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 @@ +