feat(android): add native Android application example with complete UI framework

- Create Android project structure with build configuration and manifest
- Implement PHP-to-Java bridge with JNI integration for native Android UI components
- Add comprehensive UI stub functions including window creation, labels, buttons, and inputs
- Develop Android Activity with native View bridge and layout management system
- Integrate build scripts for compiling PHP into Android shared library and packaging APK
- Include installation script with ADB deployment and application launching
- Provide complete Hello World application demonstrating TypePHP Android capabilities
- Add proper Git ignore rules and documentation for the Android example
master
韩天峰 1 month ago
parent 8dfb363422
commit a0db503e3d
  1. 2
      examples/android-native/.gitignore
  2. 20
      examples/android-native/AndroidManifest.xml
  3. 30
      examples/android-native/README.md
  4. 17
      examples/android-native/android.yml
  5. 105
      examples/android-native/build-app.sh
  6. 196
      examples/android-native/cpp-src/android_bridge.cc
  7. 25
      examples/android-native/cpp-src/android_ui.stub.php
  8. 14
      examples/android-native/install-app.sh
  9. 184
      examples/android-native/java/com/swoole/typephp/MainActivity.java
  10. 14
      examples/android-native/php-src/android-main.php
  11. 88
      examples/android-native/php-src/application.php

@ -0,0 +1,2 @@
/build/
/dist/

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.swoole.typephp">
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="36" />
<application
android:allowBackup="false"
android:extractNativeLibs="false"
android:icon="@drawable/typephp_icon"
android:label="TypePHP Android"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

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

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

@ -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}"

@ -0,0 +1,196 @@
#include <jni.h>
#include <phpx.h>
#include <typephp_runtime.h>
#include <mutex>
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<void **>(&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<jclass>(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<php::Int>(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<jint>(width), static_cast<jint>(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<jint>(x), static_cast<jint>(y),
static_cast<jint>(width), static_cast<jint>(height));
env->DeleteLocalRef(java_name);
return static_cast<php::Int>(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<jint>(x), static_cast<jint>(y),
static_cast<jint>(width), static_cast<jint>(height),
static_cast<jint>(font_size), static_cast<jboolean>(bold));
env->DeleteLocalRef(java_text);
return static_cast<php::Int>(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<jint>(x), static_cast<jint>(y),
static_cast<jint>(width), static_cast<jint>(height));
env->DeleteLocalRef(java_hint);
return static_cast<php::Int>(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<jint>(x), static_cast<jint>(y),
static_cast<jint>(width), static_cast<jint>(height), static_cast<jint>(style));
env->DeleteLocalRef(java_title);
return static_cast<php::Int>(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<jint>(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<jstring>(env->CallObjectMethod(activity, target, static_cast<jint>(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);
}
}

@ -0,0 +1,25 @@
<?php
function ui_create_window(string $title, int $width, int $height): void {}
function ui_add_image(string $resourceName, int $x, int $y, int $width, int $height): int {}
function ui_add_label(
string $text,
int $x,
int $y,
int $width,
int $height,
int $fontSize,
bool $bold,
): int {}
function ui_add_text_input(string $hint, int $x, int $y, int $width, int $height): int {}
function ui_add_button(string $title, int $x, int $y, int $width, int $height, int $style): int {}
function ui_set_control_text(int $controlId, string $text): void {}
function ui_get_control_text(int $controlId): string {}
function ui_show_window(): void {}

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
project_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
android_sdk=${ANDROID_SDK_ROOT:-${ANDROID_HOME:-/home/swoole/soft/android-sdk}}
adb=${android_sdk}/platform-tools/adb
apk=${project_dir}/dist/typephp-android-hello-debug.apk
[[ -x "${adb}" ]] || { echo "adb was not found: ${adb}" >&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

@ -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<View> 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;
}
}
}
}

@ -0,0 +1,14 @@
<?php
/** Called by the thin Android Activity after its native library is loaded. */
function typephp_application_did_launch(): void
{
AndroidHelloApplication::build();
ui_show_window();
}
/** Called by the generic Android View bridge for every activated control. */
function typephp_application_control_activated(int $controlId): void
{
AndroidHelloApplication::handleEvent($controlId);
}

@ -0,0 +1,88 @@
<?php
final class AndroidHelloApplication
{
private const BUTTON_PRIMARY = 1;
private const BUTTON_SECONDARY = 2;
private static int $nameInput = 0;
private static int $statusLabel = 0;
private static int $countButton = 0;
private static int $resetButton = 0;
private static int $clickCount = 0;
public static function build(): void
{
ui_create_window('TypePHP Android', 390, 844);
ui_add_image('typephp_icon', 105, 36, 180, 96);
ui_add_label('TypePHP Native App', 24, 142, 342, 52, 29, true);
ui_add_label(
"界面结构、应用状态和事件逻辑均由 TypePHP 实现。\n" .
'Java 仅提供 Activity 和原生 View 桥接。',
30,
202,
330,
74,
15,
false,
);
self::$nameInput = ui_add_text_input('请输入名字', 30, 300, 330, 56);
self::$statusLabel = ui_add_label('', 30, 378, 330, 74, 18, true);
self::$countButton = ui_add_button(
'使用 TypePHP 点击计数',
30,
476,
330,
58,
self::BUTTON_PRIMARY,
);
self::$resetButton = ui_add_button(
'重置计数',
105,
552,
180,
52,
self::BUTTON_SECONDARY,
);
self::renderStatus(false);
}
public static function handleEvent(int $controlId): void
{
if ($controlId === self::$resetButton) {
self::$clickCount = 0;
self::renderStatus(true);
return;
}
if ($controlId !== self::$countButton) {
return;
}
self::$clickCount++;
self::renderStatus(false);
}
private static function renderStatus(bool $wasReset): void
{
$name = ui_get_control_text(self::$nameInput);
if ($name === '') {
$name = 'Android';
}
if ($wasReset) {
ui_set_control_text(self::$statusLabel, $name . ',计数已重置');
return;
}
if (self::$clickCount === 0) {
ui_set_control_text(self::$statusLabel, '输入名字,然后开始计数');
return;
}
ui_set_control_text(
self::$statusLabel,
'你好,' . $name . '!已点击 ' . self::$clickCount . ' 次',
);
}
}
Loading…
Cancel
Save