diff --git a/examples/android-native/.gitignore b/examples/android-native/.gitignore
new file mode 100644
index 00000000..e2cae228
--- /dev/null
+++ b/examples/android-native/.gitignore
@@ -0,0 +1,2 @@
+/build/
+/dist/
diff --git a/examples/android-native/AndroidManifest.xml b/examples/android-native/AndroidManifest.xml
new file mode 100644
index 00000000..d23700e9
--- /dev/null
+++ b/examples/android-native/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/android-native/README.md b/examples/android-native/README.md
new file mode 100644
index 00000000..d5595c14
--- /dev/null
+++ b/examples/android-native/README.md
@@ -0,0 +1,30 @@
+# TypePHP Android native app
+
+This example compiles PHP into a self-contained Android `arm64-v8a` shared
+library and packages a signed APK using the Android SDK command-line tools. The
+TypePHP code owns the control layout, name input, click counter, reset behavior,
+and event dispatch. Java only supplies the Activity lifecycle and a generic
+native View bridge. It does not require Gradle or Android Studio.
+
+Prerequisites:
+
+- Android SDK Platform/Build Tools 36
+- Android NDK r27 or newer
+- JDK with `javac` and `keytool`
+- the PHPX Android SDK produced by `phpx/sdk/build-native.sh`
+
+Build:
+
+```sh
+export ANDROID_SDK_ROOT=/home/swoole/soft/android-sdk
+export ANDROID_NDK_HOME=/home/swoole/soft/android-ndk-r27d
+export PHPX_ANDROID_SDK_DIR=/path/to/android-arm64-v8a-sdk
+./build-app.sh
+```
+
+The result is `dist/typephp-android-hello-debug.apk`. With an arm64 Android
+device connected and USB debugging enabled, install and launch it using:
+
+```sh
+./install-app.sh
+```
diff --git a/examples/android-native/android.yml b/examples/android-native/android.yml
new file mode 100644
index 00000000..b5a35629
--- /dev/null
+++ b/examples/android-native/android.yml
@@ -0,0 +1,17 @@
+name: typephp_android_hello
+version: 0.1.0
+mode: lib
+output: dist/typephp_android_hello.so
+php-version: "8.4"
+cxx-std: c++17
+target-platform: aarch64-linux-android24
+build-dir: build/typephp
+no-progress: true
+
+sources:
+ - php-src
+ - cpp-src
+
+ld-flags:
+ - -Wl,--gc-sections
+ - -Wl,--exclude-libs,ALL
diff --git a/examples/android-native/build-app.sh b/examples/android-native/build-app.sh
new file mode 100755
index 00000000..872892fe
--- /dev/null
+++ b/examples/android-native/build-app.sh
@@ -0,0 +1,105 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+project_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+compiler_root=$(cd "${project_dir}/../.." && pwd)
+android_sdk=${ANDROID_SDK_ROOT:-${ANDROID_HOME:-/home/swoole/soft/android-sdk}}
+android_ndk=${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-/home/swoole/soft/android-ndk-r27d}}
+phpx_root=${PHPX_HOME:-$(cd "${compiler_root}/../phpx" && pwd)}
+phpx_android_sdk=${PHPX_ANDROID_SDK_DIR:-${phpx_root}/android/arm64-v8a}
+
+if [[ ! -d "${android_sdk}/build-tools" || ! -f "${android_sdk}/platforms/android-36/android.jar" ]]; then
+ echo "Android SDK Platform 36 and Build Tools are required: ${android_sdk}" >&2
+ exit 1
+fi
+if [[ ! -f "${android_ndk}/build/cmake/android.toolchain.cmake" ]]; then
+ echo "Android NDK is required: ${android_ndk}" >&2
+ exit 1
+fi
+if [[ ! -f "${phpx_android_sdk}/.typephp-android-sdk-abi" ]]; then
+ echo "TypePHP Android SDK is required: ${phpx_android_sdk}" >&2
+ exit 1
+fi
+
+build_tools_version=$(find "${android_sdk}/build-tools" -mindepth 1 -maxdepth 1 -type d \
+ -printf '%f\n' | sort -V | tail -n 1)
+build_tools=${android_sdk}/build-tools/${build_tools_version}
+android_jar=${android_sdk}/platforms/android-36/android.jar
+build_dir=${project_dir}/build/apk
+classes_dir=${build_dir}/classes
+package_dir=${build_dir}/package
+resources_dir=${build_dir}/resources
+compiled_resources_dir=${build_dir}/compiled-resources
+dist_dir=${project_dir}/dist
+
+mkdir -p \
+ "${classes_dir}" \
+ "${package_dir}/lib/arm64-v8a" \
+ "${resources_dir}/drawable-nodpi" \
+ "${compiled_resources_dir}" \
+ "${dist_dir}"
+cp -p "${compiler_root}/examples/objective-c-macos/ios-assets/AppIcon-180.png" \
+ "${resources_dir}/drawable-nodpi/typephp_icon.png"
+
+ANDROID_NDK_HOME="${android_ndk}" \
+PHPX_HOME="${phpx_root}" \
+PHPX_ANDROID_SDK_DIR="${phpx_android_sdk}" \
+php "${compiler_root}/bin/tpc.php" "${project_dir}/android.yml" --no-progress
+
+javac -source 8 -target 8 -encoding UTF-8 \
+ -bootclasspath "${android_jar}" \
+ -d "${classes_dir}" \
+ "${project_dir}/java/com/swoole/typephp/MainActivity.java"
+
+mapfile -t class_files < <(find "${classes_dir}" -type f -name '*.class' -print)
+"${build_tools}/d8" --lib "${android_jar}" --min-api 24 \
+ --output "${package_dir}" "${class_files[@]}"
+
+unsigned_apk=${build_dir}/typephp-android-hello-unsigned.apk
+aligned_apk=${build_dir}/typephp-android-hello-aligned.apk
+signed_apk=${dist_dir}/typephp-android-hello-debug.apk
+"${build_tools}/aapt2" compile \
+ --dir "${resources_dir}" \
+ -o "${compiled_resources_dir}"
+mapfile -t compiled_resources < <(find "${compiled_resources_dir}" -type f -name '*.flat' -print)
+"${build_tools}/aapt2" link \
+ -I "${android_jar}" \
+ --manifest "${project_dir}/AndroidManifest.xml" \
+ --min-sdk-version 24 \
+ --target-sdk-version 36 \
+ --version-code 1 \
+ --version-name 0.1.0 \
+ "${compiled_resources[@]}" \
+ -o "${unsigned_apk}"
+
+cp -p "${dist_dir}/typephp_android_hello.so" \
+ "${package_dir}/lib/arm64-v8a/libtypephp_android_hello.so"
+(
+ cd "${package_dir}"
+ zip -q -0 "${unsigned_apk}" lib/arm64-v8a/libtypephp_android_hello.so
+ zip -q "${unsigned_apk}" classes.dex
+)
+"${build_tools}/zipalign" -f -P 16 4 "${unsigned_apk}" "${aligned_apk}"
+
+debug_keystore=${build_dir}/debug.keystore
+if [[ ! -f "${debug_keystore}" ]]; then
+ keytool -genkeypair -noprompt \
+ -keystore "${debug_keystore}" \
+ -storepass android \
+ -alias androiddebugkey \
+ -keypass android \
+ -dname 'CN=Android Debug,O=TypePHP,C=CN' \
+ -keyalg RSA \
+ -validity 10000 >/dev/null
+fi
+"${build_tools}/apksigner" sign \
+ --ks "${debug_keystore}" \
+ --ks-pass pass:android \
+ --key-pass pass:android \
+ --out "${signed_apk}" \
+ "${aligned_apk}"
+
+"${build_tools}/zipalign" -c -P 16 4 "${signed_apk}"
+"${build_tools}/apksigner" verify --verbose "${signed_apk}"
+echo "Built signed APK: ${signed_apk}"
diff --git a/examples/android-native/cpp-src/android_bridge.cc b/examples/android-native/cpp-src/android_bridge.cc
new file mode 100644
index 00000000..84d178e9
--- /dev/null
+++ b/examples/android-native/cpp-src/android_bridge.cc
@@ -0,0 +1,196 @@
+#include
+#include
+#include
+
+#include
+
+TYPEPHP_RUNTIME_INIT_FUNCTION(typephp_android_hello);
+extern void php_typephp_application_did_launch();
+extern void php_typephp_application_control_activated(php::Int control_id);
+
+static JavaVM *java_vm = nullptr;
+static jobject activity = nullptr;
+static jclass activity_class = nullptr;
+static std::once_flag runtime_once;
+static int runtime_status = -1;
+
+static JNIEnv *get_env()
+{
+ JNIEnv *env = nullptr;
+ if (java_vm == nullptr || java_vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) {
+ return nullptr;
+ }
+ return env;
+}
+
+static jmethodID method(JNIEnv *env, const char *name, const char *signature)
+{
+ return activity_class == nullptr ? nullptr : env->GetMethodID(activity_class, name, signature);
+}
+
+static jstring to_java_string(JNIEnv *env, const php::Str &value)
+{
+ return env->NewStringUTF(value.data());
+}
+
+static php::Str from_java_string(JNIEnv *env, jstring value)
+{
+ if (value == nullptr) {
+ return php::Str();
+ }
+ const char *utf8 = env->GetStringUTFChars(value, nullptr);
+ php::Str result(utf8 == nullptr ? "" : utf8);
+ if (utf8 != nullptr) {
+ env->ReleaseStringUTFChars(value, utf8);
+ }
+ return result;
+}
+
+static int ensure_runtime()
+{
+ std::call_once(runtime_once, []() {
+ char application_name[] = "typephp_android_hello";
+ char *argv[] = {application_name, nullptr};
+ runtime_status = TYPEPHP_RUNTIME_INIT(typephp_android_hello)(1, argv);
+ });
+ return runtime_status;
+}
+
+extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *)
+{
+ java_vm = vm;
+ return JNI_VERSION_1_6;
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_swoole_typephp_MainActivity_nativeApplicationDidLaunch(JNIEnv *env, jobject instance)
+{
+ if (activity != nullptr) {
+ env->DeleteGlobalRef(activity);
+ }
+ if (activity_class != nullptr) {
+ env->DeleteGlobalRef(activity_class);
+ }
+ activity = env->NewGlobalRef(instance);
+ jclass local_class = env->GetObjectClass(instance);
+ activity_class = reinterpret_cast(env->NewGlobalRef(local_class));
+ env->DeleteLocalRef(local_class);
+
+ if (ensure_runtime() == 0) {
+ php_typephp_application_did_launch();
+ }
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_swoole_typephp_MainActivity_nativeControlActivated(JNIEnv *, jobject, jint control_id)
+{
+ if (runtime_status == 0) {
+ php_typephp_application_control_activated(static_cast(control_id));
+ }
+}
+
+void php_ui_create_window(php::Str title, php::Int width, php::Int height)
+{
+ JNIEnv *env = get_env();
+ jmethodID target = env == nullptr ? nullptr : method(env, "uiCreateWindow", "(Ljava/lang/String;II)V");
+ if (target == nullptr) {
+ return;
+ }
+ jstring java_title = to_java_string(env, title);
+ env->CallVoidMethod(activity, target, java_title, static_cast(width), static_cast(height));
+ env->DeleteLocalRef(java_title);
+}
+
+php::Int php_ui_add_image(php::Str resource_name, php::Int x, php::Int y, php::Int width, php::Int height)
+{
+ JNIEnv *env = get_env();
+ jmethodID target = env == nullptr ? nullptr : method(env, "uiAddImage", "(Ljava/lang/String;IIII)I");
+ if (target == nullptr) {
+ return 0;
+ }
+ jstring java_name = to_java_string(env, resource_name);
+ jint result = env->CallIntMethod(activity, target, java_name, static_cast(x), static_cast(y),
+ static_cast(width), static_cast(height));
+ env->DeleteLocalRef(java_name);
+ return static_cast(result);
+}
+
+php::Int php_ui_add_label(php::Str text, php::Int x, php::Int y, php::Int width, php::Int height,
+ php::Int font_size, php::Bool bold)
+{
+ JNIEnv *env = get_env();
+ jmethodID target = env == nullptr ? nullptr : method(env, "uiAddLabel", "(Ljava/lang/String;IIIIIZ)I");
+ if (target == nullptr) {
+ return 0;
+ }
+ jstring java_text = to_java_string(env, text);
+ jint result = env->CallIntMethod(activity, target, java_text, static_cast(x), static_cast(y),
+ static_cast(width), static_cast(height),
+ static_cast(font_size), static_cast(bold));
+ env->DeleteLocalRef(java_text);
+ return static_cast(result);
+}
+
+php::Int php_ui_add_text_input(php::Str hint, php::Int x, php::Int y, php::Int width, php::Int height)
+{
+ JNIEnv *env = get_env();
+ jmethodID target = env == nullptr ? nullptr : method(env, "uiAddTextInput", "(Ljava/lang/String;IIII)I");
+ if (target == nullptr) {
+ return 0;
+ }
+ jstring java_hint = to_java_string(env, hint);
+ jint result = env->CallIntMethod(activity, target, java_hint, static_cast(x), static_cast(y),
+ static_cast(width), static_cast(height));
+ env->DeleteLocalRef(java_hint);
+ return static_cast(result);
+}
+
+php::Int php_ui_add_button(php::Str title, php::Int x, php::Int y, php::Int width, php::Int height, php::Int style)
+{
+ JNIEnv *env = get_env();
+ jmethodID target = env == nullptr ? nullptr : method(env, "uiAddButton", "(Ljava/lang/String;IIIII)I");
+ if (target == nullptr) {
+ return 0;
+ }
+ jstring java_title = to_java_string(env, title);
+ jint result = env->CallIntMethod(activity, target, java_title, static_cast(x), static_cast(y),
+ static_cast(width), static_cast(height), static_cast(style));
+ env->DeleteLocalRef(java_title);
+ return static_cast(result);
+}
+
+void php_ui_set_control_text(php::Int control_id, php::Str text)
+{
+ JNIEnv *env = get_env();
+ jmethodID target = env == nullptr ? nullptr : method(env, "uiSetControlText", "(ILjava/lang/String;)V");
+ if (target == nullptr) {
+ return;
+ }
+ jstring java_text = to_java_string(env, text);
+ env->CallVoidMethod(activity, target, static_cast(control_id), java_text);
+ env->DeleteLocalRef(java_text);
+}
+
+php::Str php_ui_get_control_text(php::Int control_id)
+{
+ JNIEnv *env = get_env();
+ jmethodID target = env == nullptr ? nullptr : method(env, "uiGetControlText", "(I)Ljava/lang/String;");
+ if (target == nullptr) {
+ return php::Str();
+ }
+ auto value = static_cast(env->CallObjectMethod(activity, target, static_cast(control_id)));
+ php::Str result = from_java_string(env, value);
+ if (value != nullptr) {
+ env->DeleteLocalRef(value);
+ }
+ return result;
+}
+
+void php_ui_show_window()
+{
+ JNIEnv *env = get_env();
+ jmethodID target = env == nullptr ? nullptr : method(env, "uiShowWindow", "()V");
+ if (target != nullptr) {
+ env->CallVoidMethod(activity, target);
+ }
+}
diff --git a/examples/android-native/cpp-src/android_ui.stub.php b/examples/android-native/cpp-src/android_ui.stub.php
new file mode 100644
index 00000000..f0958787
--- /dev/null
+++ b/examples/android-native/cpp-src/android_ui.stub.php
@@ -0,0 +1,25 @@
+&2; exit 1; }
+[[ -f "${apk}" ]] || { echo "Build the APK first with ./build-app.sh" >&2; exit 1; }
+
+"${adb}" install -r "${apk}"
+"${adb}" shell am start -n com.swoole.typephp/.MainActivity
diff --git a/examples/android-native/java/com/swoole/typephp/MainActivity.java b/examples/android-native/java/com/swoole/typephp/MainActivity.java
new file mode 100644
index 00000000..4c3ed68b
--- /dev/null
+++ b/examples/android-native/java/com/swoole/typephp/MainActivity.java
@@ -0,0 +1,184 @@
+package com.swoole.typephp;
+
+import android.app.Activity;
+import android.content.res.ColorStateList;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.os.Bundle;
+import android.util.SparseArray;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+public final class MainActivity extends Activity {
+ static {
+ System.loadLibrary("typephp_android_hello");
+ }
+
+ private native void nativeApplicationDidLaunch();
+ private native void nativeControlActivated(int controlId);
+
+ private final SparseArray controls = new SparseArray<>();
+ private int nextControlId = 1;
+ private LogicalLayout content;
+
+ @Override
+ protected void onCreate(Bundle state) {
+ super.onCreate(state);
+ nativeApplicationDidLaunch();
+ }
+
+ private int storeControl(View control, int x, int y, int width, int height) {
+ int controlId = nextControlId++;
+ control.setId(controlId);
+ controls.put(controlId, control);
+ content.addView(control, content.new LayoutParams(x, y, width, height));
+ return controlId;
+ }
+
+ private void uiCreateWindow(String title, int width, int height) {
+ setTitle(title);
+ content = new LogicalLayout(width, height);
+ content.setBackgroundColor(Color.rgb(247, 249, 252));
+ setContentView(content);
+ }
+
+ private int uiAddImage(String resourceName, int x, int y, int width, int height) {
+ ImageView image = new ImageView(this);
+ int resourceId = getResources().getIdentifier(resourceName, "drawable", getPackageName());
+ if (resourceId != 0) {
+ image.setImageResource(resourceId);
+ }
+ image.setScaleType(ImageView.ScaleType.CENTER_INSIDE);
+ return storeControl(image, x, y, width, height);
+ }
+
+ private int uiAddLabel(
+ String text,
+ int x,
+ int y,
+ int width,
+ int height,
+ int fontSize,
+ boolean bold) {
+ TextView label = new TextView(this);
+ label.setText(text);
+ label.setTextSize(fontSize);
+ label.setTextColor(bold ? Color.rgb(24, 36, 54) : Color.rgb(90, 103, 122));
+ label.setTypeface(Typeface.DEFAULT, bold ? Typeface.BOLD : Typeface.NORMAL);
+ label.setGravity(Gravity.CENTER);
+ return storeControl(label, x, y, width, height);
+ }
+
+ private int uiAddTextInput(String hint, int x, int y, int width, int height) {
+ EditText input = new EditText(this);
+ input.setHint(hint);
+ input.setSingleLine(true);
+ input.setTextSize(18);
+ input.setPadding(dp(12), 0, dp(12), 0);
+ return storeControl(input, x, y, width, height);
+ }
+
+ private int uiAddButton(String title, int x, int y, int width, int height, int style) {
+ Button button = new Button(this);
+ button.setText(title);
+ button.setTextSize(16);
+ if (style == 1) {
+ button.setTextColor(Color.WHITE);
+ button.setBackgroundTintList(ColorStateList.valueOf(Color.rgb(0, 125, 214)));
+ } else {
+ button.setTextColor(Color.rgb(0, 92, 164));
+ button.setBackgroundTintList(ColorStateList.valueOf(Color.rgb(220, 236, 249)));
+ }
+ final int controlId = storeControl(button, x, y, width, height);
+ button.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ nativeControlActivated(controlId);
+ }
+ });
+ return controlId;
+ }
+
+ private void uiSetControlText(int controlId, String text) {
+ View control = controls.get(controlId);
+ if (control instanceof TextView) {
+ ((TextView) control).setText(text);
+ }
+ }
+
+ private String uiGetControlText(int controlId) {
+ View control = controls.get(controlId);
+ return control instanceof TextView ? ((TextView) control).getText().toString() : "";
+ }
+
+ private void uiShowWindow() {
+ content.setVisibility(View.VISIBLE);
+ }
+
+ private int dp(float value) {
+ return Math.round(value * getResources().getDisplayMetrics().density);
+ }
+
+ private final class LogicalLayout extends ViewGroup {
+ private final int logicalWidth;
+ private final int logicalHeight;
+
+ private LogicalLayout(int width, int height) {
+ super(MainActivity.this);
+ logicalWidth = width;
+ logicalHeight = height;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
+ int measuredHeight = MeasureSpec.getSize(heightMeasureSpec);
+ float scaleX = (float) measuredWidth / logicalWidth;
+ float scaleY = (float) measuredHeight / logicalHeight;
+ for (int i = 0; i < getChildCount(); i++) {
+ LayoutParams params = (LayoutParams) getChildAt(i).getLayoutParams();
+ getChildAt(i).measure(
+ MeasureSpec.makeMeasureSpec(Math.round(params.logicalWidth * scaleX), MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(Math.round(params.logicalHeight * scaleY), MeasureSpec.EXACTLY));
+ }
+ setMeasuredDimension(measuredWidth, measuredHeight);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ float scaleX = (float) (right - left) / logicalWidth;
+ float scaleY = (float) (bottom - top) / logicalHeight;
+ for (int i = 0; i < getChildCount(); i++) {
+ View child = getChildAt(i);
+ LayoutParams params = (LayoutParams) child.getLayoutParams();
+ int childLeft = Math.round(params.logicalX * scaleX);
+ int childTop = Math.round(params.logicalY * scaleY);
+ child.layout(
+ childLeft,
+ childTop,
+ childLeft + child.getMeasuredWidth(),
+ childTop + child.getMeasuredHeight());
+ }
+ }
+
+ private final class LayoutParams extends ViewGroup.LayoutParams {
+ private final int logicalX;
+ private final int logicalY;
+ private final int logicalWidth;
+ private final int logicalHeight;
+
+ private LayoutParams(int x, int y, int width, int height) {
+ super(width, height);
+ logicalX = x;
+ logicalY = y;
+ logicalWidth = width;
+ logicalHeight = height;
+ }
+ }
+ }
+}
diff --git a/examples/android-native/php-src/android-main.php b/examples/android-native/php-src/android-main.php
new file mode 100644
index 00000000..373159f0
--- /dev/null
+++ b/examples/android-native/php-src/android-main.php
@@ -0,0 +1,14 @@
+