diff --git a/.github/workflows/godot-android-raw-wire.yml b/.github/workflows/godot-android-raw-wire.yml index 70806290..10bf11ce 100644 --- a/.github/workflows/godot-android-raw-wire.yml +++ b/.github/workflows/godot-android-raw-wire.yml @@ -1,9 +1,10 @@ -name: Godot Android raw-wire POC +name: Godot Android socket-only POC on: push: branches: - feature/godot-android-raw-wire-poc + - feature/godot-android-api-wire-poc pull_request: branches: - upstream/android-oboe @@ -39,24 +40,31 @@ jobs: with: gradle-version: "8.13" - - name: Build AMY AAR + - name: Build AMY service AAR run: bash godot/android-hello-world/prepare.sh - - name: Verify AMY AAR contents + - name: Verify AMY AAR contains service only run: | AAR=godot/android-hello-world/addons/amy_android/amy-service-debug.aar test -s "$AAR" unzip -l "$AAR" | tee /tmp/amy-aar.txt grep -q 'jni/arm64-v8a/libamy_android.so' /tmp/amy-aar.txt - grep -q 'jni/arm64-v8a/libamy_android_client.so' /tmp/amy-aar.txt grep -q 'jni/x86_64/libamy_android.so' /tmp/amy-aar.txt - grep -q 'jni/x86_64/libamy_android_client.so' /tmp/amy-aar.txt + ! grep -q 'libamy_android_client.so' /tmp/amy-aar.txt unzip -p "$AAR" classes.jar > /tmp/amy-classes.jar unzip -l /tmp/amy-classes.jar | tee /tmp/amy-classes.txt grep -q 'org/amy/audio/AmyService.class' /tmp/amy-classes.txt grep -q 'org/amy/audio/AmyClient.class' /tmp/amy-classes.txt grep -q 'org/amy/audio/AmyAutoStartProvider.class' /tmp/amy-classes.txt + - name: Guard against Android AMY GDExtension + run: | + # Desktop/web AMY integration may continue to exist in godot/, but + # Android must use only the independent service AAR and wire socket. + ! grep -Eq '^android\.' godot/amy.gdextension + test ! -e godot/android-hello-world/amy.gdextension + test ! -d godot/android-hello-world/addons/amy/bin + - name: Install Godot 4.7.2 and Android export templates run: | curl -L --fail --retry 3 \ @@ -79,7 +87,10 @@ jobs: godot --version - name: Import Godot project - run: godot --headless --path godot/android-hello-world --import --quit + run: | + godot --headless --path godot/android-hello-world --import --quit 2>&1 | tee /tmp/godot-import.log + ! grep -q 'SCRIPT ERROR' /tmp/godot-import.log + ! grep -q 'ERROR: Failed to load script' /tmp/godot-import.log - name: Export ARM64 phone APK run: | @@ -97,7 +108,7 @@ jobs: --export-debug "Android CI x86_64" build/amy-godot-raw-wire-x86_64.apk test -s godot/android-hello-world/build/amy-godot-raw-wire-x86_64.apk - - name: Verify APK packaging + - name: Verify Android APK has exactly one AMY native implementation run: | ARM=godot/android-hello-world/build/amy-godot-raw-wire-arm64.apk X86=godot/android-hello-world/build/amy-godot-raw-wire-x86_64.apk @@ -105,14 +116,18 @@ jobs: unzip -l "$ARM" | tee /tmp/arm-apk.txt grep -q 'lib/arm64-v8a/libgodot_android.so' /tmp/arm-apk.txt grep -q 'lib/arm64-v8a/libamy_android.so' /tmp/arm-apk.txt - grep -q 'lib/arm64-v8a/libamy_android_client.so' /tmp/arm-apk.txt + ! grep -q 'libamy_android_client.so' /tmp/arm-apk.txt ! grep -q 'lib/x86_64/' /tmp/arm-apk.txt + test "$(grep -Ec 'lib/arm64-v8a/libamy[^ ]*\.so' /tmp/arm-apk.txt)" -eq 1 + ! zipinfo -1 "$ARM" | grep -q 'amy.gdextension' unzip -l "$X86" | tee /tmp/x86-apk.txt grep -q 'lib/x86_64/libgodot_android.so' /tmp/x86-apk.txt grep -q 'lib/x86_64/libamy_android.so' /tmp/x86-apk.txt - grep -q 'lib/x86_64/libamy_android_client.so' /tmp/x86-apk.txt + ! grep -q 'libamy_android_client.so' /tmp/x86-apk.txt ! grep -q 'lib/arm64-v8a/' /tmp/x86-apk.txt + test "$(grep -Ec 'lib/x86_64/libamy[^ ]*\.so' /tmp/x86-apk.txt)" -eq 1 + ! zipinfo -1 "$X86" | grep -q 'amy.gdextension' for apk in "$ARM" "$X86"; do for dex in $(zipinfo -1 "$apk" | grep -E '^classes([0-9]+)?\.dex$'); do diff --git a/android/amy-service/src/main/cpp/CMakeLists.txt b/android/amy-service/src/main/cpp/CMakeLists.txt index 40812ca0..9794d4c3 100644 --- a/android/amy-service/src/main/cpp/CMakeLists.txt +++ b/android/amy-service/src/main/cpp/CMakeLists.txt @@ -29,6 +29,9 @@ set(AMY_SOURCES ${AMY_SRC}/cv_trigger.c ) +# The only native library built for Android is the independent AMY/Oboe +# service implementation. Client/framework code uses Android LocalSocket and +# never links AMY or a Godot AMY GDExtension. add_library(amy_android SHARED amy_android.cpp amy_android_capture.cpp @@ -36,13 +39,6 @@ add_library(amy_android SHARED ${AMY_SOURCES} ) -# Tiny client used by non-AMY processes (Godot in this proof of concept). -# It contains no synth/audio engine: only one AF_UNIX/SOCK_SEQPACKET connection -# to the separate :amy service process. -add_library(amy_android_client SHARED - amy_android_client.cpp -) - target_include_directories(amy_android PRIVATE ${AMY_SRC} ${CMAKE_CURRENT_SOURCE_DIR} @@ -68,12 +64,7 @@ target_compile_options(amy_android PRIVATE $<$:-O3;-Wall;-Wextra;-Wno-unused-parameter> ) -target_compile_options(amy_android_client PRIVATE - -O3 -Wall -Wextra -Wno-unused-parameter -) - target_compile_features(amy_android PRIVATE c_std_11 cxx_std_17) -target_compile_features(amy_android_client PRIVATE cxx_std_17) target_link_libraries(amy_android PRIVATE oboe::oboe diff --git a/android/amy-service/src/main/cpp/amy_android_client.cpp b/android/amy-service/src/main/cpp/amy_android_client.cpp deleted file mode 100644 index 67cdfff1..00000000 --- a/android/amy-service/src/main/cpp/amy_android_client.cpp +++ /dev/null @@ -1,73 +0,0 @@ -#include - -#include -#include -#include - -#include -#include -#include - -namespace { - -int connect_socket(const char *path) { - if (path == nullptr || path[0] == '\0') return -EINVAL; - - sockaddr_un addr{}; - const size_t path_len = std::strlen(path); - if (path_len >= sizeof(addr.sun_path)) return -ENAMETOOLONG; - - addr.sun_family = AF_UNIX; - std::memcpy(addr.sun_path, path, path_len + 1u); - - const int fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0); - if (fd < 0) return -errno; - - if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) < 0) { - const int saved = errno; - close(fd); - return -saved; - } - return fd; -} - -int send_wire(int fd, JNIEnv *env, jstring wire) { - if (fd < 0) return -ENOTCONN; - if (wire == nullptr) return -EINVAL; - - const jsize len = env->GetStringUTFLength(wire); - if (len <= 0) return -EINVAL; - - const char *bytes = env->GetStringUTFChars(wire, nullptr); - if (bytes == nullptr) return -ENOMEM; - - const ssize_t sent = send(fd, bytes, static_cast(len), MSG_NOSIGNAL); - const int saved = sent < 0 ? errno : 0; - env->ReleaseStringUTFChars(wire, bytes); - - if (sent < 0) return -saved; - if (sent != len) return -EIO; - return 0; -} - -} // namespace - -extern "C" JNIEXPORT jint JNICALL -Java_org_amy_audio_AmyClient_nativeConnect(JNIEnv *env, jclass, jstring socketPath) { - if (socketPath == nullptr) return -EINVAL; - const char *path = env->GetStringUTFChars(socketPath, nullptr); - if (path == nullptr) return -ENOMEM; - const int result = connect_socket(path); - env->ReleaseStringUTFChars(socketPath, path); - return result; -} - -extern "C" JNIEXPORT jint JNICALL -Java_org_amy_audio_AmyClient_nativeSend(JNIEnv *env, jclass, jint fd, jstring wire) { - return send_wire(fd, env, wire); -} - -extern "C" JNIEXPORT void JNICALL -Java_org_amy_audio_AmyClient_nativeClose(JNIEnv *, jclass, jint fd) { - if (fd >= 0) close(fd); -} diff --git a/android/amy-service/src/main/java/org/amy/audio/AmyClient.java b/android/amy-service/src/main/java/org/amy/audio/AmyClient.java index 9b173df8..b9b9de2e 100644 --- a/android/amy-service/src/main/java/org/amy/audio/AmyClient.java +++ b/android/amy-service/src/main/java/org/amy/audio/AmyClient.java @@ -1,18 +1,27 @@ package org.amy.audio; import android.content.Context; +import android.net.LocalSocket; +import android.net.LocalSocketAddress; import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; -/** Minimal client for the private AMY SOCK_SEQPACKET socket. */ +/** + * Minimal pure-Java client for the private AMY SOCK_SEQPACKET socket. + * + * This class is transport only: it contains no AMY engine code, does not start + * or stop AmyService, and does not load any JNI/native client library. + */ public final class AmyClient { private static final int EINVAL = 22; + private static final int EIO = 5; private static final int ENOTCONN = 107; - private static int nativeFd = -1; - static { - System.loadLibrary("amy_android_client"); - } + private static LocalSocket socket; + private static OutputStream output; private AmyClient() {} @@ -22,23 +31,41 @@ private static String socketPath(Context context) { .getAbsolutePath(); } - /** Connect once to filesDir/amy.sock. Returns 0 or negative errno. */ + /** Connect once to filesDir/amy.sock. Returns 0 or a negative errno-style value. */ public static synchronized int connect(Context context) { if (context == null) return -EINVAL; closeLocked(); - int fd = nativeConnect(socketPath(context)); - if (fd < 0) return fd; - nativeFd = fd; - return 0; + + LocalSocket candidate = new LocalSocket(LocalSocket.SOCKET_SEQPACKET); + LocalSocketAddress address = new LocalSocketAddress( + socketPath(context), LocalSocketAddress.Namespace.FILESYSTEM); + try { + candidate.connect(address); + output = candidate.getOutputStream(); + socket = candidate; + return 0; + } catch (IOException ex) { + try { + candidate.close(); + } catch (IOException ignored) { + } + return -EIO; + } } /** Send exactly one AMY wire command as one SOCK_SEQPACKET packet. */ public static synchronized int sendWire(String wire) { - if (nativeFd < 0) return -ENOTCONN; + if (socket == null || output == null) return -ENOTCONN; if (wire == null || wire.isEmpty()) return -EINVAL; - int result = nativeSend(nativeFd, wire); - if (result < 0) closeLocked(); - return result; + + try { + output.write(wire.getBytes(StandardCharsets.US_ASCII)); + output.flush(); + return 0; + } catch (IOException ex) { + closeLocked(); + return -EIO; + } } public static synchronized void close() { @@ -46,13 +73,13 @@ public static synchronized void close() { } private static void closeLocked() { - if (nativeFd >= 0) { - nativeClose(nativeFd); - nativeFd = -1; + output = null; + if (socket != null) { + try { + socket.close(); + } catch (IOException ignored) { + } + socket = null; } } - - private static native int nativeConnect(String socketPath); - private static native int nativeSend(int fd, String wire); - private static native void nativeClose(int fd); } diff --git a/godot/amy.gd b/godot/amy.gd index a59651a4..9ee1e249 100644 --- a/godot/amy.gd +++ b/godot/amy.gd @@ -3,7 +3,8 @@ extends Node ## AMY Synthesizer for Godot. ## ## High-level GDScript API that mirrors AMY's Python interface. -## Works on native (GDExtension) and web (WASM via JavaScriptBridge). +## Works on native (GDExtension), Android (private AMY service socket), and web +## (WASM via JavaScriptBridge). ## ## Usage: ## var amy := Amy.new() @@ -14,6 +15,9 @@ extends Node ## Or use wire protocol directly: ## amy.send_raw("v0w0f440l1") +signal backend_ready +signal backend_error(message: String) + # ============================================================ # Wave types # ============================================================ @@ -81,20 +85,31 @@ const ENVELOPE_TRUE_EXPONENTIAL: int = 3 @export var max_voices: int = 64 ## Maximum number of synths. @export var max_synths: int = 64 +## Log each successfully sent Android wire packet. Intended for diagnostics/tests. +@export var debug_wire: bool = false # ============================================================ -# Internals — audio bridge +# Internals — audio / transport bridge # ============================================================ var _synth: Node = null var _stream_player: AudioStreamPlayer = null var _playback: AudioStreamGeneratorPlayback = null var _started: bool = false var _is_web: bool = false +var _is_android: bool = false +# Deliberately leave Java wrappers as Variant. Typing the wrapped class as +# Object makes GDScript bind connect() to Object.connect(signal, callable) +# instead of dynamically dispatching to AmyClient.connect(Context). +var _amy_client +var _android_context func _ready() -> void: _is_web = OS.get_name() == "Web" + _is_android = OS.get_name() == "Android" if _is_web: _init_web() + elif _is_android: + _init_android() else: _init_native() @@ -103,7 +118,9 @@ func _init_native() -> void: _synth = ClassDB.instantiate(&"AmySynth") add_child(_synth) else: - push_warning("AmySynth GDExtension not loaded — audio disabled") + var message := "AmySynth GDExtension not loaded — audio disabled" + push_warning(message) + backend_error.emit(message) return # Apply config before starting @@ -132,6 +149,45 @@ func _init_native() -> void: _stream_player.play() _playback = _stream_player.get_stream_playback() as AudioStreamGeneratorPlayback _started = true + backend_ready.emit() + +func _init_android() -> void: + # The AAR owns AmyService lifecycle. Amy.gd is only an ordinary same-UID + # client of filesDir/amy.sock; it never starts or stops the service. + var runtime = Engine.get_singleton("AndroidRuntime") + if runtime == null: + _android_fail("AndroidRuntime unavailable") + return + + _android_context = runtime.getApplicationContext() + if _android_context == null: + _android_fail("Android application context unavailable") + return + + _amy_client = JavaClassWrapper.wrap("org.amy.audio.AmyClient") + if _amy_client == null: + _android_fail("AmyClient class unavailable") + return + if not _amy_client.has_java_method("connect") or not _amy_client.has_java_method("sendWire") or not _amy_client.has_java_method("close"): + _android_fail("AmyClient methods unavailable") + return + + # The service publishes amy.sock only after Oboe has delivered its first + # realtime callback, so a successful connect is also the readiness boundary. + for _attempt in range(200): + var rc: int = int(_amy_client.connect(_android_context)) + var exception = JavaClassWrapper.get_exception() + if exception != null: + _android_fail("AmyClient.connect exception: %s" % str(exception)) + return + if rc == 0: + _started = true + print("AMY Android connected to amy.sock") + backend_ready.emit() + return + await get_tree().create_timer(0.05).timeout + + _android_fail("Could not connect to amy.sock") func _init_web() -> void: # Pass config to JS bridge before AMY starts @@ -144,12 +200,15 @@ func _init_web() -> void: if ready: _started = true print("AMY web synth ready") + backend_ready.emit() return await get_tree().create_timer(0.1).timeout - push_warning("AMY web module failed to load after 10 s") + var message := "AMY web module failed to load after 10 s" + push_warning(message) + backend_error.emit(message) func _process(_delta: float) -> void: - if _started and not _is_web: + if _started and not _is_web and not _is_android: _fill_audio() func _fill_audio() -> void: @@ -162,9 +221,19 @@ func _fill_audio() -> void: _playback.push_buffer(buffer as PackedVector2Array) func _exit_tree() -> void: - if not _is_web and _synth: + if _is_android: + if _amy_client != null: + _amy_client.close() + _amy_client = null + _started = false + elif not _is_web and _synth: _synth.call("stop") +func _android_fail(message: String) -> void: + _started = false + push_error(message) + backend_error.emit(message) + # ============================================================ # Public API # ============================================================ @@ -202,7 +271,18 @@ func message(params: Dictionary) -> String: func send_raw(msg: String) -> void: if not _started or msg.is_empty(): return - if _is_web: + if _is_android: + var rc: int = int(_amy_client.sendWire(msg)) + var exception = JavaClassWrapper.get_exception() + if exception != null: + _android_fail("AmyClient.sendWire exception: %s" % str(exception)) + return + if rc != 0: + _android_fail("amy.sock send failed: %d" % rc) + return + if debug_wire: + print("AMY Android wire: %s" % msg) + elif _is_web: var safe: String = msg.replace("\\", "\\\\").replace("'", "\\'") JavaScriptBridge.eval("godot_amy_send('%s')" % safe) else: diff --git a/godot/android-hello-world/export_presets.cfg b/godot/android-hello-world/export_presets.cfg index 8b8e8844..296af855 100644 --- a/godot/android-hello-world/export_presets.cfg +++ b/godot/android-hello-world/export_presets.cfg @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="" -export_path="build/amy-godot-raw-wire-arm64.apk" +export_path="build/amy-godot-api-wire-arm64.apk" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -36,7 +36,7 @@ architectures/x86_64=false version/code=1 version/name="1.0" package/unique_name="org.amy.godothello" -package/name="AMY Godot Raw Wire POC" +package/name="AMY Godot API Wire POC" package/signed=true package/app_category=2 package/retain_data_on_uninstall=false @@ -56,7 +56,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="" -export_path="build/amy-godot-raw-wire-x86_64.apk" +export_path="build/amy-godot-api-wire-x86_64.apk" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -83,7 +83,7 @@ architectures/x86_64=true version/code=1 version/name="1.0" package/unique_name="org.amy.godothello" -package/name="AMY Godot Raw Wire POC" +package/name="AMY Godot API Wire POC" package/signed=true package/app_category=2 package/retain_data_on_uninstall=false diff --git a/godot/android-hello-world/main.gd b/godot/android-hello-world/main.gd index 71e08ea2..e7ea0e3d 100644 --- a/godot/android-hello-world/main.gd +++ b/godot/android-hello-world/main.gd @@ -1,12 +1,11 @@ extends Control +const AmyApi = preload("res://amy.gd") + var _status: Label var _play_button: Button -# Deliberately leave these as Variant. Typing AmyClient as Object makes -# GDScript bind connect() to Object.connect(signal, callable) at parse time -# instead of dispatching to the wrapped Java static method. -var _amy_client -var _android_context +var _amy +var _amy_error: String = "" func _ready() -> void: _build_ui() @@ -15,44 +14,12 @@ func _ready() -> void: _fail("Android export required") return - var runtime = Engine.get_singleton("AndroidRuntime") - if runtime == null: - _fail("AndroidRuntime unavailable") - return - - _android_context = runtime.getApplicationContext() - if _android_context == null: - _fail("Android application context unavailable") - return - - _amy_client = JavaClassWrapper.wrap("org.amy.audio.AmyClient") - if _amy_client == null: - _fail("AmyClient class unavailable") - return - if not _amy_client.has_java_method("connect") or not _amy_client.has_java_method("sendWire"): - _fail("AmyClient methods unavailable") - return - - _status.text = "Connecting to amy.sock..." - for _attempt in range(200): - var rc: int = int(_amy_client.connect(_android_context)) - var exception = JavaClassWrapper.get_exception() - if exception != null: - _fail("AmyClient.connect exception: %s" % str(exception)) - return - if rc == 0: - print("Godot connected to amy.sock") - _status.text = "Connected to AMY" - _play_button.disabled = false - await _play_scale() - return - await get_tree().create_timer(0.05).timeout - - _fail("Could not connect to amy.sock") - -func _exit_tree() -> void: - if _amy_client != null: - _amy_client.close() + _status.text = "Connecting Amy.gd to amy.sock..." + _amy = AmyApi.new() + _amy.debug_wire = true + _amy.backend_ready.connect(_on_amy_ready) + _amy.backend_error.connect(_on_amy_error) + add_child(_amy) func _build_ui() -> void: var center := CenterContainer.new() @@ -65,7 +32,7 @@ func _build_ui() -> void: center.add_child(column) var title := Label.new() - title.text = "AMY + Godot raw-wire proof of concept" + title.text = "AMY + Godot high-level API proof" title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER title.add_theme_font_size_override("font_size", 28) column.add_child(title) @@ -82,46 +49,52 @@ func _build_ui() -> void: _play_button.pressed.connect(_on_play_pressed) column.add_child(_play_button) +func _on_amy_ready() -> void: + print("Godot Amy.gd Android backend ready") + _status.text = "Connected to AMY through Amy.gd" + _play_button.disabled = false + await _play_scale() + +func _on_amy_error(message: String) -> void: + _amy_error = message + _fail(message) + func _on_play_pressed() -> void: await _play_scale() -func _send_wire(wire: String) -> bool: - var rc: int = int(_amy_client.sendWire(wire)) - var exception = JavaClassWrapper.get_exception() - if exception != null: - _fail("AmyClient.sendWire exception: %s" % str(exception)) - return false - if rc != 0: - _fail("amy.sock send failed: %d" % rc) - return false - print("Godot wire: %s" % wire) - return true +func _send(params: Dictionary) -> bool: + _amy_error = "" + var wire: String = _amy.message(params) + print("Godot Amy.gd message: %s" % wire) + _amy.send(params) + return _amy_error.is_empty() func _play_scale() -> void: _play_button.disabled = true - _status.text = "Playing C major scale..." + _status.text = "Playing C major scale through Amy.gd..." - # Stage 1 deliberately uses the exact literal AMY wire commands from the - # already-working Android hello-world. No Amy.gd/API translation is involved. - if not _send_wire("v0w0V10.0Z"): + # Stage 2 uses only Amy.gd's ordinary dictionary API. The shared message() + # implementation produces the wire command and Amy.gd's Android backend sends + # exactly that command through the private service socket. + if not _send({"osc": 0, "wave": AmyApi.SINE, "volume": 10.0}): return await get_tree().create_timer(0.03).timeout for note in [60, 62, 64, 65, 67, 69, 71, 72]: - if not _send_wire("v0n%dl1Z" % note): + if not _send({"osc": 0, "note": note, "vel": 1.0}): return await get_tree().create_timer(0.35).timeout - if not _send_wire("v0l0Z"): + if not _send({"osc": 0, "vel": 0.0}): return await get_tree().create_timer(0.08).timeout _status.text = "C scale complete" _play_button.disabled = false - print("Godot raw-wire C scale complete") + print("Godot Amy.gd C scale complete") func _fail(message: String) -> void: push_error(message) - print("Godot raw-wire error: %s" % message) + print("Godot Amy.gd error: %s" % message) if _status != null: _status.text = message if _play_button != null: diff --git a/godot/android-hello-world/prepare.sh b/godot/android-hello-world/prepare.sh index 3bf83619..02bf42b8 100644 --- a/godot/android-hello-world/prepare.sh +++ b/godot/android-hello-world/prepare.sh @@ -5,6 +5,8 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "${HERE}/../.." && pwd)" GRADLE_BIN="${GRADLE_BIN:-gradle}" +# Android intentionally builds/packages only the independent AMY service AAR. +# Do not build or copy the Godot AmySynth GDExtension for Android. ( cd "${ROOT}/android" "${GRADLE_BIN}" :amy-service:assembleDebug @@ -15,4 +17,8 @@ mkdir -p "${ADDON}" cp "${ROOT}/android/amy-service/build/outputs/aar/amy-service-debug.aar" \ "${ADDON}/amy-service-debug.aar" -printf 'Prepared AMY service AAR for Godot in %s\n' "${ADDON}" +# Exercise the exact shared high-level Godot API in this Android proof rather +# than maintaining a second Android-specific copy of the dictionary/wire code. +cp "${ROOT}/godot/amy.gd" "${HERE}/amy.gd" + +printf 'Prepared AMY service AAR and shared Amy.gd for Godot in %s\n' "${HERE}"