diff --git a/.github/workflows/android-unix-socket.yml b/.github/workflows/android-unix-socket.yml new file mode 100644 index 00000000..6647320b --- /dev/null +++ b/.github/workflows/android-unix-socket.yml @@ -0,0 +1,21 @@ +name: Android Unix socket transport + +on: + pull_request: + paths: + - 'src/amy_unix_socket.c' + - 'src/amy_unix_socket.h' + - 'tests/test_amy_unix_socket.c' + - 'tests/run_amy_unix_socket_test.sh' + - '.github/workflows/android-unix-socket.yml' + +permissions: + contents: read + +jobs: + linux-socket-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Compile and run private Unix socket transport test + run: bash tests/run_amy_unix_socket_test.sh diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..6c5bdeac --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,109 @@ +name: Android AMY + +on: + pull_request: + paths: + - "android/**" + - "src/**" + - "tests/test_amy_unix_socket.c" + - "tests/run_amy_unix_socket_test.sh" + - ".github/workflows/android.yml" + +permissions: + contents: read + +jobs: + socket-transport: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Build and run private Unix socket test + run: bash tests/run_amy_unix_socket_test.sh + + android-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - uses: android-actions/setup-android@v3 + + - name: Install Android SDK components + run: | + yes | sdkmanager --licenses >/dev/null + sdkmanager \ + "platforms;android-36" \ + "build-tools;35.0.0" \ + "ndk;27.0.12077973" \ + "cmake;3.22.1" + + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: "8.13" + + - name: Build AMY Android AAR and hello-world APK + working-directory: android + run: gradle :amy-service:assembleDebug :hello-world:assembleDebug --stacktrace + + - name: Upload AMY Android AAR + uses: actions/upload-artifact@v4 + with: + name: amy-service-debug-aar + path: android/amy-service/build/outputs/aar/amy-service-debug.aar + if-no-files-found: error + + - name: Upload AMY hello-world APK + uses: actions/upload-artifact@v4 + with: + name: amy-hello-world-debug-apk + path: android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + if-no-files-found: error + + - name: Enable KVM for Android emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Emulator end-to-end smoke test + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + profile: pixel_2 + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-boot-anim + script: | + adb uninstall org.amy.hello >/dev/null 2>&1 || true + adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb logcat -c + adb shell am start -W -n org.amy.hello/.MainActivity + sleep 10 + adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log + test "$(grep -c 'AMY/Oboe started' /tmp/amy-first.log)" -eq 1 + grep -q 'AMY output route: deviceId=' /tmp/amy-first.log + test "$(grep -c 'C scale complete' /tmp/amy-first.log)" -eq 1 + ! grep -q 'C scale failed' /tmp/amy-first.log + grep -q 'wire: v0w0V2.0Z' /tmp/amy-first.log + test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-first.log)" -eq 8 + grep -q 'wire: v0n60l1Z' /tmp/amy-first.log + grep -q 'wire: v0n72l1Z' /tmp/amy-first.log + adb uninstall org.amy.hello + adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb logcat -c + adb shell am start -W -n org.amy.hello/.MainActivity + sleep 10 + adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log + test "$(grep -c 'AMY/Oboe started' /tmp/amy-second.log)" -eq 1 + grep -q 'AMY output route: deviceId=' /tmp/amy-second.log + test "$(grep -c 'C scale complete' /tmp/amy-second.log)" -eq 1 + ! grep -q 'C scale failed' /tmp/amy-second.log + grep -q 'wire: v0w0V2.0Z' /tmp/amy-second.log + test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-second.log)" -eq 8 + grep -q 'wire: v0n60l1Z' /tmp/amy-second.log + grep -q 'wire: v0n72l1Z' /tmp/amy-second.log diff --git a/android/README.md b/android/README.md new file mode 100644 index 00000000..49a30900 --- /dev/null +++ b/android/README.md @@ -0,0 +1,177 @@ +# AMY Android Oboe service + +This directory builds a generic Android AAR that hosts AMY in an unexported +`:amy` service process. The service owns Oboe/AAudio output and receives native +AMY wire messages through the private pathname Unix transport implemented by +`src/amy_unix_socket.[ch]`. + +```text +Android client process + | + | AF_UNIX / SOCK_SEQPACKET + | /amy.sock + | one AMY wire message per packet + v +Android :amy service process + | + +-- amy_unix_socket receiver thread + +-- fixed 64-packet SPSC queue + +-- AMY C engine + +-- Oboe low-latency callback + | + v + AAudio +``` + +The AAR is intended to be embedded by an Android application that wants to use +AMY as its local synth engine. The client can be written with the Android SDK, +Kotlin/Java, native code, Qt, another framework, or any other environment able +to start the service and use an Android Unix-domain `SOCK_SEQPACKET` socket. +AMY itself has no dependency on the client UI framework. + +The service declaration uses `android:exported="false"` and +`android:process=":amy"`. Consequently the service runs in a separate process +from the client while remaining in the same Android application package and +under the same application UID. + +The service only accepts the exact pathname `/amy.sock`. +The native transport creates that node mode `0600` and additionally verifies +accepted peers with `SO_PEERCRED` against the service effective UID. See +`docs/android_unix_socket.md` for the transport/security contract. + +## Audio profile + +The Android native build uses AMY's existing 48 kHz / 128-frame build profile +and defines `AMY_NO_MINIAUDIO`; Oboe is the sole audio backend. + +Oboe requests: + +- stereo signed 16-bit output +- 48 kHz +- `PerformanceMode::LowLatency` +- `SharingMode::Exclusive` +- callback-driven output + +The callback size is not assumed to equal 128 frames. The native adapter keeps +only the unconsumed tail of the current AMY block and calls +`amy_simple_fill_buffer()` exactly when another AMY block is required. It does +not add an extra 128-frame output ring. + +Before each new AMY block the callback drains up to 64 already-queued socket +packets and passes them to `amy_add_message()`. The socket thread itself never +calls AMY and never participates in audio rendering. + +AMY is started with its internal platform audio disabled and with AMY rendering +owned by the Oboe callback thread. The current Android build configuration +reserves 16 Karplus-Strong oscillators. + +## JNI boundary + +JNI is lifecycle glue only. `AmyService` calls the native library to start and +stop AMY/Oboe with the validated socket pathname. Notes, patches, sequencer +commands and other musical control do not cross JNI; they use the unchanged AMY +wire protocol through `amy.sock`. + +The client-facing architecture is therefore deliberately transport-oriented: + +```text +client application -> amy.sock -> AMY/Oboe service +``` + +A client does not need AMY-specific JNI bindings. It only needs to start the +service and exchange AMY wire packets over the private socket. + +## Socket client contract + +Use `AF_UNIX` + `SOCK_SEQPACKET` and send one logical AMY request per packet. +For example the payload of three consecutive packets may be: + +```text +K28i2Z +n60l1i2Z +n60l0i2Z +``` + +Do not add stream framing or depend on newline boundaries. Packet boundaries +are preserved by `SOCK_SEQPACKET`. + +The pathname also serves as the engine readiness boundary. `amy.sock` is not +created until Oboe has started and the realtime audio callback has executed at +least once. A client may therefore retry `connect()` while the service starts; +once `connect()` succeeds it may begin sending AMY wire packets immediately. +No fixed Android-startup sleep is required. + +The socket is bidirectional. The Android engine currently consumes ordinary AMY +wire commands; the existing `amy_unix_socket_send()` path is ready for compact +introspection/status replies when that functionality is integrated. + +## Client integration + +A client application needs to: + +1. package the `amy-service` AAR/module in the Android application; +2. start `org.amy.audio.AmyService` while synthesis is required; +3. obtain the application's actual private files directory rather than + hard-code `/data/user/...`; +4. retry an `AF_UNIX` / `SOCK_SEQPACKET` connection to `/amy.sock` + until the service publishes its ready socket; +5. send one ordinary AMY wire message per packet; +6. optionally receive response packets over the same bidirectional socket; +7. stop and reconnect cleanly across Android application/audio lifecycle + events. + +The transport deliberately does not prescribe a programming language or UI +framework. A minimal example client is provided separately by the Android +hello-world application. + +## Building the AAR + +Requirements used by CI: + +- JDK 17 +- Android SDK platform 36 +- Android NDK 27.0.12077973 +- CMake 3.22.1 +- Gradle 8.13 +- Android Gradle Plugin 8.13.2 +- Oboe 1.10.0 (Prefab dependency) + +From the repository root: + +```bash +cd android +gradle :amy-service:assembleDebug +``` + +The production Android service build targets `arm64-v8a`. Output is below: + +```text +android/amy-service/build/outputs/aar/ +``` + +## Tests + +The private socket regression test is: + +```bash +bash tests/run_amy_unix_socket_test.sh +``` + +It validates packet round-trip, mode/ownership, `EMSGSIZE` behavior, +oversized-packet rejection, cleanup, and protection against deleting an +existing non-socket path. + +`.github/workflows/android.yml` runs that regression plus a complete Android +AAR/NDK/Oboe build. The earlier `.github/workflows/android-unix-socket.yml` +continues to isolate the transport regression itself. + +## Hardware-test items + +The first device tests should measure: + +1. command-to-audio latency; +2. negotiated Oboe callback/device buffer sizes; +3. xruns during patch changes and heavy reverb/delay loads; +4. suspend/resume and audio-device changes; +5. whether executing rare heavy AMY commands at a block boundary needs further + separation from the realtime callback. diff --git a/android/amy-service/build.gradle.kts b/android/amy-service/build.gradle.kts new file mode 100644 index 00000000..c0c0e37e --- /dev/null +++ b/android/amy-service/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "org.amy.audio" + compileSdk = 36 + ndkVersion = "27.0.12077973" + + defaultConfig { + minSdk = 26 + + // arm64-v8a is the production target. x86_64 is included on this + // hello-world branch so CI can run the same AMY/Oboe service in the + // hardware-accelerated Android emulator. + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + + externalNativeBuild { + cmake { + arguments += "-DANDROID_STL=c++_shared" + cppFlags += "-std=c++17" + } + } + } + + buildFeatures { + prefab = true + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } + + packaging { + jniLibs { + useLegacyPackaging = false + } + } +} + +dependencies { + implementation("com.google.oboe:oboe:1.10.0") +} diff --git a/android/amy-service/src/main/AndroidManifest.xml b/android/amy-service/src/main/AndroidManifest.xml new file mode 100644 index 00000000..cde4251b --- /dev/null +++ b/android/amy-service/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/android/amy-service/src/main/cpp/CMakeLists.txt b/android/amy-service/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..25c92db2 --- /dev/null +++ b/android/amy-service/src/main/cpp/CMakeLists.txt @@ -0,0 +1,70 @@ +cmake_minimum_required(VERSION 3.22.1) +project(amy_android LANGUAGES C CXX) + +find_package(oboe REQUIRED CONFIG) + +set(AMY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../..") +set(AMY_SRC "${AMY_ROOT}/src") + +set(AMY_SOURCES + ${AMY_SRC}/algorithms.c + ${AMY_SRC}/amy.c + ${AMY_SRC}/amy_unix_socket.c + ${AMY_SRC}/delay.c + ${AMY_SRC}/envelope.c + ${AMY_SRC}/filters.c + ${AMY_SRC}/parse.c + ${AMY_SRC}/sequencer.c + ${AMY_SRC}/transfer.c + ${AMY_SRC}/midi_mappings.c + ${AMY_SRC}/custom.c + ${AMY_SRC}/patches.c + ${AMY_SRC}/oscillators.c + ${AMY_SRC}/interp_partials.c + ${AMY_SRC}/pcm.c + ${AMY_SRC}/log2_exp2.c + ${AMY_SRC}/instrument.c + ${AMY_SRC}/amy_midi.c + ${AMY_SRC}/api.c + ${AMY_SRC}/cv_trigger.c +) + +add_library(amy_android SHARED + amy_android.cpp + amy_android_profile.cpp + ${AMY_SOURCES} +) + +target_include_directories(amy_android PRIVATE + ${AMY_SRC} + ${CMAKE_CURRENT_SOURCE_DIR} +) + +# AMY_DAISY selects AMY's existing 48 kHz / 128-frame compile profile. Android +# owns both the audio and MIDI device layers, so no Daisy device implementation +# is linked: AMY_NO_MINIAUDIO leaves Oboe as the sole audio backend and +# AMY_HOST_MIDI leaves run_midi/stop_midi/midi_out to amy_android.cpp. +# delay.c already provides qspi_malloc/qspi_free under AMY_DAISY. pcm.c needs +# declarations for those helpers, so force only the compatibility declarations +# into C translation units; do not link a second allocator implementation. +target_compile_definitions(amy_android PRIVATE + AMY_ANDROID=1 + AMY_DAISY=1 + AMY_HOST_MIDI=1 + AMY_NO_MINIAUDIO=1 + AMY_WAVETABLE=1 +) + +target_compile_options(amy_android PRIVATE + $<$:-include;${CMAKE_CURRENT_SOURCE_DIR}/amy_android_daisy_alloc.h;-O3;-Wall;-Wextra;-Wno-unused-parameter;-Wno-float-conversion> + $<$:-O3;-Wall;-Wextra;-Wno-unused-parameter> +) + +target_compile_features(amy_android PRIVATE c_std_11 cxx_std_17) + +target_link_libraries(amy_android PRIVATE + oboe::oboe + android + log + m +) diff --git a/android/amy-service/src/main/cpp/amy_android.cpp b/android/amy-service/src/main/cpp/amy_android.cpp new file mode 100644 index 00000000..bfddd62f --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -0,0 +1,323 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include "amy.h" +#include "amy_unix_socket.h" +} + +#define LOG_TAG "AmyAndroid" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +/* + * AMY's generic API always calls these platform hooks. The Android build does + * not use AMY's miniaudio/I2S platform layer; Oboe owns the output stream and + * calls amy_simple_fill_buffer() directly. + */ +extern "C" void amy_platform_init(void) {} +extern "C" void amy_platform_deinit(void) {} +extern "C" void amy_update_tasks(void) {} +extern "C" int16_t *amy_render_audio(void) { return nullptr; } +extern "C" size_t amy_i2s_write(const uint8_t *, size_t) { return 0; } + +/* + * AMY_HOST_MIDI makes the embedder own the MIDI device layer. This Android + * service is controlled by AMY wire messages rather than a MIDI device, so the + * lifecycle hooks are no-ops. Preserve AMY's optional outgoing MIDI hook even + * though no platform MIDI port is opened here. + */ +extern "C" void run_midi(void) {} +extern "C" void stop_midi(void) {} +extern "C" void midi_out(uint8_t *bytes, uint16_t len) { + if (amy_global.config.amy_external_midi_output_hook != nullptr) { + amy_global.config.amy_external_midi_output_hook(bytes, len); + } +} + +namespace { + +constexpr int kMaxCommandsPerBlock = 64; +constexpr int kAudioReadyTimeoutMs = 2000; +constexpr int kAudioReadyPollMs = 2; + +class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, + public oboe::AudioStreamErrorCallback { +public: + int start(const char *socketPath) { + if (socketPath == nullptr || socketPath[0] == '\0') return -EINVAL; + if (mRunning.load(std::memory_order_acquire)) return -EALREADY; + + amy_config_t config = amy_default_config(); + config.audio = AMY_AUDIO_IS_NONE; + config.features.audio_in = 0; + config.features.default_synths = 0; + config.features.startup_bleep = 0; + /* Keep AMY rendering entirely on Oboe's realtime callback thread. */ + config.platform.multicore = 0; + config.platform.multithread = 0; + /* Physical-string clients can require many simultaneous KS voices. */ + config.ks_oscs = 16; + + amy_start(config); + mAmyStarted = true; + + oboe::AudioStreamBuilder builder; + builder.setDirection(oboe::Direction::Output); + builder.setPerformanceMode(oboe::PerformanceMode::LowLatency); + builder.setSharingMode(oboe::SharingMode::Exclusive); + builder.setFormat(oboe::AudioFormat::I16); + builder.setChannelCount(AMY_NCHANS); + builder.setSampleRate(AMY_SAMPLE_RATE); + builder.setUsage(oboe::Usage::Game); + builder.setContentType(oboe::ContentType::Music); + builder.setDataCallback(this); + builder.setErrorCallback(this); + + oboe::Result result = builder.openStream(mStream); + if (result != oboe::Result::OK || !mStream) { + LOGE("Oboe openStream failed: %s", oboe::convertToText(result)); + stopAmy(); + return static_cast(result); + } + + if (mStream->getFormat() != oboe::AudioFormat::I16 || + mStream->getChannelCount() != AMY_NCHANS || + mStream->getSampleRate() != AMY_SAMPLE_RATE) { + LOGE("Unexpected Oboe format: format=%d channels=%d rate=%d", + static_cast(mStream->getFormat()), + mStream->getChannelCount(), + mStream->getSampleRate()); + mStream->close(); + mStream.reset(); + stopAmy(); + return -ERANGE; + } + + LOGI("Oboe output: deviceId=%d sharing=%d performance=%d usage=%d content=%d framesPerBurst=%d capacity=%d", + mStream->getDeviceId(), + static_cast(mStream->getSharingMode()), + static_cast(mStream->getPerformanceMode()), + static_cast(mStream->getUsage()), + static_cast(mStream->getContentType()), + mStream->getFramesPerBurst(), + mStream->getBufferCapacityInFrames()); + + mBlock = nullptr; + mBlockFrame = AMY_BLOCK_SIZE; + mAudioCallbackSeen.store(false, std::memory_order_release); + mRunning.store(true, std::memory_order_release); + + result = mStream->requestStart(); + if (result != oboe::Result::OK) { + LOGE("Oboe requestStart failed: %s", oboe::convertToText(result)); + mRunning.store(false, std::memory_order_release); + mStream->close(); + mStream.reset(); + stopAmy(); + return static_cast(result); + } + + // Do not publish amy.sock until the realtime audio callback has actually + // executed. This makes successful socket connect a useful readiness + // boundary for generic clients, including the first launch after install. + int waitedMs = 0; + while (!mAudioCallbackSeen.load(std::memory_order_acquire) && + mRunning.load(std::memory_order_acquire) && + waitedMs < kAudioReadyTimeoutMs) { + std::this_thread::sleep_for(std::chrono::milliseconds(kAudioReadyPollMs)); + waitedMs += kAudioReadyPollMs; + } + + if (!mAudioCallbackSeen.load(std::memory_order_acquire)) { + LOGE("Timed out waiting for first Oboe audio callback"); + mRunning.store(false, std::memory_order_release); + mStream->requestStop(); + mStream->close(); + mStream.reset(); + stopAmy(); + return -ETIMEDOUT; + } + + if (!mRunning.load(std::memory_order_acquire)) { + LOGE("Oboe stream stopped before AMY socket became ready"); + mStream->close(); + mStream.reset(); + stopAmy(); + return -EIO; + } + + amy_unix_socket_server_t *socket = nullptr; + int socketResult = amy_unix_socket_start(&socket, socketPath); + if (socketResult != 0) { + mRunning.store(false, std::memory_order_release); + mStream->requestStop(); + mStream->close(); + mStream.reset(); + stopAmy(); + return socketResult; + } + mSocket.store(socket, std::memory_order_release); + + LOGI("AMY/Oboe started: %d Hz, %d-frame AMY blocks, socket=%s", + AMY_SAMPLE_RATE, AMY_BLOCK_SIZE, socketPath); + return 0; + } + + int32_t outputDeviceId() const { + return mStream ? mStream->getDeviceId() : -1; + } + + void stop() { + mRunning.store(false, std::memory_order_release); + + if (mStream) { + mStream->requestStop(); + mStream->close(); + mStream.reset(); + } + + cleanupSocketAndAmy(); + mAudioCallbackSeen.store(false, std::memory_order_release); + mBlock = nullptr; + mBlockFrame = AMY_BLOCK_SIZE; + } + + oboe::DataCallbackResult onAudioReady( + oboe::AudioStream *, + void *audioData, + int32_t numFrames) override { + int16_t *output = static_cast(audioData); + if (!mRunning.load(std::memory_order_acquire)) { + std::memset(output, 0, + static_cast(numFrames) * AMY_NCHANS * sizeof(int16_t)); + return oboe::DataCallbackResult::Stop; + } + + mAudioCallbackSeen.store(true, std::memory_order_release); + + int32_t outputFrame = 0; + while (outputFrame < numFrames) { + if (mBlock == nullptr || mBlockFrame >= AMY_BLOCK_SIZE) { + drainCommands(); + mBlock = amy_simple_fill_buffer(); + mBlockFrame = 0; + if (mBlock == nullptr) { + std::memset(output + outputFrame * AMY_NCHANS, 0, + static_cast(numFrames - outputFrame) * + AMY_NCHANS * sizeof(int16_t)); + break; + } + } + + const int32_t available = AMY_BLOCK_SIZE - mBlockFrame; + const int32_t frames = std::min(available, numFrames - outputFrame); + std::memcpy( + output + outputFrame * AMY_NCHANS, + mBlock + mBlockFrame * AMY_NCHANS, + static_cast(frames) * AMY_NCHANS * sizeof(int16_t)); + outputFrame += frames; + mBlockFrame += frames; + } + + return oboe::DataCallbackResult::Continue; + } + + void onErrorAfterClose(oboe::AudioStream *, oboe::Result error) override { + mRunning.store(false, std::memory_order_release); + LOGE("Oboe stream closed after error: %s", oboe::convertToText(error)); + /* Lifecycle owner may restart the service; no work is done on Oboe's error thread. */ + } + +private: + void drainCommands() { + amy_unix_socket_server_t *socket = mSocket.load(std::memory_order_acquire); + if (socket == nullptr) return; + + char command[MAX_MESSAGE_LEN]; + for (int count = 0; count < kMaxCommandsPerBlock; ++count) { + int length = amy_unix_socket_receive(socket, command, sizeof(command)); + if (length <= 0) break; + amy_add_message(command); + } + } + + void stopAmy() { + if (mAmyStarted) { + amy_stop(); + mAmyStarted = false; + } + } + + void cleanupSocketAndAmy() { + amy_unix_socket_server_t *socket = + mSocket.exchange(nullptr, std::memory_order_acq_rel); + if (socket != nullptr) { + uint32_t overruns = amy_unix_socket_queue_overruns(socket); + uint32_t oversize = amy_unix_socket_oversize_packets(socket); + uint32_t rejected = amy_unix_socket_rejected_peers(socket); + if (overruns || oversize || rejected) { + LOGE("AMY socket diagnostics: overruns=%u oversize=%u rejected=%u", + overruns, oversize, rejected); + } + amy_unix_socket_stop(socket); + } + stopAmy(); + } + + std::atomic mRunning{false}; + std::atomic mAudioCallbackSeen{false}; + bool mAmyStarted = false; + std::atomic mSocket{nullptr}; + std::shared_ptr mStream; + int16_t *mBlock = nullptr; + int32_t mBlockFrame = AMY_BLOCK_SIZE; +}; + +std::mutex gLifecycleMutex; +std::unique_ptr gEngine; + +} // namespace + +extern "C" JNIEXPORT jint JNICALL +Java_org_amy_audio_AmyService_nativeStart(JNIEnv *env, jclass, jstring socketPath) { + if (socketPath == nullptr) return -EINVAL; + + const char *path = env->GetStringUTFChars(socketPath, nullptr); + if (path == nullptr) return -ENOMEM; + + std::lock_guard guard(gLifecycleMutex); + if (gEngine) gEngine->stop(); + gEngine = std::make_unique(); + int result = gEngine->start(path); + if (result != 0) gEngine.reset(); + + env->ReleaseStringUTFChars(socketPath, path); + return result; +} + +extern "C" JNIEXPORT jint JNICALL +Java_org_amy_audio_AmyService_nativeGetOutputDeviceId(JNIEnv *, jclass) { + std::lock_guard guard(gLifecycleMutex); + return gEngine ? gEngine->outputDeviceId() : -1; +} + +extern "C" JNIEXPORT void JNICALL +Java_org_amy_audio_AmyService_nativeStop(JNIEnv *, jclass) { + std::lock_guard guard(gLifecycleMutex); + if (gEngine) { + gEngine->stop(); + gEngine.reset(); + } +} diff --git a/android/amy-service/src/main/cpp/amy_android_daisy_alloc.c b/android/amy-service/src/main/cpp/amy_android_daisy_alloc.c new file mode 100644 index 00000000..a6108f35 --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_daisy_alloc.c @@ -0,0 +1,11 @@ +#include "amy_android_daisy_alloc.h" + +#include + +void *qspi_malloc(size_t size) { + return malloc(size); +} + +void qspi_free(void *ptr) { + free(ptr); +} diff --git a/android/amy-service/src/main/cpp/amy_android_daisy_alloc.h b/android/amy-service/src/main/cpp/amy_android_daisy_alloc.h new file mode 100644 index 00000000..7237295e --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_daisy_alloc.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +void *qspi_malloc(size_t size); +void qspi_free(void *ptr); + +#ifdef __cplusplus +} +#endif diff --git a/android/amy-service/src/main/cpp/amy_android_profile.cpp b/android/amy-service/src/main/cpp/amy_android_profile.cpp new file mode 100644 index 00000000..ba37373e --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_profile.cpp @@ -0,0 +1,10 @@ +extern "C" { +#include "amy.h" +} + +static_assert(AMY_SAMPLE_RATE == 48000, + "Android AMY service requires a 48 kHz AMY build"); +static_assert(AMY_BLOCK_SIZE == 128, + "Android AMY service requires 128-frame AMY blocks"); +static_assert(AMY_NCHANS == 2, + "Android AMY service expects stereo AMY output"); diff --git a/android/amy-service/src/main/java/org/amy/audio/AmyService.java b/android/amy-service/src/main/java/org/amy/audio/AmyService.java new file mode 100644 index 00000000..98ff90e7 --- /dev/null +++ b/android/amy-service/src/main/java/org/amy/audio/AmyService.java @@ -0,0 +1,172 @@ +package org.amy.audio; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import android.os.IBinder; +import android.util.Log; + +import java.io.File; +import java.io.IOException; + +/** + * Unexported same-UID service hosting native AMY + Oboe in a separate process. + * + * Musical control never crosses JNI. The host opens the private pathname Unix + * SOCK_SEQPACKET socket and sends one AMY wire message per packet. JNI is only + * used to start/stop the native engine and report its actual Oboe output device. + */ +public final class AmyService extends Service { + private static final String TAG = "AmyService"; + + public static final String EXTRA_SOCKET_PATH = "org.amy.audio.extra.SOCKET_PATH"; + public static final String DEFAULT_SOCKET_NAME = "amy.sock"; + + static { + System.loadLibrary("amy_android"); + } + + private boolean running; + private String runningSocketPath; + + private static native int nativeStart(String socketPath); + private static native int nativeGetOutputDeviceId(); + private static native void nativeStop(); + + /** Start the private AMY process using filesDir/amy.sock. */ + public static void start(Context context) { + File socket = new File(context.getFilesDir(), DEFAULT_SOCKET_NAME); + Intent intent = new Intent(context, AmyService.class); + intent.putExtra(EXTRA_SOCKET_PATH, socket.getAbsolutePath()); + context.startService(intent); + } + + /** Stop the private AMY process. */ + public static void stop(Context context) { + context.stopService(new Intent(context, AmyService.class)); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + stopSelf(startId); + return START_NOT_STICKY; + } + + String requested = intent.getStringExtra(EXTRA_SOCKET_PATH); + if (requested == null) { + requested = new File(getFilesDir(), DEFAULT_SOCKET_NAME).getAbsolutePath(); + } + + final String socketPath; + try { + socketPath = validatePrivateSocketPath(requested); + } catch (IOException | SecurityException ex) { + Log.e(TAG, "Refusing AMY socket path", ex); + stopSelf(startId); + return START_NOT_STICKY; + } + + // Starting the same service again is normal Android lifecycle behavior. + // Do not tear down an active audio engine and disconnect its socket + // client merely because another equivalent startService() arrived. + if (running && socketPath.equals(runningSocketPath)) { + Log.i(TAG, "AMY already running on private socket " + socketPath); + return START_NOT_STICKY; + } + + if (running) { + nativeStop(); + running = false; + runningSocketPath = null; + } + + int result = nativeStart(socketPath); + if (result != 0) { + Log.e(TAG, "nativeStart failed: " + result); + stopSelf(startId); + return START_NOT_STICKY; + } + + running = true; + runningSocketPath = socketPath; + Log.i(TAG, "AMY listening on private socket " + socketPath); + logOutputRoute(nativeGetOutputDeviceId()); + return START_NOT_STICKY; + } + + private void logOutputRoute(int deviceId) { + AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (audioManager == null) { + Log.i(TAG, "AMY output route: deviceId=" + deviceId + " (AudioManager unavailable)"); + return; + } + + for (AudioDeviceInfo device : audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { + if (device.getId() == deviceId) { + Log.i(TAG, "AMY output route: deviceId=" + deviceId + + " type=" + audioDeviceTypeName(device.getType()) + + " product=" + String.valueOf(device.getProductName())); + return; + } + } + + Log.i(TAG, "AMY output route: deviceId=" + deviceId + + " type=UNRESOLVED_DEFAULT_OR_DEVICE"); + } + + private static String audioDeviceTypeName(int type) { + switch (type) { + case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE: + return "BUILTIN_EARPIECE"; + case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER: + return "BUILTIN_SPEAKER"; + case AudioDeviceInfo.TYPE_WIRED_HEADSET: + return "WIRED_HEADSET"; + case AudioDeviceInfo.TYPE_WIRED_HEADPHONES: + return "WIRED_HEADPHONES"; + case AudioDeviceInfo.TYPE_BLUETOOTH_SCO: + return "BLUETOOTH_SCO"; + case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP: + return "BLUETOOTH_A2DP"; + case AudioDeviceInfo.TYPE_HDMI: + return "HDMI"; + case AudioDeviceInfo.TYPE_USB_DEVICE: + return "USB_DEVICE"; + case AudioDeviceInfo.TYPE_USB_ACCESSORY: + return "USB_ACCESSORY"; + default: + return "TYPE_" + type; + } + } + + private String validatePrivateSocketPath(String requested) throws IOException { + File files = getFilesDir().getCanonicalFile(); + File socket = new File(requested).getCanonicalFile(); + File parent = socket.getParentFile(); + if (parent == null || !parent.equals(files)) { + throw new SecurityException("AMY socket must be directly inside app filesDir"); + } + if (!DEFAULT_SOCKET_NAME.equals(socket.getName())) { + throw new SecurityException("AMY socket filename must be " + DEFAULT_SOCKET_NAME); + } + return socket.getAbsolutePath(); + } + + @Override + public void onDestroy() { + if (running) { + nativeStop(); + running = false; + runningSocketPath = null; + } + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 00000000..a2cc8b72 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.13.2" apply false + id("com.android.library") version "8.13.2" apply false +} diff --git a/android/hello-world/README.md b/android/hello-world/README.md new file mode 100644 index 00000000..ee44a05a --- /dev/null +++ b/android/hello-world/README.md @@ -0,0 +1,51 @@ +# AMY Android Hello World + +Minimal Android application proving the generic AMY Android service end to end. + +On launch it: + +1. starts `org.amy.audio.AmyService` from the `amy-service` AAR/module; +2. retries a connection to the app-private `/amy.sock` Unix-domain `SOCK_SEQPACKET` socket until the AMY/Oboe service publishes its ready socket; +3. configures raw oscillator 0 as a sine wave and sets AMY global output gain to `V2.0`; +4. waits 30 ms so that setup is committed on a fresh AMY instance before the first note-on; +5. sends AMY wire commands for C4, D4, E4, F4, G4, A4, B4, C5; +6. shows `C scale complete` when all packets have been sent. + +The note path does not call AMY through JNI. JNI is used only for the Android client-side Unix socket syscalls because the Java `LocalSocket` API is stream-oriented. The synth process receives ordinary AMY wire packets exactly as another AMY wire transport would. + +The generic AMY Android service also logs Oboe's actual output device ID and resolves it through `AudioDeviceInfo`, so device logs identify routes such as `BUILTIN_SPEAKER`, `BUILTIN_EARPIECE`, Bluetooth, wired headphones, or USB where Android exposes a matching device. + +## Wire sequence + +Setup: + +```text +v0w0V2.0Z +``` + +`V` is AMY's global output gain. It is intentionally set above unity in this audible hello-world test; it is not an oscillator-local amplitude control. + +Notes use MIDI note numbers and velocity, e.g. middle C: + +```text +v0n60l1Z +v0l0Z +``` + +The complete scale is MIDI notes `60, 62, 64, 65, 67, 69, 71, 72`. + +## Build + +From `android/`: + +```bash +gradle :hello-world:assembleDebug +``` + +APK: + +```text +hello-world/build/outputs/apk/debug/hello-world-debug.apk +``` + +The CI Android emulator smoke test builds the AAR/APK and performs two clean install/launch cycles. Each cycle must show exactly one AMY/Oboe startup, an output-route diagnostic, exactly one completed C scale, all eight note-on packets, and no socket failure. diff --git a/android/hello-world/build.gradle.kts b/android/hello-world/build.gradle.kts new file mode 100644 index 00000000..2e77a882 --- /dev/null +++ b/android/hello-world/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + id("com.android.application") +} + +android { + namespace = "org.amy.hello" + compileSdk = 36 + ndkVersion = "27.0.12077973" + + defaultConfig { + applicationId = "org.amy.hello" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + + externalNativeBuild { + cmake { + cppFlags += "-std=c++17" + } + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } +} + +dependencies { + implementation(project(":amy-service")) +} diff --git a/android/hello-world/src/main/AndroidManifest.xml b/android/hello-world/src/main/AndroidManifest.xml new file mode 100644 index 00000000..4c3f384c --- /dev/null +++ b/android/hello-world/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/android/hello-world/src/main/cpp/CMakeLists.txt b/android/hello-world/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..1915bf81 --- /dev/null +++ b/android/hello-world/src/main/cpp/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.22.1) +project(amy_hello_client LANGUAGES CXX) + +add_library(amy_hello_client SHARED amy_hello_client.cpp) + +target_compile_features(amy_hello_client PRIVATE cxx_std_17) +target_compile_options(amy_hello_client PRIVATE -Wall -Wextra -Werror) +target_link_libraries(amy_hello_client PRIVATE log) diff --git a/android/hello-world/src/main/cpp/amy_hello_client.cpp b/android/hello-world/src/main/cpp/amy_hello_client.cpp new file mode 100644 index 00000000..df83a9e6 --- /dev/null +++ b/android/hello-world/src/main/cpp/amy_hello_client.cpp @@ -0,0 +1,101 @@ +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +#define LOG_TAG "AmyHelloWorld" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +namespace { + +int connect_with_retry(const char *path) { + if (path == nullptr || path[0] == '\0') return -EINVAL; + + sockaddr_un addr{}; + if (std::strlen(path) >= sizeof(addr.sun_path)) return -ENAMETOOLONG; + addr.sun_family = AF_UNIX; + std::strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); + + for (int attempt = 0; attempt < 100; ++attempt) { + int fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0); + if (fd < 0) return -errno; + + if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0) { + return fd; + } + + int saved = errno; + close(fd); + if (saved != ENOENT && saved != ECONNREFUSED) return -saved; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + return -ETIMEDOUT; +} + +int send_wire(int fd, const char *wire) { + size_t len = std::strlen(wire); + ssize_t sent = send(fd, wire, len, MSG_NOSIGNAL); + if (sent < 0) return -errno; + if (static_cast(sent) != len) return -EIO; + LOGI("wire: %s", wire); + return 0; +} + +int play_c_scale(const char *path) { + int fd = connect_with_retry(path); + if (fd < 0) return fd; + + // Raw oscillator 0, sine wave. V is AMY's global output gain, not an + // oscillator-local amplitude; use 2.0 here so the hello-world is easy to hear. + // Every packet is an ordinary AMY wire command sent through amy.sock. + int rc = send_wire(fd, "v0w0V2.0Z"); + if (rc < 0) { + close(fd); + return rc; + } + + // On a completely fresh AMY instance, commit oscillator setup before the + // first note-on instead of allowing both commands into the same first drain. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + + static constexpr int notes[] = {60, 62, 64, 65, 67, 69, 71, 72}; + char wire[64]; + + for (int note : notes) { + std::snprintf(wire, sizeof(wire), "v0n%dl1Z", note); + rc = send_wire(fd, wire); + if (rc < 0) break; + + std::this_thread::sleep_for(std::chrono::milliseconds(350)); + + rc = send_wire(fd, "v0l0Z"); + if (rc < 0) break; + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + } + + close(fd); + if (rc == 0) LOGI("C scale complete"); + return rc; +} + +} // namespace + +extern "C" JNIEXPORT jint JNICALL +Java_org_amy_hello_MainActivity_nativePlayCScale(JNIEnv *env, jclass, jstring socketPath) { + if (socketPath == nullptr) return -EINVAL; + const char *path = env->GetStringUTFChars(socketPath, nullptr); + if (path == nullptr) return -ENOMEM; + int rc = play_c_scale(path); + env->ReleaseStringUTFChars(socketPath, path); + if (rc < 0) LOGE("C scale failed: %d", rc); + return rc; +} diff --git a/android/hello-world/src/main/java/org/amy/hello/MainActivity.java b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java new file mode 100644 index 00000000..e1b5eb96 --- /dev/null +++ b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java @@ -0,0 +1,92 @@ +package org.amy.hello; + +import android.app.Activity; +import android.os.Bundle; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.amy.audio.AmyService; + +import java.io.File; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public final class MainActivity extends Activity { + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); + + private TextView status; + private Button playButton; + + static { + System.loadLibrary("amy_hello_client"); + } + + private static native int nativePlayCScale(String socketPath); + + @Override + protected void onCreate(Bundle state) { + super.onCreate(state); + + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setGravity(Gravity.CENTER); + root.setPadding(48, 48, 48, 48); + + TextView title = new TextView(this); + title.setText("AMY Hello World"); + title.setTextSize(28); + title.setGravity(Gravity.CENTER); + root.addView(title, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + status = new TextView(this); + status.setText("Starting AMY..."); + status.setTextSize(18); + status.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams statusParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + statusParams.setMargins(0, 40, 0, 40); + root.addView(status, statusParams); + + playButton = new Button(this); + playButton.setText("Play C scale"); + playButton.setOnClickListener(v -> playScale()); + root.addView(playButton, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + setContentView(root); + + AmyService.start(this); + if (state == null) { + playScale(); + } else { + status.setText("AMY ready"); + } + } + + private void playScale() { + playButton.setEnabled(false); + status.setText("Playing C major scale..."); + String socketPath = new File(getFilesDir(), AmyService.DEFAULT_SOCKET_NAME) + .getAbsolutePath(); + + EXECUTOR.execute(() -> { + int rc = nativePlayCScale(socketPath); + runOnUiThread(() -> { + if (isDestroyed()) return; + if (rc == 0) { + status.setText("C scale complete"); + } else { + status.setText("AMY/socket error: " + rc); + } + playButton.setEnabled(true); + }); + }); + } +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 00000000..d17aab50 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "amy-android" +include(":amy-service") +include(":hello-world") diff --git a/docs/android_unix_socket.md b/docs/android_unix_socket.md new file mode 100644 index 00000000..0a0de556 --- /dev/null +++ b/docs/android_unix_socket.md @@ -0,0 +1,118 @@ +# Android private `amy.sock` transport + +`src/amy_unix_socket.c` provides a small Linux/Android pathname `AF_UNIX` +transport intended for a stand-alone AMY + Oboe Android process. + +The Android application should choose a pathname below its private internal +storage directory, for example conceptually: + +``` +/data/user/0//files/amy.sock +``` + +Do not hard-code that example path. Obtain the application's actual internal +files directory from Android and pass the resulting full pathname to the native +AMY process/service. + +## Security properties + +The server: + +- uses `AF_UNIX` + `SOCK_SEQPACKET` rather than TCP/UDP; +- creates the socket pathname mode `0600`; +- on Linux/Android accepts only peers whose `SO_PEERCRED` UID equals the + server's effective UID; +- removes a stale socket only when it is a socket owned by the same UID; +- never removes an existing regular file or foreign-owned socket; +- supports one connected client at a time. + +The Android private app-data parent directory remains the primary sandbox +boundary. Socket mode and peer credentials are defense in depth. + +## Realtime ownership + +The socket receiver thread never calls AMY. Each received `SOCK_SEQPACKET` +message is copied into a fixed 64-entry SPSC queue. There is no allocation in +the dequeue path. + +The AMY/Oboe owner should drain the queue at a safe block boundary: + +```c +#include "amy.h" +#include "amy_unix_socket.h" + +static amy_unix_socket_server_t *amy_socket; + +void process_amy_socket(void) { + char message[MAX_MESSAGE_LEN]; + for (;;) { + int len = amy_unix_socket_receive( + amy_socket, message, sizeof(message)); + if (len <= 0) break; + amy_add_message(message); + } +} +``` + +For an Oboe backend, call `process_amy_socket()` immediately before producing a +new AMY render block, not from the socket thread. + +A packet payload may omit a terminating NUL; the dequeue API adds one. Keep a +single AMY wire command or other logical request in each packet. Maximum packet +payload is `MAX_MESSAGE_LEN - 1` bytes. + +## Bidirectional replies + +`amy_unix_socket_send()` sends one `SOCK_SEQPACKET` reply to the current +client. It is non-blocking and intended for control/status/introspection paths, +not for the realtime audio callback. + +This means the compact introspection protocol can later use the same connection: + +``` +Qt -> AMY ?iv +AMY -> Qt !iv1 +``` + +The socket transport itself intentionally does not depend on the introspection +implementation, so the two branches can be reviewed and merged independently. + +## Starting and stopping + +```c +amy_unix_socket_server_t *server = NULL; +int rc = amy_unix_socket_start(&server, socket_path); +if (rc < 0) { + // rc is -errno +} + +// ... run AMY/Oboe ... + +amy_unix_socket_stop(server); +``` + +Stopping joins the receiver thread and removes the socket pathname. + +## Diagnostics + +These counters can be queried from a non-realtime diagnostics path: + +- `amy_unix_socket_queue_overruns()` +- `amy_unix_socket_oversize_packets()` +- `amy_unix_socket_rejected_peers()` + +A queue overrun means the AMY/control owner is not draining packets quickly +enough. The transport drops the new packet rather than blocking the receiver or +allocating more memory. + +## Host regression test + +On Linux: + +```bash +bash tests/run_amy_unix_socket_test.sh +``` + +The test verifies round-trip packet transport, socket mode/ownership, +non-consuming `EMSGSIZE` behavior, oversized-packet rejection, pathname cleanup, +and refusal to delete a pre-existing regular file. diff --git a/src/amy_unix_socket.c b/src/amy_unix_socket.c new file mode 100644 index 00000000..c585bfb6 --- /dev/null +++ b/src/amy_unix_socket.c @@ -0,0 +1,447 @@ +#define _GNU_SOURCE + +#include "amy_unix_socket.h" + +#if defined(__linux__) || defined(__ANDROID__) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MSG_NOSIGNAL +#define MSG_NOSIGNAL 0 +#endif + +#define AMY_UNIX_SOCKET_POLL_MS 50 + +struct amy_unix_socket_packet { + uint16_t len; + char data[MAX_MESSAGE_LEN]; +}; + +struct amy_unix_socket_server { + int listen_fd; + int client_fd; + pthread_t thread; + pthread_mutex_t client_lock; + bool thread_started; + volatile uint32_t running; + + char path[sizeof(((struct sockaddr_un *)0)->sun_path)]; + + struct amy_unix_socket_packet queue[AMY_UNIX_SOCKET_QUEUE_CAPACITY]; + volatile uint32_t write_index; + volatile uint32_t read_index; + + volatile uint32_t queue_overruns; + volatile uint32_t oversize_packets; + volatile uint32_t rejected_peers; +}; + +static uint32_t load_u32(const volatile uint32_t *value) { + return __atomic_load_n(value, __ATOMIC_ACQUIRE); +} + +static void store_u32(volatile uint32_t *value, uint32_t new_value) { + __atomic_store_n(value, new_value, __ATOMIC_RELEASE); +} + +static void increment_u32(volatile uint32_t *value) { + __atomic_add_fetch(value, 1u, __ATOMIC_RELAXED); +} + +static int set_nonblocking_cloexec(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0) return -errno; + if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) return -errno; + + flags = fcntl(fd, F_GETFD, 0); + if (flags < 0) return -errno; + if (fcntl(fd, F_SETFD, flags | FD_CLOEXEC) < 0) return -errno; + return 0; +} + +static int remove_owned_stale_socket(const char *path) { + struct stat st; + if (lstat(path, &st) < 0) { + return errno == ENOENT ? 0 : -errno; + } + + if (!S_ISSOCK(st.st_mode)) return -EEXIST; + if (st.st_uid != geteuid()) return -EPERM; + if (unlink(path) < 0) return -errno; + return 0; +} + +static bool peer_has_same_uid(int fd) { + struct ucred cred; + socklen_t len = sizeof(cred); + if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) < 0) { + return false; + } + return cred.uid == geteuid(); +} + +static void close_client_locked(amy_unix_socket_server_t *server) { + if (server->client_fd >= 0) { + shutdown(server->client_fd, SHUT_RDWR); + close(server->client_fd); + server->client_fd = -1; + } +} + +static void close_client(amy_unix_socket_server_t *server) { + pthread_mutex_lock(&server->client_lock); + close_client_locked(server); + pthread_mutex_unlock(&server->client_lock); +} + +static void queue_packet(amy_unix_socket_server_t *server, + const char *data, + size_t len) { + if (len == 0) return; + if (len > AMY_UNIX_SOCKET_MAX_PACKET) { + increment_u32(&server->oversize_packets); + return; + } + + uint32_t write_index = load_u32(&server->write_index); + uint32_t read_index = load_u32(&server->read_index); + if ((uint32_t)(write_index - read_index) >= + AMY_UNIX_SOCKET_QUEUE_CAPACITY) { + increment_u32(&server->queue_overruns); + return; + } + + struct amy_unix_socket_packet *slot = + &server->queue[write_index % AMY_UNIX_SOCKET_QUEUE_CAPACITY]; + memcpy(slot->data, data, len); + slot->data[len] = '\0'; + slot->len = (uint16_t)len; + + store_u32(&server->write_index, write_index + 1u); +} + +static void receive_client_packets(amy_unix_socket_server_t *server, + int client_fd) { + for (;;) { + char packet[MAX_MESSAGE_LEN]; + ssize_t received = recv(client_fd, + packet, + sizeof(packet), + MSG_DONTWAIT | MSG_TRUNC); + if (received > 0) { + if ((size_t)received > AMY_UNIX_SOCKET_MAX_PACKET) { + increment_u32(&server->oversize_packets); + } else { + queue_packet(server, packet, (size_t)received); + } + continue; + } + + if (received == 0) { + close_client(server); + return; + } + + if (errno == EAGAIN || errno == EWOULDBLOCK) return; + if (errno == EINTR) continue; + + close_client(server); + return; + } +} + +static void accept_clients(amy_unix_socket_server_t *server) { + for (;;) { + int fd = accept(server->listen_fd, NULL, NULL); + if (fd < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) return; + if (errno == EINTR) continue; + return; + } + + if (set_nonblocking_cloexec(fd) < 0 || !peer_has_same_uid(fd)) { + increment_u32(&server->rejected_peers); + close(fd); + continue; + } + + pthread_mutex_lock(&server->client_lock); + if (server->client_fd >= 0) { + increment_u32(&server->rejected_peers); + close(fd); + } else { + server->client_fd = fd; + } + pthread_mutex_unlock(&server->client_lock); + } +} + +static int current_client_fd(amy_unix_socket_server_t *server) { + int fd; + pthread_mutex_lock(&server->client_lock); + fd = server->client_fd; + pthread_mutex_unlock(&server->client_lock); + return fd; +} + +static void *socket_thread(void *arg) { + amy_unix_socket_server_t *server = + (amy_unix_socket_server_t *)arg; + + while (load_u32(&server->running)) { + struct pollfd fds[2]; + nfds_t count = 1; + + fds[0].fd = server->listen_fd; + fds[0].events = POLLIN; + fds[0].revents = 0; + + int client_fd = current_client_fd(server); + if (client_fd >= 0) { + fds[1].fd = client_fd; + fds[1].events = POLLIN; + fds[1].revents = 0; + count = 2; + } + + int ready = poll(fds, count, AMY_UNIX_SOCKET_POLL_MS); + if (ready < 0) { + if (errno == EINTR) continue; + break; + } + if (ready == 0) continue; + + if (fds[0].revents & POLLIN) accept_clients(server); + + if (count == 2) { + if (fds[1].revents & POLLIN) { + receive_client_packets(server, client_fd); + } + if (fds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) { + close_client(server); + } + } + } + + close_client(server); + return NULL; +} + +int amy_unix_socket_start(amy_unix_socket_server_t **out_server, + const char *path) { + if (out_server == NULL || path == NULL || path[0] == '\0') return -EINVAL; + *out_server = NULL; + + size_t path_len = strlen(path); + if (path_len >= sizeof(((struct sockaddr_un *)0)->sun_path)) { + return -ENAMETOOLONG; + } + + int rc = remove_owned_stale_socket(path); + if (rc < 0) return rc; + + amy_unix_socket_server_t *server = calloc(1, sizeof(*server)); + if (server == NULL) return -ENOMEM; + + server->listen_fd = -1; + server->client_fd = -1; + memcpy(server->path, path, path_len + 1u); + + int mutex_rc = pthread_mutex_init(&server->client_lock, NULL); + if (mutex_rc != 0) { + free(server); + return -mutex_rc; + } + + int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + if (fd < 0) { + rc = -errno; + goto fail; + } + server->listen_fd = fd; + + rc = set_nonblocking_cloexec(fd); + if (rc < 0) goto fail; + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + memcpy(addr.sun_path, path, path_len + 1u); + + if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + rc = -errno; + goto fail; + } + + // The Android app-data parent directory is already sandboxed. Mode 0600 + // additionally makes filesystem pathname access same-UID only. + if (chmod(path, S_IRUSR | S_IWUSR) < 0) { + rc = -errno; + goto fail; + } + + if (listen(fd, 1) < 0) { + rc = -errno; + goto fail; + } + + store_u32(&server->running, 1u); + int thread_rc = pthread_create(&server->thread, NULL, + socket_thread, server); + if (thread_rc != 0) { + rc = -thread_rc; + store_u32(&server->running, 0u); + goto fail; + } + server->thread_started = true; + + *out_server = server; + return 0; + +fail: + if (server->listen_fd >= 0) close(server->listen_fd); + if (server->path[0] != '\0') unlink(server->path); + pthread_mutex_destroy(&server->client_lock); + free(server); + return rc; +} + +void amy_unix_socket_stop(amy_unix_socket_server_t *server) { + if (server == NULL) return; + + store_u32(&server->running, 0u); + if (server->thread_started) { + pthread_join(server->thread, NULL); + } + + if (server->listen_fd >= 0) { + close(server->listen_fd); + server->listen_fd = -1; + } + + if (server->path[0] != '\0') unlink(server->path); + pthread_mutex_destroy(&server->client_lock); + free(server); +} + +int amy_unix_socket_receive(amy_unix_socket_server_t *server, + char *out, + size_t out_len) { + if (server == NULL || out == NULL) return -EINVAL; + + uint32_t read_index = load_u32(&server->read_index); + uint32_t write_index = load_u32(&server->write_index); + if (read_index == write_index) return 0; + + const struct amy_unix_socket_packet *slot = + &server->queue[read_index % AMY_UNIX_SOCKET_QUEUE_CAPACITY]; + size_t len = slot->len; + if (out_len <= len) return -EMSGSIZE; + + memcpy(out, slot->data, len); + out[len] = '\0'; + store_u32(&server->read_index, read_index + 1u); + return (int)len; +} + +int amy_unix_socket_send(amy_unix_socket_server_t *server, + const void *data, + size_t len) { + if (server == NULL || (data == NULL && len != 0)) return -EINVAL; + if (len > AMY_UNIX_SOCKET_MAX_PACKET) return -EMSGSIZE; + + pthread_mutex_lock(&server->client_lock); + int fd = server->client_fd; + if (fd < 0) { + pthread_mutex_unlock(&server->client_lock); + return -ENOTCONN; + } + + ssize_t sent = send(fd, data, len, + MSG_DONTWAIT | MSG_NOSIGNAL); + int saved_errno = errno; + pthread_mutex_unlock(&server->client_lock); + + if (sent < 0) return -saved_errno; + return (int)sent; +} + +uint32_t amy_unix_socket_queue_overruns( + const amy_unix_socket_server_t *server) { + return server == NULL ? 0u : load_u32(&server->queue_overruns); +} + +uint32_t amy_unix_socket_oversize_packets( + const amy_unix_socket_server_t *server) { + return server == NULL ? 0u : load_u32(&server->oversize_packets); +} + +uint32_t amy_unix_socket_rejected_peers( + const amy_unix_socket_server_t *server) { + return server == NULL ? 0u : load_u32(&server->rejected_peers); +} + +#else + +#include + +int amy_unix_socket_start(amy_unix_socket_server_t **out_server, + const char *path) { + (void)out_server; + (void)path; + return -ENOTSUP; +} + +void amy_unix_socket_stop(amy_unix_socket_server_t *server) { + (void)server; +} + +int amy_unix_socket_receive(amy_unix_socket_server_t *server, + char *out, + size_t out_len) { + (void)server; + (void)out; + (void)out_len; + return -ENOTSUP; +} + +int amy_unix_socket_send(amy_unix_socket_server_t *server, + const void *data, + size_t len) { + (void)server; + (void)data; + (void)len; + return -ENOTSUP; +} + +uint32_t amy_unix_socket_queue_overruns( + const amy_unix_socket_server_t *server) { + (void)server; + return 0u; +} + +uint32_t amy_unix_socket_oversize_packets( + const amy_unix_socket_server_t *server) { + (void)server; + return 0u; +} + +uint32_t amy_unix_socket_rejected_peers( + const amy_unix_socket_server_t *server) { + (void)server; + return 0u; +} + +#endif diff --git a/src/amy_unix_socket.h b/src/amy_unix_socket.h new file mode 100644 index 00000000..245e3937 --- /dev/null +++ b/src/amy_unix_socket.h @@ -0,0 +1,70 @@ +#ifndef AMY_UNIX_SOCKET_H +#define AMY_UNIX_SOCKET_H + +#include +#include + +#include "amy.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Private pathname AF_UNIX transport for local AMY control. +// +// Intended Android topology: +// Qt/Python process <-> amy.sock <-> native AMY/Oboe process +// +// The socket thread never calls AMY. It only copies complete SOCK_SEQPACKET +// packets into this fixed SPSC queue. The audio/control owner drains packets +// explicitly at a safe point (for example, immediately before rendering the +// next AMY block) and may then pass them to amy_add_message(). +// +// One connected client is supported at a time. On Linux/Android, accepted +// peers must have the same effective UID as the server process. The pathname +// is created mode 0600 and a stale socket is removed only when it is owned by +// the same UID; an existing non-socket path is never removed. + +#define AMY_UNIX_SOCKET_QUEUE_CAPACITY 64u +#define AMY_UNIX_SOCKET_MAX_PACKET ((size_t)MAX_MESSAGE_LEN - 1u) + +typedef struct amy_unix_socket_server amy_unix_socket_server_t; + +// Start a server at path. Returns 0 on success or -errno on failure. +// out_server is set only on success. +int amy_unix_socket_start(amy_unix_socket_server_t **out_server, + const char *path); + +// Stop the receiver thread, close any client, unlink the socket pathname and +// free the server. Safe to call with NULL. +void amy_unix_socket_stop(amy_unix_socket_server_t *server); + +// Non-blocking dequeue for the AMY/control owner. +// Returns payload length (>0), 0 when no packet is queued, or -errno. +// On success out is NUL-terminated; packet payloads themselves need not carry +// a trailing NUL. If out_len is too small, returns -EMSGSIZE and leaves the +// packet queued. +int amy_unix_socket_receive(amy_unix_socket_server_t *server, + char *out, + size_t out_len); + +// Send one reply packet to the currently connected client. This is intended +// for non-realtime status/introspection replies, not the audio callback. +// Returns bytes sent or -errno. The accepted client socket is non-blocking. +int amy_unix_socket_send(amy_unix_socket_server_t *server, + const void *data, + size_t len); + +// Diagnostic counters. They are monotonic until the server is stopped. +uint32_t amy_unix_socket_queue_overruns( + const amy_unix_socket_server_t *server); +uint32_t amy_unix_socket_oversize_packets( + const amy_unix_socket_server_t *server); +uint32_t amy_unix_socket_rejected_peers( + const amy_unix_socket_server_t *server); + +#ifdef __cplusplus +} +#endif + +#endif // AMY_UNIX_SOCKET_H diff --git a/tests/run_amy_unix_socket_test.sh b/tests/run_amy_unix_socket_test.sh new file mode 100644 index 00000000..c3b22a21 --- /dev/null +++ b/tests/run_amy_unix_socket_test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +out="${TMPDIR:-/tmp}/test_amy_unix_socket" + +cc \ + -std=c11 \ + -O2 \ + -Wall \ + -Wextra \ + -Werror \ + -pthread \ + -I"$repo_root/src" \ + "$repo_root/src/amy_unix_socket.c" \ + "$repo_root/tests/test_amy_unix_socket.c" \ + -o "$out" + +"$out" +rm -f "$out" diff --git a/tests/test_amy_unix_socket.c b/tests/test_amy_unix_socket.c new file mode 100644 index 00000000..8310cc32 --- /dev/null +++ b/tests/test_amy_unix_socket.c @@ -0,0 +1,183 @@ +#define _GNU_SOURCE + +#include "amy_unix_socket.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int connect_client(const char *path) { + int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + assert(fd >= 0); + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + assert(strlen(path) < sizeof(addr.sun_path)); + strcpy(addr.sun_path, path); + + assert(connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0); + return fd; +} + +static int wait_receive(amy_unix_socket_server_t *server, + char *buffer, + size_t buffer_len) { + for (int i = 0; i < 1000; ++i) { + int rc = amy_unix_socket_receive(server, buffer, buffer_len); + if (rc != 0) return rc; + usleep(1000); + } + return -ETIMEDOUT; +} + +static ssize_t wait_client_receive(int fd, void *buffer, size_t len) { + for (int i = 0; i < 1000; ++i) { + ssize_t rc = recv(fd, buffer, len, MSG_DONTWAIT); + if (rc >= 0) return rc; + if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + return -1; + } + usleep(1000); + } + errno = ETIMEDOUT; + return -1; +} + +static void test_round_trip_and_permissions(void) { + char dir_template[] = "/tmp/amy-unix-socket-XXXXXX"; + char *dir = mkdtemp(dir_template); + assert(dir != NULL); + assert(chmod(dir, 0700) == 0); + + char path[256]; + snprintf(path, sizeof(path), "%s/amy.sock", dir); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + assert(server != NULL); + + struct stat st; + assert(lstat(path, &st) == 0); + assert(S_ISSOCK(st.st_mode)); + assert((st.st_mode & 0777) == 0600); + assert(st.st_uid == geteuid()); + + int client = connect_client(path); + + const char command[] = "n60l1i2Z"; + assert(send(client, command, strlen(command), 0) == + (ssize_t)strlen(command)); + + char received[MAX_MESSAGE_LEN]; + int rc = wait_receive(server, received, sizeof(received)); + assert(rc == (int)strlen(command)); + assert(strcmp(received, command) == 0); + + // Too-small destination must not consume the next queued packet. + const char second[] = "K28i2Z"; + assert(send(client, second, strlen(second), 0) == + (ssize_t)strlen(second)); + for (int i = 0; i < 1000; ++i) { + rc = amy_unix_socket_receive(server, received, 4); + if (rc != 0) break; + usleep(1000); + } + assert(rc == -EMSGSIZE); + rc = amy_unix_socket_receive(server, received, sizeof(received)); + assert(rc == (int)strlen(second)); + assert(strcmp(received, second) == 0); + + const char reply[] = "!iv1"; + for (int i = 0; i < 1000; ++i) { + rc = amy_unix_socket_send(server, reply, strlen(reply)); + if (rc != -ENOTCONN) break; + usleep(1000); + } + assert(rc == (int)strlen(reply)); + + char reply_buffer[32]; + ssize_t reply_len = wait_client_receive(client, + reply_buffer, + sizeof(reply_buffer)); + assert(reply_len == (ssize_t)strlen(reply)); + assert(memcmp(reply_buffer, reply, strlen(reply)) == 0); + + close(client); + amy_unix_socket_stop(server); + + assert(lstat(path, &st) < 0); + assert(errno == ENOENT); + assert(rmdir(dir) == 0); +} + +static void test_oversize_packet_is_dropped(void) { + char dir_template[] = "/tmp/amy-unix-oversize-XXXXXX"; + char *dir = mkdtemp(dir_template); + assert(dir != NULL); + assert(chmod(dir, 0700) == 0); + + char path[256]; + snprintf(path, sizeof(path), "%s/amy.sock", dir); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + int client = connect_client(path); + + char packet[MAX_MESSAGE_LEN]; + memset(packet, 'x', sizeof(packet)); + assert(send(client, packet, sizeof(packet), 0) == (ssize_t)sizeof(packet)); + + for (int i = 0; i < 1000; ++i) { + if (amy_unix_socket_oversize_packets(server) != 0) break; + usleep(1000); + } + assert(amy_unix_socket_oversize_packets(server) == 1); + + char received[MAX_MESSAGE_LEN]; + assert(amy_unix_socket_receive(server, received, sizeof(received)) == 0); + + close(client); + amy_unix_socket_stop(server); + assert(rmdir(dir) == 0); +} + +static void test_existing_regular_file_is_never_removed(void) { + char dir_template[] = "/tmp/amy-unix-stale-XXXXXX"; + char *dir = mkdtemp(dir_template); + assert(dir != NULL); + assert(chmod(dir, 0700) == 0); + + char path[256]; + snprintf(path, sizeof(path), "%s/amy.sock", dir); + + int fd = open(path, O_CREAT | O_WRONLY | O_EXCL, 0600); + assert(fd >= 0); + close(fd); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == -EEXIST); + assert(server == NULL); + + struct stat st; + assert(lstat(path, &st) == 0); + assert(S_ISREG(st.st_mode)); + + assert(unlink(path) == 0); + assert(rmdir(dir) == 0); +} + +int main(void) { + test_round_trip_and_permissions(); + test_oversize_packet_is_dropped(); + test_existing_regular_file_is_never_removed(); + puts("amy unix socket tests passed"); + return 0; +}