diff --git a/.github/actions/android-build/action.yml b/.github/actions/android-build/action.yml index 7050e5a9..fca751a6 100644 --- a/.github/actions/android-build/action.yml +++ b/.github/actions/android-build/action.yml @@ -80,7 +80,7 @@ runs: if [ ${TARGET_ABI} == 'arm64-v8a' ]; then bash ${{ github.workspace }}/java/build-nnstreamer-android.sh --target_abi=${TARGET_ABI} --enable_nnfw=yes --build_type=${BUILD_TYPE} --enable_mqtt=yes --enable_ml_offloading=yes --enable_ml_service=yes --enable_llamacpp=yes elif [ ${TARGET_ABI} == 'x86_64' ]; then - bash ${{ github.workspace }}/java/build-nnstreamer-android.sh --target_abi=${TARGET_ABI} --enable_nnfw=no --build_type=${BUILD_TYPE} + bash ${{ github.workspace }}/java/build-nnstreamer-android.sh --target_abi=${TARGET_ABI} --enable_nnfw=no --build_type=${BUILD_TYPE} --build_test=yes else bash ${{ github.workspace }}/java/build-nnstreamer-android.sh --target_abi=${TARGET_ABI} --enable_nnfw=no --enable_tflite=no --build_type=${BUILD_TYPE} fi diff --git a/.github/workflows/android-jni-test-ubuntu.yml b/.github/workflows/android-jni-test-ubuntu.yml new file mode 100644 index 00000000..db207f3f --- /dev/null +++ b/.github/workflows/android-jni-test-ubuntu.yml @@ -0,0 +1,45 @@ +name: Android JNI test on Ubuntu + +# Build the JNI wrapper of the Android API on Ubuntu and run the instrumented tests +# that do not require an Android device (java/test-nnstreamer-ubuntu.sh). + +on: + pull_request: + branches: [ main ] + paths: + - 'c/**' + - 'java/**' + - 'meson.build' + - 'meson_options.txt' + - '.github/workflows/android-jni-test-ubuntu.yml' + + # Allow manually triggering the workflow + workflow_dispatch: + +jobs: + test: + name: Android JNI host test + runs-on: ubuntu-22.04 + timeout-minutes: 60 + env: + JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Install dependencies + run: | + for i in 1 2 3; do sudo add-apt-repository -y ppa:nnstreamer/ppa && break || sleep 30; done + sudo apt-get update + sudo apt-get install -y meson ninja-build \ + libglib2.0-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libunwind-dev \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ + nnstreamer nnstreamer-dev nnstreamer-dev-internal nnstreamer-single-dev \ + openjdk-17-jdk-headless junit4 + - name: Build JNI wrapper + run: | + meson setup build -Djava-home=${JAVA_HOME} -Denable-test=false -Denable-ml-service=false + ninja -C build + - name: Run tests + run: | + bash java/test-nnstreamer-ubuntu.sh --ml_api_dir=${{ github.workspace }} --build_dir=build diff --git a/java/README.md b/java/README.md index e69de29b..e6a21a01 100644 --- a/java/README.md +++ b/java/README.md @@ -0,0 +1,20 @@ +# NNStreamer API for Java + +The Java API and its JNI wrapper for the Android API of NNStreamer. + +- `android/`: the Android library project. `android/nnstreamer/src/main/jni` is the + JNI wrapper, which is also built on Ubuntu by meson when `java-home` is given. +- `build-nnstreamer-android.sh`: build the Android library (AAR). Use + `--build_test=yes` to build the instrumented tests as well, and `--run_test=yes` + to run them on a connected device. +- `build-nnstreamer-ubuntu.sh`: build the Java API as a jar for Ubuntu. +- `test-nnstreamer-ubuntu.sh`: run the instrumented tests that do not require an + Android device on a desktop JVM, against the JNI wrapper built by meson. The + tests that cannot run on the host are listed in `host-test/exclude.txt`, and the + Android classes they use are stubbed in `host-test/stub`. + +``` +meson setup build -Djava-home=$JAVA_HOME -Denable-test=false -Denable-ml-service=false +ninja -C build +bash java/test-nnstreamer-ubuntu.sh --ml_api_dir=. --build_dir=build +``` diff --git a/java/build-nnstreamer-android.sh b/java/build-nnstreamer-android.sh index 7ba73d72..71e37582 100755 --- a/java/build-nnstreamer-android.sh +++ b/java/build-nnstreamer-android.sh @@ -30,6 +30,9 @@ ##@@ 'internal' : no plugins except for enable single-shot only, enable NNFW only ##@@ --target_abi=(armeabi-v7a|arm64-v8a) ##@@ 'arm64-v8a' is the default Android ABI +##@@ --build_test=(yes|no) +##@@ 'yes' : build instrumentation test after build procedure is done +##@@ 'no' : [default] ##@@ --run_test=(yes|no) ##@@ 'yes' : run instrumentation test after build procedure is done ##@@ 'no' : [default] @@ -128,6 +131,9 @@ include_assets="no" # Set target ABI ('armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64') target_abi="arm64-v8a" +# Build instrumentation test after build procedure is done +build_test="no" + # Run instrumentation test after build procedure is done run_test="no" @@ -212,6 +218,9 @@ for arg in "$@"; do --target_abi=*) target_abi=${arg#*=} ;; + --build_test=*) + build_test=${arg#*=} + ;; --run_test=*) run_test=${arg#*=} ;; @@ -794,6 +803,13 @@ chmod +x gradlew sh ./gradlew nnstreamer:build android_lib_build_res=$? +# Build instrumentation test if build procedure is done. +if [[ ${android_lib_build_res} -eq 0 && ${build_test} == "yes" ]]; then + echo "Build instrumentation test." + sh ./gradlew nnstreamer:assembleDebugAndroidTest + android_lib_build_res=$? +fi + # Run instrumentation test if build procedure is done. if [[ ${android_lib_build_res} -eq 0 ]]; then if [[ ${run_test} == "yes" ]]; then diff --git a/java/host-test/exclude.txt b/java/host-test/exclude.txt new file mode 100644 index 00000000..39ce9c56 --- /dev/null +++ b/java/host-test/exclude.txt @@ -0,0 +1,22 @@ +# Instrumented tests that cannot run on the host, one per line: Class#method or Class. +# An entry that does not match any test fails the run, remove it with the test. + +# Android application context is not checked on the host (@BUILD_ANDROID@). +org.nnsuite.nnstreamer.APITestCommon#testInitWithInvalidCtx_n + +# amcsrc is an Android element. +org.nnsuite.nnstreamer.APITestPipeline#testAvailableElement +org.nnsuite.nnstreamer.APITestPipeline#testAMCsrc + +# The video sink renders to an Android native window; glimagesink needs a display. +# Without one the pipeline does not start, so the negative cases below would pass +# on the failure of start() rather than on the call they are written for. +org.nnsuite.nnstreamer.APITestPipeline#testSetNullSurface +org.nnsuite.nnstreamer.APITestPipeline#testSetSurfaceNullName_n +org.nnsuite.nnstreamer.APITestPipeline#testSetSurfaceEmptyName_n +org.nnsuite.nnstreamer.APITestPipeline#testSetInvalidSurface_n + +# Test assets (image, model) are copied from the Android test package. +org.nnsuite.nnstreamer.APITestPipeline#testAppsrcPng +org.nnsuite.nnstreamer.APITestSingleShot#testOptionsUnknownFW_n +org.nnsuite.nnstreamer.APITestSingleShot#testOptionsInvalidFW_n diff --git a/java/host-test/src/org/nnsuite/nnstreamer/HostTestRunner.java b/java/host-test/src/org/nnsuite/nnstreamer/HostTestRunner.java new file mode 100644 index 00000000..30fa431d --- /dev/null +++ b/java/host-test/src/org/nnsuite/nnstreamer/HostTestRunner.java @@ -0,0 +1,111 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* + * NNStreamer Android API host test + * Copyright (C) 2026 Samsung Electronics Co., Ltd. + */ + +package org.nnsuite.nnstreamer; + +import org.junit.internal.TextListener; +import org.junit.runner.Description; +import org.junit.runner.JUnitCore; +import org.junit.runner.Request; +import org.junit.runner.Result; +import org.junit.runner.manipulation.Filter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Runs the instrumented test classes on a desktop JVM, skipping the tests listed in an exclude file. + * + * Usage: HostTestRunner (exclude file) (test class name)... + * + * Each line of the exclude file is a class name, or a class name and a method name joined + * with '#'. Text after '#' at the start of a line or after whitespace is a comment. + * An entry that does not match any test fails the run, so that the list stays accurate. + */ +public final class HostTestRunner { + private static Set readExcludeFile(String path) throws IOException { + Set entries = new HashSet<>(); + + for (String line : Files.readAllLines(Paths.get(path), StandardCharsets.UTF_8)) { + int comment = line.indexOf(" #"); + String entry = (comment >= 0 ? line.substring(0, comment) : line).trim(); + + if (!entry.isEmpty() && !entry.startsWith("#")) { + entries.add(entry); + } + } + + return entries; + } + + public static void main(String[] args) throws Exception { + if (args.length < 2) { + System.err.println("Usage: HostTestRunner ..."); + System.exit(2); + } + + final Set excluded = readExcludeFile(args[0]); + final Set matched = new HashSet<>(); + List> classes = new ArrayList<>(); + + for (int i = 1; i < args.length; i++) { + Class klass = Class.forName(args[i]); + + if (excluded.contains(klass.getName())) { + matched.add(klass.getName()); + System.out.println("Excluded class " + klass.getName()); + } else { + classes.add(klass); + } + } + + Filter filter = new Filter() { + @Override + public boolean shouldRun(Description description) { + if (!description.isTest()) { + return true; + } + + String name = description.getClassName() + "#" + description.getMethodName(); + + if (excluded.contains(name)) { + matched.add(name); + return false; + } + + return true; + } + + @Override + public String describe() { + return "exclude tests listed in " + args[0]; + } + }; + + JUnitCore core = new JUnitCore(); + core.addListener(new TextListener(System.out)); + + Result result = core.run(Request.classes(classes.toArray(new Class[0])).filterWith(filter)); + boolean success = result.wasSuccessful(); + + System.out.println("Excluded " + (matched.size()) + " entries."); + + for (String entry : excluded) { + if (!matched.contains(entry)) { + System.out.println("Exclude entry does not match any test: " + entry); + success = false; + } + } + + System.exit(success ? 0 : 1); + } +} diff --git a/java/host-test/stub/android/content/Context.java b/java/host-test/stub/android/content/Context.java new file mode 100644 index 00000000..68ceeb9a --- /dev/null +++ b/java/host-test/stub/android/content/Context.java @@ -0,0 +1,43 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* + * NNStreamer Android API host test + * Copyright (C) 2026 Samsung Electronics Co., Ltd. + */ + +package android.content; + +import android.content.res.AssetManager; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +/** + * Host stub of android.content.Context for running the instrumented tests on a desktop JVM. + * The files directory is a temporary directory, and the assets are empty. + */ +public class Context { + private final File mFilesDir; + private final AssetManager mAssets = new AssetManager(); + + public Context() { + try { + mFilesDir = Files.createTempDirectory("nns-host-test").toFile(); + mFilesDir.deleteOnExit(); + } catch (IOException e) { + throw new IllegalStateException("Failed to create the files directory", e); + } + } + + public AssetManager getAssets() { + return mAssets; + } + + public File getFilesDir() { + return mFilesDir; + } + + public String getPackageName() { + return "org.nnsuite.nnstreamer.test"; + } +} diff --git a/java/host-test/stub/android/content/res/AssetManager.java b/java/host-test/stub/android/content/res/AssetManager.java new file mode 100644 index 00000000..6e668ab3 --- /dev/null +++ b/java/host-test/stub/android/content/res/AssetManager.java @@ -0,0 +1,24 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* + * NNStreamer Android API host test + * Copyright (C) 2026 Samsung Electronics Co., Ltd. + */ + +package android.content.res; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +/** + * Host stub of android.content.res.AssetManager. The host test has no assets. + */ +public final class AssetManager { + public String[] list(String path) throws IOException { + return null; + } + + public InputStream open(String fileName) throws IOException { + throw new FileNotFoundException(fileName); + } +} diff --git a/java/host-test/stub/android/os/Build.java b/java/host-test/stub/android/os/Build.java new file mode 100644 index 00000000..b09b2cac --- /dev/null +++ b/java/host-test/stub/android/os/Build.java @@ -0,0 +1,19 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* + * NNStreamer Android API host test + * Copyright (C) 2026 Samsung Electronics Co., Ltd. + */ + +package android.os; + +/** + * Host stub of android.os.Build. + */ +public final class Build { + public static final String HARDWARE = "host"; + public static final String MANUFACTURER = "host"; + + public static final class VERSION { + public static final int SDK_INT = 0; + } +} diff --git a/java/host-test/stub/android/support/test/InstrumentationRegistry.java b/java/host-test/stub/android/support/test/InstrumentationRegistry.java new file mode 100644 index 00000000..78c71c52 --- /dev/null +++ b/java/host-test/stub/android/support/test/InstrumentationRegistry.java @@ -0,0 +1,24 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* + * NNStreamer Android API host test + * Copyright (C) 2026 Samsung Electronics Co., Ltd. + */ + +package android.support.test; + +import android.content.Context; + +/** + * Host stub of android.support.test.InstrumentationRegistry. + */ +public final class InstrumentationRegistry { + private static Context sContext; + + public static synchronized Context getTargetContext() { + if (sContext == null) { + sContext = new Context(); + } + + return sContext; + } +} diff --git a/java/host-test/stub/android/support/test/runner/AndroidJUnit4.java b/java/host-test/stub/android/support/test/runner/AndroidJUnit4.java new file mode 100644 index 00000000..de2feea1 --- /dev/null +++ b/java/host-test/stub/android/support/test/runner/AndroidJUnit4.java @@ -0,0 +1,19 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* + * NNStreamer Android API host test + * Copyright (C) 2026 Samsung Electronics Co., Ltd. + */ + +package android.support.test.runner; + +import org.junit.runners.BlockJUnit4ClassRunner; +import org.junit.runners.model.InitializationError; + +/** + * Host stub of android.support.test.runner.AndroidJUnit4, the plain JUnit 4 runner. + */ +public final class AndroidJUnit4 extends BlockJUnit4ClassRunner { + public AndroidJUnit4(Class klass) throws InitializationError { + super(klass); + } +} diff --git a/java/host-test/stub/android/view/Surface.java b/java/host-test/stub/android/view/Surface.java new file mode 100644 index 00000000..49829f81 --- /dev/null +++ b/java/host-test/stub/android/view/Surface.java @@ -0,0 +1,16 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* + * NNStreamer Android API host test + * Copyright (C) 2026 Samsung Electronics Co., Ltd. + */ + +package android.view; + +/** + * Host stub of android.view.Surface. A host surface is never valid. + */ +public final class Surface { + public boolean isValid() { + return false; + } +} diff --git a/java/host-test/stub/android/view/SurfaceHolder.java b/java/host-test/stub/android/view/SurfaceHolder.java new file mode 100644 index 00000000..99644ea7 --- /dev/null +++ b/java/host-test/stub/android/view/SurfaceHolder.java @@ -0,0 +1,14 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* + * NNStreamer Android API host test + * Copyright (C) 2026 Samsung Electronics Co., Ltd. + */ + +package android.view; + +/** + * Host stub of android.view.SurfaceHolder. + */ +public interface SurfaceHolder { + Surface getSurface(); +} diff --git a/java/host-test/stub/android/view/SurfaceView.java b/java/host-test/stub/android/view/SurfaceView.java new file mode 100644 index 00000000..297953ae --- /dev/null +++ b/java/host-test/stub/android/view/SurfaceView.java @@ -0,0 +1,28 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* + * NNStreamer Android API host test + * Copyright (C) 2026 Samsung Electronics Co., Ltd. + */ + +package android.view; + +import android.content.Context; + +/** + * Host stub of android.view.SurfaceView. + */ +public class SurfaceView { + private final Surface mSurface = new Surface(); + + public SurfaceView(Context context) { + } + + public SurfaceHolder getHolder() { + return new SurfaceHolder() { + @Override + public Surface getSurface() { + return mSurface; + } + }; + } +} diff --git a/java/test-nnstreamer-ubuntu.sh b/java/test-nnstreamer-ubuntu.sh new file mode 100755 index 00000000..f959ca1d --- /dev/null +++ b/java/test-nnstreamer-ubuntu.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash + +## +## SPDX-License-Identifier: Apache-2.0 +## +# @file test-nnstreamer-ubuntu.sh +# @brief A script to run the Android instrumented tests of NNStreamer API on Ubuntu +# +# The JNI wrapper is the same code as Android. This script builds the Java API with +# the host stubs in java/host-test, and runs the instrumented tests on a desktop JVM +# against the JNI wrapper built by meson (-Djava-home). +# +# The following comments that start with '##@@' are for the generation of usage messages. +##@@ Test script for NNStreamer API Library on Ubuntu +##@@ - Before running this script, below variables must be set. +##@@ - JAVA_HOME: Path to JDK +##@@ - ML_API_ROOT: The source root directory of ML API +##@@ +##@@ usage: test-nnstreamer-ubuntu.sh [OPTIONS] +##@@ +##@@ basic options: +##@@ --help +##@@ display this help and exit +##@@ --java_home=(path to JDK) +##@@ This option overrides the JAVA_HOME variable +##@@ --ml_api_dir=(the_source_root_of_ml_api) +##@@ This option overrides the ML_API_ROOT variable +##@@ --build_dir=(meson build directory) +##@@ The meson build directory configured with -Djava-home +##@@ Default path is 'ml_api_dir/build' +##@@ --junit_classpath=(class path of JUnit 4 and Hamcrest) +##@@ Default is '/usr/share/java/junit4.jar:/usr/share/java/hamcrest-core.jar' +##@@ +##@@ For example, to run the tests with the default meson build directory +##@@ ./test-nnstreamer-ubuntu.sh --java_home=/usr/lib/jvm/java-17-openjdk-amd64 + +# Find '--help' in the given arguments +arg_help="--help" +for arg in "$@"; do + if [[ ${arg} == "${arg_help}" ]]; then + sed -ne 's/^##@@ \(.*\)/\1/p' "$0" && exit 1 + fi +done + +junit_classpath="/usr/share/java/junit4.jar:/usr/share/java/hamcrest-core.jar" + +# Parse args +for arg in "$@"; do + case ${arg} in + --java_home=*) + java_home=${arg#*=} + ;; + --ml_api_dir=*) + ml_api_dir=${arg#*=} + ;; + --build_dir=*) + build_dir=${arg#*=} + ;; + --junit_classpath=*) + junit_classpath=${arg#*=} + ;; + esac +done + +# Java home +if [[ -z "${java_home}" ]]; then + [ -z "${JAVA_HOME}" ] && echo "Need to set JAVA_HOME." && exit 1 + java_home=${JAVA_HOME} +fi + +echo "Java home: ${java_home}" + +# ML API root directory +if [[ -z "${ml_api_dir}" ]]; then + [ -z "${ML_API_ROOT}" ] && echo "Need to set ML_API_ROOT." && exit 1 + ml_api_dir=${ML_API_ROOT} +fi + +ml_api_dir=$(realpath "${ml_api_dir}") +echo "ML API root directory: ${ml_api_dir}" + +# Meson build directory +if [[ -z "${build_dir}" ]]; then + build_dir=${ml_api_dir}/build +fi + +build_dir=$(realpath "${build_dir}") +native_lib_dir=${build_dir}/java + +if [[ ! -f ${native_lib_dir}/libnnstreamer-native.so ]]; then + echo "Cannot find ${native_lib_dir}/libnnstreamer-native.so, configure meson with -Djava-home and build first." + exit 1 +fi + +java_src_dir=${ml_api_dir}/java/android/nnstreamer/src +host_test_dir=${ml_api_dir}/java/host-test + +work_dir=$(mktemp -d) +trap 'rm -rf "${work_dir}"' EXIT + +src_dir=${work_dir}/src/org/nnsuite/nnstreamer +class_dir=${work_dir}/classes +mkdir -p "${src_dir}" "${class_dir}" + +# Java API, same as build-nnstreamer-ubuntu.sh +cp "${java_src_dir}"/main/java/org/nnsuite/nnstreamer/*.java "${src_dir}" +sed -i "s|android.content.Context|Object|" "${src_dir}"/*.java +sed -i "s|android.view.Surface|Object|" "${src_dir}"/*.java +sed -i "s|@BUILD_ANDROID@|//|" "${src_dir}"/*.java + +# The host build does not include ml-service. +rm -f "${src_dir}/MLService.java" + +# Instrumented tests and host runner +test_files=() +for file in "${java_src_dir}"/androidTest/java/org/nnsuite/nnstreamer/APITest*.java; do + name=$(basename "${file}" .java) + [[ ${name} == "APITestMLService" ]] && continue + cp "${file}" "${src_dir}" + test_files+=("org.nnsuite.nnstreamer.${name}") +done + +cp "${host_test_dir}"/src/org/nnsuite/nnstreamer/*.java "${src_dir}" + +echo "Compiling the Java API and the tests." +find "${host_test_dir}/stub" "${work_dir}/src" -name "*.java" > "${work_dir}/sources.txt" + +if ! "${java_home}/bin/javac" -encoding UTF-8 -nowarn -d "${class_dir}" \ + -cp "${junit_classpath}" "@${work_dir}/sources.txt"; then + echo "Failed to compile the tests." + exit 1 +fi + +echo "Running the tests." +export LD_LIBRARY_PATH=${build_dir}/c/src${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}} + +"${java_home}/bin/java" \ + -XX:ErrorFile="${work_dir}/hs_err_pid%p.log" \ + -Djava.library.path="${native_lib_dir}" \ + -cp "${class_dir}:${junit_classpath}" \ + org.nnsuite.nnstreamer.HostTestRunner "${host_test_dir}/exclude.txt" "${test_files[@]}" +test_res=$? + +# Show the native stack if the JVM has crashed. +for file in "${work_dir}"/hs_err_pid*.log; do + [[ -f ${file} ]] && sed -n '1,/^Java frames/p' "${file}" +done + +# exit with success/failure status +exit ${test_res}