diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..8e2c19ff --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,49 @@ +name: Android Build + +on: + push: + paths-ignore: + - 'docs/**' + - '**.md' + pull_request: + paths-ignore: + - '**.md' + - 'docs/**' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - uses: android-actions/setup-android@v3 + + - run: yes | sdkmanager --licenses || true + + - run: | + sdkmanager --install "platforms;android-34" + sdkmanager --install "build-tools;34.0.0" + sdkmanager --install "ndk;25.2.9519653" + sdkmanager --install "cmake;3.22.1" + + - uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - run: chmod +x examples/android/gradlew + + - run: | + cd examples/android + ./gradlew assembleDebug --no-daemon + diff --git a/.github/workflows/build_cmake.yml b/.github/workflows/build_cmake.yml index 15f54a7c..2eb9bf17 100644 --- a/.github/workflows/build_cmake.yml +++ b/.github/workflows/build_cmake.yml @@ -9,6 +9,7 @@ on: paths-ignore: - '**.md' - 'docs/**' + jobs: build: name: ${{ matrix.config.name }} diff --git a/.github/workflows/mingw.yml b/.github/workflows/mingw.yml index e8cc5b6e..91a17922 100644 --- a/.github/workflows/mingw.yml +++ b/.github/workflows/mingw.yml @@ -1,6 +1,14 @@ name: MSYS2 build -on: push +on: + push: + paths-ignore: + - 'docs/**' + - '**.md' + pull_request: + paths-ignore: + - '**.md' + - 'docs/**' jobs: mingw: diff --git a/.gitignore b/.gitignore index 9901a40d..2cf47b1c 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,86 @@ build .vscode +### Generated by gibo (https://github.com/simonwhitaker/gibo) +### https://raw.github.com/github/gitignore/15d684578f37580886f29e4b8462f3a27f722cb5/Android.gitignore + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.aab +*.apk +output-metadata.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof +### Generated by gibo (https://github.com/simonwhitaker/gibo) +### https://raw.github.com/github/gitignore/15d684578f37580886f29e4b8462f3a27f722cb5/Gradle.gitignore + +.gradle +**/build/ +!**/src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath +### Generated by gibo (https://github.com/simonwhitaker/gibo) +### https://raw.github.com/github/gitignore/15d684578f37580886f29e4b8462f3a27f722cb5/CMake.gitignore + +CMakeLists.txt.user +CMakeCache.txt +CMakeFiles +CMakeScripts +Testing +Makefile +cmake_install.cmake +install_manifest.txt +compile_commands.json +CTestTestfile.cmake +_deps +CMakeUserPresets.json + +# CLion +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#cmake-build-* diff --git a/CMakeLists.txt b/CMakeLists.txt index 4a05c384..2528ba99 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,6 +35,7 @@ option(LIBREMIDI_NO_JACK "Disable JACK back-end" OFF) option(LIBREMIDI_NO_PIPEWIRE "Disable PipeWire back-end" OFF) option(LIBREMIDI_NO_NETWORK "Disable Network back-end" OFF) option(LIBREMIDI_NO_KEYBOARD "Disable Computer keyboard back-end" OFF) +cmake_dependent_option(LIBREMIDI_NO_ANDROID "Disable Android AMidi back-end" OFF "ANDROID" OFF) option(LIBREMIDI_NO_EXPORTS "Disable dynamic symbol exporting" OFF) option(LIBREMIDI_NO_BOOST "Do not use Boost if available" OFF) @@ -70,6 +71,7 @@ include(libremidi.jack) include(libremidi.pipewire) include(libremidi.keyboard) include(libremidi.net) +include(libremidi.android) ### Install ### include(libremidi.install) diff --git a/cmake/libremidi.android.cmake b/cmake/libremidi.android.cmake new file mode 100644 index 00000000..70b36c46 --- /dev/null +++ b/cmake/libremidi.android.cmake @@ -0,0 +1,18 @@ +### Android ### +if(ANDROID AND NOT LIBREMIDI_NO_ANDROID) + # Check for AMidi API availability (API level 29+) + # Also we need API 31+ for JNI_GetCreatedJavaVMs + if(ANDROID_NATIVE_API_LEVEL AND ANDROID_NATIVE_API_LEVEL GREATER_EQUAL 31) + message(STATUS "libremidi: Using Android AMidi API") + message(STATUS " !!! Remember to include MidiDeviceCallback.java in your Android app") + message(STATUS " !!! See the example in examples/android/java/dev/celtera/libremidi") + + target_compile_definitions(libremidi ${_public} LIBREMIDI_ANDROID=1) + target_link_libraries(libremidi ${_public} amidi log nativehelper) + target_sources(libremidi ${_public} + "$" + ) + else() + message(FATAL_ERROR "libremidi: Android AMidi API requires API level 31+ (current: ${ANDROID_NATIVE_API_LEVEL})") + endif() +endif() diff --git a/examples/android/app/build.gradle b/examples/android/app/build.gradle new file mode 100644 index 00000000..5fa61344 --- /dev/null +++ b/examples/android/app/build.gradle @@ -0,0 +1,51 @@ +apply plugin: 'com.android.application' + +android { + compileSdk 34 + ndkVersion "25.2.9519653" + + defaultConfig { + applicationId "com.libremidi.example" + minSdk 31 + targetSdk 34 + versionCode 1 + versionName "1.0" + + externalNativeBuild { + cmake { + cppFlags "-std=c++20 -frtti -fexceptions" + arguments "-DANDROID_STL=c++_shared" + } + } + ndk { + abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64' + } + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + externalNativeBuild { + cmake { + path "src/main/cpp/CMakeLists.txt" + version "3.22.1" + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + namespace 'com.libremidi.example' +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' +} \ No newline at end of file diff --git a/examples/android/app/src/main/AndroidManifest.xml b/examples/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..6b1f7ecd --- /dev/null +++ b/examples/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/android/app/src/main/cpp/CMakeLists.txt b/examples/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..9ddd6d98 --- /dev/null +++ b/examples/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.22.1) +project(libremidi-example) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Find libremidi in the parent directory +set(LIBREMIDI_NO_ANDROID 0) +set(BUILD_SHARED_LIBS 1) +set(LIBREMIDI_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../..") +add_subdirectory(${LIBREMIDI_ROOT} libremidi-build) + +# Create shared library +add_library(libremidi-example SHARED + native-lib.cpp +) + +# Link libremidi +target_link_libraries(libremidi-example + libremidi + android + log +) + +# Include directories +target_include_directories(libremidi-example PRIVATE + ${LIBREMIDI_ROOT}/include +) \ No newline at end of file diff --git a/examples/android/app/src/main/cpp/native-lib.cpp b/examples/android/app/src/main/cpp/native-lib.cpp new file mode 100644 index 00000000..ae2c09ae --- /dev/null +++ b/examples/android/app/src/main/cpp/native-lib.cpp @@ -0,0 +1,73 @@ +#include +#include +#include +#include +#include + +#include +extern "C" JNIEXPORT jstring JNICALL +Java_com_libremidi_example_MainActivity_getMidiDevices(JNIEnv* env, jobject /* this */) { + std::stringstream result; + __android_log_print(ANDROID_LOG_WARN, "libremidi", "Java_com_libremidi_example_MainActivity_getMidiDevices!!"); + + try { + auto api = libremidi::API::ANDROID_AMIDI; + libremidi::observer obs{{.track_any = true}, libremidi::observer_configuration_for(api)}; + + result << "=== MIDI Input Devices ===\n"; + auto inputs = obs.get_input_ports(); + if (inputs.empty()) { + result << "No input devices found\n"; + } else { + for (const auto& port : inputs) { + result << "Port " << port.port << ": " << port.port_name; + if (!port.device_name.empty() && port.device_name != port.port_name) { + result << " (Device: " << port.device_name << ")"; + } + if (!port.display_name.empty() && port.display_name != port.port_name) { + result << " [" << port.display_name << "]"; + } + result << "\n"; + + __android_log_print(ANDROID_LOG_WARN, "libremidi", "opening: %s", port.port_name.c_str()); + auto midi_in = new libremidi::midi_in{{.on_message{[](const libremidi::message& m) { + __android_log_print(ANDROID_LOG_WARN, "libremidi", "ayy!!"); + }}}, libremidi::midi_in_configuration_for(api)}; + + auto p = midi_in->get_current_api(); + + __android_log_print(ANDROID_LOG_WARN, "libremidi", ">> ??! %s", libremidi::get_api_name(p).data()); + __android_log_print(ANDROID_LOG_WARN, "libremidi", ">> open port?!"); + midi_in->open_port(port); + } + } + + result << "\n=== MIDI Output Devices ===\n"; + auto outputs = obs.get_output_ports(); + if (outputs.empty()) { + result << "No output devices found\n"; + } else { + for (const auto& port : outputs) { + result << "Port " << port.port << ": " << port.port_name; + if (!port.device_name.empty() && port.device_name != port.port_name) { + result << " (Device: " << port.device_name << ")"; + } + if (!port.display_name.empty() && port.display_name != port.port_name) { + result << " [" << port.display_name << "]"; + } + result << "\n"; + } + } + + } catch (const std::exception& e) { + result << "Error: " << e.what() << "\n"; + } + + __android_log_print(ANDROID_LOG_WARN, "libremidi", "FINITO!!"); + return env->NewStringUTF(result.str().c_str()); +} + +extern "C" JNIEXPORT jstring JNICALL +Java_com_libremidi_example_MainActivity_getLibremidiVersion(JNIEnv* env, jobject /* this */) { + return env->NewStringUTF("libremidi Android example"); +} \ No newline at end of file diff --git a/examples/android/app/src/main/java/com/libremidi/example/MainActivity.java b/examples/android/app/src/main/java/com/libremidi/example/MainActivity.java new file mode 100644 index 00000000..9f7542ed --- /dev/null +++ b/examples/android/app/src/main/java/com/libremidi/example/MainActivity.java @@ -0,0 +1,71 @@ +package com.libremidi.example; + +import androidx.appcompat.app.AppCompatActivity; +import android.os.Bundle; +import android.widget.TextView; +import android.widget.Button; +import android.widget.ScrollView; +import android.view.ViewGroup.LayoutParams; +import android.widget.LinearLayout; +import android.graphics.Typeface; +import android.util.TypedValue; + +public class MainActivity extends AppCompatActivity { + + static { + System.loadLibrary("libremidi-example"); + } + + private TextView deviceListTextView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setPadding(16, 16, 16, 16); + + TextView titleView = new TextView(this); + titleView.setText("LibreMIDI Android Example"); + titleView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 24); + titleView.setTypeface(null, Typeface.BOLD); + titleView.setPadding(0, 0, 0, 16); + layout.addView(titleView); + + TextView versionView = new TextView(this); + versionView.setText(getLibremidiVersion()); + versionView.setPadding(0, 0, 0, 16); + layout.addView(versionView); + + Button refreshButton = new Button(this); + refreshButton.setText("Refresh MIDI Devices"); + refreshButton.setOnClickListener(v -> refreshDevices()); + layout.addView(refreshButton); + + ScrollView scrollView = new ScrollView(this); + deviceListTextView = new TextView(this); + deviceListTextView.setTypeface(Typeface.MONOSPACE); + deviceListTextView.setPadding(0, 16, 0, 0); + scrollView.addView(deviceListTextView); + + LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT + ); + scrollParams.weight = 1; + layout.addView(scrollView, scrollParams); + + setContentView(layout); + + refreshDevices(); + } + + private void refreshDevices() { + String devices = getMidiDevices(); + deviceListTextView.setText(devices); + } + + public native String getMidiDevices(); + public native String getLibremidiVersion(); +} \ No newline at end of file diff --git a/examples/android/app/src/main/java/dev/celtera/libremidi/MidiDeviceCallback.java b/examples/android/app/src/main/java/dev/celtera/libremidi/MidiDeviceCallback.java new file mode 100644 index 00000000..be0ab6ad --- /dev/null +++ b/examples/android/app/src/main/java/dev/celtera/libremidi/MidiDeviceCallback.java @@ -0,0 +1,34 @@ +package dev.celtera.libremidi; + +import android.media.midi.MidiDevice; +import android.media.midi.MidiManager; +import android.util.Log; + +public class MidiDeviceCallback implements MidiManager.OnDeviceOpenedListener { + private static final String TAG = "libremidi"; + private long nativePtr; + private boolean isOutput; + + public MidiDeviceCallback(long ptr, boolean output) { + nativePtr = ptr; + isOutput = output; + } + @Override + public void onDeviceOpened(MidiDevice device) { + if (device == null) { + Log.e(TAG, "Failed to open MIDI device"); + return; + } + + Log.i(TAG, "MIDI device opened successfully"); + onDeviceOpened(device, nativePtr, isOutput); + } + + // Native method declaration + private native void onDeviceOpened(MidiDevice device, long targetPtr, boolean isOutput); + + static { + // Load the native library containing the JNI implementation + // System.loadLibrary("remidi"); + } +} diff --git a/examples/android/build.gradle b/examples/android/build.gradle new file mode 100644 index 00000000..e86ec200 --- /dev/null +++ b/examples/android/build.gradle @@ -0,0 +1,20 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.11.1' + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/examples/android/gradle.properties b/examples/android/gradle.properties new file mode 100644 index 00000000..976ff4ec --- /dev/null +++ b/examples/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/examples/android/gradle/wrapper/gradle-wrapper.jar b/examples/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..980502d1 Binary files /dev/null and b/examples/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/android/gradle/wrapper/gradle-wrapper.properties b/examples/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..128196a7 --- /dev/null +++ b/examples/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0-milestone-1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/android/gradlew b/examples/android/gradlew new file mode 100755 index 00000000..faf93008 --- /dev/null +++ b/examples/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/android/gradlew.bat b/examples/android/gradlew.bat new file mode 100644 index 00000000..9b42019c --- /dev/null +++ b/examples/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/android/settings.gradle b/examples/android/settings.gradle new file mode 100644 index 00000000..9d495b34 --- /dev/null +++ b/examples/android/settings.gradle @@ -0,0 +1 @@ +include ':app' \ No newline at end of file diff --git a/include/libremidi/api-c.h b/include/libremidi/api-c.h index 8822253f..69a13d0c 100644 --- a/include/libremidi/api-c.h +++ b/include/libremidi/api-c.h @@ -22,6 +22,7 @@ typedef enum libremidi_api PIPEWIRE, /*!< PipeWire */ KEYBOARD, /*!< Computer keyboard input */ NETWORK, /*!< MIDI over IP */ + ANDROID_AMIDI, /*!< Android AMidi API */ // MIDI 2.0 APIs ALSA_RAW_UMP = 0x1000, /*!< Raw ALSA API for MIDI 2.0 */ diff --git a/include/libremidi/api.hpp b/include/libremidi/api.hpp index 44add71b..2f1e7df1 100644 --- a/include/libremidi/api.hpp +++ b/include/libremidi/api.hpp @@ -64,6 +64,8 @@ inline constexpr libremidi::API default_api() noexcept return API::ALSA_SEQ; #elif defined(__emscripten__) return API::EMSCRIPTEN_WEBMIDI; +#elif defined(LIBREMIDI_ANDROID) + return API::ANDROID_AMIDI; #else return API::DUMMY; #endif diff --git a/include/libremidi/backends.hpp b/include/libremidi/backends.hpp index 18c42a12..292336f9 100644 --- a/include/libremidi/backends.hpp +++ b/include/libremidi/backends.hpp @@ -71,6 +71,10 @@ #include #endif +#if defined(LIBREMIDI_ANDROID) + #include +#endif + namespace libremidi { // The order here will control the order of the API search in @@ -124,6 +128,10 @@ static constexpr auto available_backends = make_tl( #if defined(LIBREMIDI_NETWORK) , net::backend{} +#endif +#if defined(LIBREMIDI_ANDROID) + , + android::backend{} #endif , dummy_backend{}); diff --git a/include/libremidi/backends/android/android.hpp b/include/libremidi/backends/android/android.hpp new file mode 100644 index 00000000..53b16e05 --- /dev/null +++ b/include/libremidi/backends/android/android.hpp @@ -0,0 +1,33 @@ +#pragma once +#include +#include +#include +#include + +namespace libremidi +{ +namespace android +{ +struct backend +{ + using midi_in = android::midi_in; + using midi_out = android::midi_out; + using midi_observer = android::observer; + using midi_in_configuration = android::input_configuration; + using midi_out_configuration = android::output_configuration; + using midi_observer_configuration = android::observer_configuration; + static const constexpr auto API = libremidi::API::ANDROID_AMIDI; + static const constexpr std::string_view name = "android"; + static const constexpr std::string_view display_name = "Android MIDI API"; + + static bool available() noexcept + { +#if defined(__ANDROID__) + return true; +#else + return false; +#endif + } +}; +} +} diff --git a/include/libremidi/backends/android/config.hpp b/include/libremidi/backends/android/config.hpp new file mode 100644 index 00000000..1f805edf --- /dev/null +++ b/include/libremidi/backends/android/config.hpp @@ -0,0 +1,23 @@ +#pragma once +#include + +namespace libremidi +{ +namespace android +{ +struct input_configuration +{ + std::string_view client_name = "libremidi client"; +}; + +struct output_configuration +{ + std::string_view client_name = "libremidi client"; +}; + +struct observer_configuration +{ + std::string_view client_name = "libremidi client"; +}; +} +} \ No newline at end of file diff --git a/include/libremidi/backends/android/helpers.cpp b/include/libremidi/backends/android/helpers.cpp new file mode 100644 index 00000000..f41bedbb --- /dev/null +++ b/include/libremidi/backends/android/helpers.cpp @@ -0,0 +1,239 @@ +#include +#include +#include + +namespace libremidi::android +{ +// Main source of knowledge is RtMidi implementation +// which was done by Yellow Labrador + +JNIEnv* context::get_thread_env() +{ + JNIEnv* env; + JavaVM* jvm; + jsize count; + if (JNI_GetCreatedJavaVMs(&jvm, 1, &count) != JNI_OK || count < 1) + { + LOGE("No JVM found"); + return nullptr; + } + + if (jvm->GetEnv((void**)&env, JNI_VERSION_1_6) == JNI_EDETACHED) + { + if (jvm->AttachCurrentThread(&env, nullptr) != JNI_OK) + { + LOGE("Failed to attach thread"); + return nullptr; + } + } + + return env; +} + +jobject context::get_context(JNIEnv* env) +{ + auto activity_thread = env->FindClass("android/app/ActivityThread"); + auto current_activity_thread = env->GetStaticMethodID( + activity_thread, "currentActivityThread", "()Landroid/app/ActivityThread;"); + auto at = env->CallStaticObjectMethod(activity_thread, current_activity_thread); + + if (!at) + { + LOGE("Failed to get ActivityThread"); + return nullptr; + } + + auto get_application + = env->GetMethodID(activity_thread, "getApplication", "()Landroid/app/Application;"); + auto app = env->CallObjectMethod(at, get_application); + + if (!app) + { + LOGE("Failed to get Application"); + return nullptr; + } + + return app; +} + +jobject context::get_midi_manager(JNIEnv* env, jobject ctx) +{ + auto context_class = env->FindClass("android/content/Context"); + auto get_system_service = env->GetMethodID( + context_class, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;"); + auto midi_service_str = env->NewStringUTF("midi"); + auto service = env->CallObjectMethod(ctx, get_system_service, midi_service_str); + env->DeleteLocalRef(midi_service_str); + return service; +} + +void context::refresh_midi_devices(JNIEnv* env, jobject ctx, bool is_output) +{ + cleanup_devices(env); + + auto midi_service = get_midi_manager(env, ctx); + if (!midi_service) + return; + + auto midi_mgr_class = env->FindClass("android/media/midi/MidiManager"); + auto get_devices_method + = env->GetMethodID(midi_mgr_class, "getDevices", "()[Landroid/media/midi/MidiDeviceInfo;"); + auto device_array = (jobjectArray)env->CallObjectMethod(midi_service, get_devices_method); + + auto device_info_class = env->FindClass("android/media/midi/MidiDeviceInfo"); + auto get_input_count = env->GetMethodID(device_info_class, "getInputPortCount", "()I"); + auto get_output_count = env->GetMethodID(device_info_class, "getOutputPortCount", "()I"); + + jsize count = env->GetArrayLength(device_array); + for (jsize i = 0; i < count; ++i) + { + auto device_info = env->GetObjectArrayElement(device_array, i); + int port_count = is_output ? env->CallIntMethod(device_info, get_input_count) + : env->CallIntMethod(device_info, get_output_count); + + if (port_count > 0) + { + midi_devices.push_back(env->NewGlobalRef(device_info)); + } + env->DeleteLocalRef(device_info); + } + + env->DeleteLocalRef(device_array); + env->DeleteLocalRef(midi_service); +} + +std::string context::port_name(JNIEnv* env, unsigned int port_number) +{ + if (port_number >= midi_devices.size()) + { + LOGE("Invalid port number"); + return ""; + } + + auto device_info_class = env->FindClass("android/media/midi/MidiDeviceInfo"); + auto get_props_method + = env->GetMethodID(device_info_class, "getProperties", "()Landroid/os/Bundle;"); + auto bundle = env->CallObjectMethod(midi_devices[port_number], get_props_method); + + auto bundle_class = env->FindClass("android/os/Bundle"); + auto get_string_method + = env->GetMethodID(bundle_class, "getString", "(Ljava/lang/String;)Ljava/lang/String;"); + auto key = env->NewStringUTF("name"); + auto name_str = (jstring)env->CallObjectMethod(bundle, get_string_method, key); + + std::string result; + if (name_str) + { + const char* name_chars = env->GetStringUTFChars(name_str, nullptr); + result = name_chars; + env->ReleaseStringUTFChars(name_str, name_chars); + env->DeleteLocalRef(name_str); + } + + env->DeleteLocalRef(key); + env->DeleteLocalRef(bundle); + + return result; +} + +void context::cleanup_devices(JNIEnv* env) +{ + for (jobject device : midi_devices) + { + env->DeleteGlobalRef(device); + } + midi_devices.clear(); +} + +void context::open_device(jobject device_info, void* target, bool is_output) +{ + auto env = get_thread_env(); + if (!env) + return; + + auto ctx = get_context(env); + if (!ctx) + return; + + auto midi_mgr = get_midi_manager(env, ctx); + if (!midi_mgr) + return; + + // Check if we're on the main thread + auto looper_class = env->FindClass("android/os/Looper"); + auto get_my_looper_method + = env->GetStaticMethodID(looper_class, "myLooper", "()Landroid/os/Looper;"); + auto my_looper = env->CallStaticObjectMethod(looper_class, get_my_looper_method); + + jobject handler = nullptr; + if (!my_looper) + { + LOGI("Not on a Looper thread, using main looper"); + auto get_main_looper_method + = env->GetStaticMethodID(looper_class, "getMainLooper", "()Landroid/os/Looper;"); + auto main_looper = env->CallStaticObjectMethod(looper_class, get_main_looper_method); + + auto handler_class = env->FindClass("android/os/Handler"); + auto handler_ctor = env->GetMethodID(handler_class, "", "(Landroid/os/Looper;)V"); + handler = env->NewObject(handler_class, handler_ctor, main_looper); + + env->DeleteLocalRef(main_looper); + } + + // Create the callback listener + auto callback_class = env->FindClass("dev/celtera/libremidi/MidiDeviceCallback"); + if (!callback_class) + { + LOGE("MidiDeviceCallback class not found - ensure the Java class is loaded"); + if (handler) + env->DeleteLocalRef(handler); + return; + } + + auto callback_ctor = env->GetMethodID(callback_class, "", "(JZ)V"); + auto callback + = env->NewObject(callback_class, callback_ctor, (jlong)target, (jboolean)is_output); + + // Open the device + auto midi_mgr_class = env->FindClass("android/media/midi/MidiManager"); + auto open_device_method = env->GetMethodID( + midi_mgr_class, "openDevice", + "(Landroid/media/midi/MidiDeviceInfo;Landroid/media/midi/" + "MidiManager$OnDeviceOpenedListener;Landroid/os/Handler;)V"); + + env->CallVoidMethod(midi_mgr, open_device_method, device_info, callback, handler); + + env->DeleteLocalRef(callback); + if (handler) + env->DeleteLocalRef(handler); +} + +extern "C" JNIEXPORT void JNICALL Java_dev_celtera_libremidi_MidiDeviceCallback_onDeviceOpened( + JNIEnv* env, jobject /*thiz*/, jobject midi_device, jlong target_ptr, jboolean is_output) +{ + if (!midi_device || target_ptr == 0) + { + LOGE("Invalid device or target pointer in callback"); + return; + } + + AMidiDevice* amidi_device = nullptr; + AMidiDevice_fromJava(env, midi_device, &amidi_device); + + if (!amidi_device) + { + LOGE("Failed to convert Java MIDI device to AMidiDevice"); + return; + } + + if (is_output) + { + midi_out::open_callback(reinterpret_cast(target_ptr), amidi_device); + } + else + { + midi_in::open_callback(reinterpret_cast(target_ptr), amidi_device); + } +} + +} diff --git a/include/libremidi/backends/android/helpers.hpp b/include/libremidi/backends/android/helpers.hpp new file mode 100644 index 00000000..881639be --- /dev/null +++ b/include/libremidi/backends/android/helpers.hpp @@ -0,0 +1,40 @@ +#pragma once +#include + +#include +#include + +#include +#include + +#include +#include + +#define LOG_TAG "libremidi" +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) + +namespace libremidi +{ +namespace android +{ +struct context +{ + static inline std::string client_name; + static inline std::vector midi_devices; + + static JNIEnv* get_thread_env(); + static jobject get_context(JNIEnv* env); + static jobject get_midi_manager(JNIEnv* env, jobject context); + static void refresh_midi_devices(JNIEnv* env, jobject context, bool is_output); + static void open_device(jobject device_info, void* target, bool is_output); + static std::string port_name(JNIEnv* env, unsigned int port_number); + static void cleanup_devices(JNIEnv* env); +}; + +extern "C" JNIEXPORT void JNICALL Java_dev_celtera_libremidi_MidiDeviceCallback_onDeviceOpened( + JNIEnv* env, jobject /*thiz*/, jobject midi_device, jlong target_ptr, jboolean is_output); +} +} diff --git a/include/libremidi/backends/android/midi_in.hpp b/include/libremidi/backends/android/midi_in.hpp new file mode 100644 index 00000000..a633e9f3 --- /dev/null +++ b/include/libremidi/backends/android/midi_in.hpp @@ -0,0 +1,177 @@ +#pragma once +#include +#include +#include +#include + +#include +#include +#include + +namespace libremidi +{ +namespace android +{ +class midi_in + : public midi1::in_api + , public error_handler +{ +public: + explicit midi_in( + libremidi::input_configuration&& conf, libremidi::android::input_configuration aconf) + : configuration{std::move(conf)} + { + if (!context::client_name.empty() && context::client_name != aconf.client_name) + { + libremidi_handle_error( + configuration, "Android backend only supports one client name per process"); + return; + } + context::client_name = std::string(aconf.client_name); + client_open_ = stdx::error{}; + } + + ~midi_in() override { close_port(); } + + libremidi::API get_current_api() const noexcept override + { + return libremidi::API::ANDROID_AMIDI; + } + + stdx::error open_port(const input_port& port, std::string_view /*name*/) override + { + if (is_port_open()) + { + libremidi_handle_error(configuration, "midi_in::open_port: a port is already open"); + return std::errc{}; + } + + const unsigned int port_number = port.port; + auto env = context::get_thread_env(); + if (!env) + { + libremidi_handle_error(configuration, "midi_in::open_port: failed to get JNI environment"); + return std::errc{}; + } + + auto ctx = context::get_context(env); + if (!ctx) + { + libremidi_handle_error(configuration, "midi_in::open_port: failed to get Android context"); + return std::errc{}; + } + + context::refresh_midi_devices(env, ctx, false); + + if (port_number >= context::midi_devices.size()) + { + libremidi_handle_error(configuration, "midi_in::open_port: invalid port number"); + return std::errc{}; + } + + port_open = true; + running = true; + context::open_device(context::midi_devices[port_number], this, false); + return stdx::error{}; + } + + stdx::error close_port() override + { + if (!is_port_open()) + return std::errc{}; + + running = false; + + if (poll_thread.joinable()) + { + poll_thread.join(); + } + + if (midi_output_port) + { + AMidiOutputPort_close(midi_output_port); + midi_output_port = nullptr; + } + + if (receive_device) + { + AMidiDevice_release(receive_device); + receive_device = nullptr; + } + + port_open = false; + return stdx::error{}; + } + + stdx::error set_client_name(std::string_view name) override + { + if (!context::client_name.empty() && context::client_name != name) + { + libremidi_handle_error( + configuration, "Android backend only supports one client name per process"); + return std::errc{}; + } + context::client_name = std::string(name); + return stdx::error{}; + } + + bool is_port_open() const noexcept { return port_open; } + + void start_midi_thread() { poll_thread = std::thread(&midi_in::poll_midi, this); } + + timestamp absolute_timestamp() const noexcept override { return system_ns(); } + void poll_midi() + { + static constexpr timestamp_backend_info timestamp_info{ + .has_absolute_timestamps = false, + .absolute_is_monotonic = false, + .has_samples = false, + }; + + uint8_t buffer[1024]; + int32_t op_code; + int64_t timestamp; + size_t num_bytes; + + while (running) + { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + + ssize_t num_messages = AMidiOutputPort_receive( + midi_output_port, &op_code, buffer, sizeof(buffer), &num_bytes, ×tamp); + + if (num_messages < 0) + { + LOGE("Error receiving MIDI data: %d", (int)num_messages); + continue; + } + + if (num_messages > 0 && num_bytes > 0) + { + const auto to_ns = [=] { return timestamp; }; + + m_processing.on_bytes_multi( + {buffer, buffer + num_bytes}, + m_processing.timestamp(to_ns, timestamp)); + } + } + } + + static void open_callback(midi_in* self, AMidiDevice* device) + { + self->receive_device = device; + AMidiOutputPort_open(device, 0, &self->midi_output_port); + self->start_midi_thread(); + } + +private: + libremidi::input_configuration configuration; + AMidiDevice* receive_device = nullptr; + AMidiOutputPort* midi_output_port = nullptr; + std::thread poll_thread; + std::atomic port_open{false}; + std::atomic running{false}; + midi1::input_state_machine m_processing{this->configuration}; +}; +} +} diff --git a/include/libremidi/backends/android/midi_out.hpp b/include/libremidi/backends/android/midi_out.hpp new file mode 100644 index 00000000..5f703657 --- /dev/null +++ b/include/libremidi/backends/android/midi_out.hpp @@ -0,0 +1,138 @@ +#pragma once +#include +#include +#include + +namespace libremidi +{ +namespace android +{ +class midi_out final + : public midi1::out_api + , public error_handler +{ +public: + explicit midi_out( + libremidi::output_configuration&& conf, libremidi::android::output_configuration aconf) + : configuration{std::move(conf)} + { + if (!context::client_name.empty() && context::client_name != aconf.client_name) + { + libremidi_handle_error( + configuration, "Android backend only supports one client name per process"); + return; + } + context::client_name = std::string(aconf.client_name); + client_open_ = stdx::error{}; + } + + ~midi_out() override { close_port(); } + + libremidi::API get_current_api() const noexcept override + { + return libremidi::API::ANDROID_AMIDI; + } + + stdx::error open_port(const output_port& port, std::string_view /*name*/) override + { + if (is_port_open()) + { + libremidi_handle_error(configuration, "midi_out::open_port: a port is already open"); + return std::errc{}; + } + + const unsigned int port_number = port.port; + + auto env = context::get_thread_env(); + if (!env) + { + libremidi_handle_error(configuration, "midi_out::open_port: failed to get JNI environment"); + return std::errc{}; + } + + auto ctx = context::get_context(env); + if (!ctx) + { + libremidi_handle_error(configuration, "midi_out::open_port: failed to get Android context"); + return std::errc{}; + } + + context::refresh_midi_devices(env, ctx, true); + + if (port_number >= context::midi_devices.size()) + { + libremidi_handle_error(configuration, "midi_out::open_port: invalid port number"); + return std::errc{}; + } + + port_open = true; + context::open_device(context::midi_devices[port_number], this, true); + return stdx::error{}; + } + + stdx::error close_port() override + { + if (!is_port_open()) + return std::errc{}; + + if (midi_input_port) + { + AMidiInputPort_close(midi_input_port); + midi_input_port = nullptr; + } + + if (send_device) + { + AMidiDevice_release(send_device); + send_device = nullptr; + } + + port_open = false; + return stdx::error{}; + } + + stdx::error set_client_name(std::string_view name) override + { + if (!context::client_name.empty() && context::client_name != name) + { + libremidi_handle_error( + configuration, "Android backend only supports one client name per process"); + return std::errc{}; + } + context::client_name = std::string(name); + return stdx::error{}; + } + + bool is_port_open() const noexcept { return port_open; } + + stdx::error send_message(const unsigned char* message, size_t size) override + { + if (!is_port_open() || !midi_input_port) + { + libremidi_handle_error(configuration, "midi_out::send_message: port is not open"); + return std::errc{}; + } + + ssize_t result = AMidiInputPort_send(midi_input_port, message, size); + if (result < 0) + { + libremidi_handle_error(configuration, "midi_out::send_message: failed to send MIDI data"); + return std::errc{}; + } + return stdx::error{}; + } + + static void open_callback(midi_out* self, AMidiDevice* device) + { + self->send_device = device; + AMidiInputPort_open(device, 0, &self->midi_input_port); + } + +private: + libremidi::output_configuration configuration; + AMidiDevice* send_device = nullptr; + AMidiInputPort* midi_input_port = nullptr; + bool port_open = false; +}; +} +} diff --git a/include/libremidi/backends/android/observer.hpp b/include/libremidi/backends/android/observer.hpp new file mode 100644 index 00000000..1c9b84ce --- /dev/null +++ b/include/libremidi/backends/android/observer.hpp @@ -0,0 +1,92 @@ +#pragma once +#include +#include +#include + +namespace libremidi +{ +namespace android +{ +class observer final + : public libremidi::observer_api + , public error_handler +{ +public: + explicit observer( + libremidi::observer_configuration&& conf, libremidi::android::observer_configuration aconf) + : configuration{std::move(conf)} + { + if (!context::client_name.empty() && context::client_name != aconf.client_name) + { + LOGW("Android backend only supports one client name per process"); + } + context::client_name = std::string(aconf.client_name); + } + + ~observer() + { + auto env = context::get_thread_env(); + if (env) + { + context::cleanup_devices(env); + } + } + + libremidi::API get_current_api() const noexcept override + { + return libremidi::API::ANDROID_AMIDI; + } + + std::vector get_input_ports() const noexcept override + { + auto env = context::get_thread_env(); + if (!env) + return {}; + + auto ctx = context::get_context(env); + if (!ctx) + return {}; + + context::refresh_midi_devices(env, ctx, false); + + std::vector ports; + for (size_t i = 0; i < context::midi_devices.size(); ++i) + { + libremidi::input_port port; + port.port_name = context::port_name(env, i); + port.port = i; + ports.push_back(std::move(port)); + } + + return ports; + } + + std::vector get_output_ports() const noexcept override + { + auto env = context::get_thread_env(); + if (!env) + return {}; + + auto ctx = context::get_context(env); + if (!ctx) + return {}; + + context::refresh_midi_devices(env, ctx, true); + + std::vector ports; + for (size_t i = 0; i < context::midi_devices.size(); ++i) + { + libremidi::output_port port; + port.port_name = context::port_name(env, i); + port.port = i; + ports.push_back(std::move(port)); + } + + return ports; + } + +private: + libremidi::observer_configuration configuration; +}; +} +} diff --git a/include/libremidi/configurations.hpp b/include/libremidi/configurations.hpp index 6f04b7b2..6ad7c9e0 100644 --- a/include/libremidi/configurations.hpp +++ b/include/libremidi/configurations.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -37,7 +38,8 @@ using input_api_configuration = std::variant< kbd_input_configuration, libremidi::net::dgram_input_configuration, libremidi::net_ump::dgram_input_configuration, pipewire_input_configuration, winmidi::input_configuration, winmm_input_configuration, winuwp_input_configuration, - jack_ump::input_configuration, pipewire_ump::input_configuration, libremidi::API>; + jack_ump::input_configuration, pipewire_ump::input_configuration, android::input_configuration, + libremidi::API>; using output_api_configuration = std::variant< unspecified_configuration, dummy_configuration, alsa_raw_output_configuration, @@ -47,7 +49,7 @@ using output_api_configuration = std::variant< libremidi::net::dgram_output_configuration, libremidi::net_ump::dgram_output_configuration, pipewire_output_configuration, winmidi::output_configuration, winmm_output_configuration, winuwp_output_configuration, jack_ump::output_configuration, - pipewire_ump::output_configuration, libremidi::API>; + pipewire_ump::output_configuration, android::output_configuration, libremidi::API>; using observer_api_configuration = std::variant< unspecified_configuration, dummy_configuration, alsa_raw_observer_configuration, @@ -57,7 +59,8 @@ using observer_api_configuration = std::variant< jack_observer_configuration, libremidi::net::dgram_observer_configuration, libremidi::net_ump::dgram_observer_configuration, pipewire_observer_configuration, winmidi::observer_configuration, winmm_observer_configuration, winuwp_observer_configuration, - jack_ump::observer_configuration, pipewire_ump::observer_configuration, libremidi::API>; + jack_ump::observer_configuration, pipewire_ump::observer_configuration, + android::observer_configuration, libremidi::API>; LIBREMIDI_EXPORT libremidi::API midi_api(const input_api_configuration& conf);