From 2ba9db14cc8b59898c9dcf8065455d46867d2d98 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Thu, 5 Nov 2026 00:02:42 -0600 Subject: [PATCH] Prevent Android JNI exhaustion and restore SQLite selection Keep local JNI references scoped to every send, including failures, and release task globals before terminal callbacks so retained native requests do not keep completed futures alive. Honor explicit AAR storage options and translate the legacy USE_ROOM flag. Add ART/CheckJNI GC regressions and AAR storage configuration checks to prevent recurrence. Files changed: .github/workflows/build-android.yml cmake/MatsdkOptions.cmake lib/android_build/maesdk/src/main/cpp/CMakeLists.txt lib/http/HttpClient_Android.cpp lib/http/HttpClient_Android.hpp tests/android-http-client/CMakeLists.txt tests/android-http-client/HttpClient.java tests/android-http-client/android-http-client-tests.cpp tests/android-http-client/run-tests.py tests/android-storage/CMakeLists.txt tests/android-storage/options-tests.cmake Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 01fc96f0-9072-4225-903a-5402ae46b7ae --- .github/workflows/build-android.yml | 35 +++- cmake/MatsdkOptions.cmake | 9 + .../maesdk/src/main/cpp/CMakeLists.txt | 4 +- lib/http/HttpClient_Android.cpp | 114 +++++++++--- lib/http/HttpClient_Android.hpp | 5 +- tests/android-http-client/CMakeLists.txt | 25 +++ tests/android-http-client/HttpClient.java | 112 ++++++++++++ .../android-http-client-tests.cpp | 162 ++++++++++++++++++ tests/android-http-client/run-tests.py | 61 +++++++ tests/android-storage/CMakeLists.txt | 30 ++++ tests/android-storage/options-tests.cmake | 32 ++++ 11 files changed, 561 insertions(+), 28 deletions(-) create mode 100644 tests/android-http-client/CMakeLists.txt create mode 100644 tests/android-http-client/HttpClient.java create mode 100644 tests/android-http-client/android-http-client-tests.cpp create mode 100644 tests/android-http-client/run-tests.py create mode 100644 tests/android-storage/CMakeLists.txt create mode 100644 tests/android-storage/options-tests.cmake diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 3bc84c4b1..c925e8b46 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -31,6 +31,36 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + jni-lifetimes: + name: Android HTTP JNI lifetime regressions + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Setup Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: 'temurin' + java-version: '17' + - name: Build production client and regression harness + run: >- + cmake -S tests/android-http-client -B out-http-jni -G Ninja + -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake" + -DANDROID_ABI=x86_64 -DANDROID_PLATFORM=android-30 + -DANDROID_STL=c++_static -DCMAKE_BUILD_TYPE=Debug + - name: Compile JNI tests + run: cmake --build out-http-jni --target android-http-client-tests --parallel 2 + - name: Enable KVM + run: sudo chmod a+rw /dev/kvm + - name: Run ART reference lifetime tests with CheckJNI + uses: reactivecircus/android-emulator-runner@324029e2f414c084d8b15ba075288885e74aef9c # v2.34.0 + with: + api-level: 30 + arch: x86_64 + target: google_apis + disable-animations: true + script: python3 tests/android-http-client/run-tests.py --sdk "$ANDROID_HOME" --library out-http-jni/libandroid-http-client-tests.so + no-exceptions: name: Android without C++ exceptions (${{ matrix.abi }}, Room=${{ matrix.room }}) runs-on: ubuntu-latest @@ -44,7 +74,8 @@ jobs: uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Configure full JNI SDK run: >- - cmake -S . -B out-no-exceptions -G Ninja + cmake -S tests/android-storage -B out-no-exceptions -G Ninja + -DEXPECT_ROOM=${{ matrix.room }} -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake" -DANDROID_ABI=${{ matrix.abi }} -DANDROID_PLATFORM=android-24 @@ -59,6 +90,8 @@ jobs: -DMATSDK_BUILD_PACKAGE=OFF - name: Compile full SDK run: cmake --build out-no-exceptions --target mat --parallel 2 + - name: Verify storage option compatibility + run: cmake -P tests/android-storage/options-tests.cmake build: runs-on: windows-latest diff --git a/cmake/MatsdkOptions.cmake b/cmake/MatsdkOptions.cmake index 433f035b3..ac8a38c7e 100644 --- a/cmake/MatsdkOptions.cmake +++ b/cmake/MatsdkOptions.cmake @@ -18,6 +18,15 @@ option(MATSDK_BUILD_FUNC_TESTS "Build functional tests" "${MATSDK_PROJECT_IS_TOP_LEVEL}") option(MATSDK_BUILD_JNI_WRAPPER "Build the JNI wrapper" OFF) +if(DEFINED USE_ROOM) + if(NOT DEFINED MATSDK_ANDROID_USE_ROOM) + set(MATSDK_ANDROID_USE_ROOM "${USE_ROOM}" CACHE BOOL + "Use Android Room for offline storage") + endif() + message(DEPRECATION + "USE_ROOM is deprecated; use MATSDK_ANDROID_USE_ROOM instead. " + "MATSDK_ANDROID_USE_ROOM=${MATSDK_ANDROID_USE_ROOM} takes precedence.") +endif() option(MATSDK_ANDROID_USE_ROOM "Use Android Room for offline storage" OFF) option(MATSDK_ENABLE_CAPI_HTTP_CLIENT diff --git a/lib/android_build/maesdk/src/main/cpp/CMakeLists.txt b/lib/android_build/maesdk/src/main/cpp/CMakeLists.txt index 6712289dd..dd0042230 100644 --- a/lib/android_build/maesdk/src/main/cpp/CMakeLists.txt +++ b/lib/android_build/maesdk/src/main/cpp/CMakeLists.txt @@ -6,7 +6,9 @@ get_filename_component(SDK_ROOT set(BUILD_SHARED_LIBS ON CACHE BOOL "") set(MATSDK_BUILD_JNI_WRAPPER ON CACHE BOOL "" FORCE) -set(MATSDK_ANDROID_USE_ROOM ON CACHE BOOL "" FORCE) +if(NOT DEFINED MATSDK_ANDROID_USE_ROOM AND NOT DEFINED USE_ROOM) + set(MATSDK_ANDROID_USE_ROOM ON CACHE BOOL "Use Android Room for offline storage") +endif() set(MATSDK_BUILD_PACKAGE OFF CACHE BOOL "" FORCE) set(MATSDK_BUILD_UNIT_TESTS OFF CACHE BOOL "" FORCE) set(MATSDK_BUILD_FUNC_TESTS OFF CACHE BOOL "" FORCE) diff --git a/lib/http/HttpClient_Android.cpp b/lib/http/HttpClient_Android.cpp index 7b1af48bf..aef92c828 100644 --- a/lib/http/HttpClient_Android.cpp +++ b/lib/http/HttpClient_Android.cpp @@ -10,6 +10,35 @@ #include #include +namespace +{ + class LocalFrame + { + public: + explicit LocalFrame(JNIEnv* env) : + m_env(env), m_pushed(env->PushLocalFrame(16) == JNI_OK) + { + } + + ~LocalFrame() + { + if (m_pushed) + { + m_env->PopLocalFrame(nullptr); + } + } + + bool IsValid() const + { + return m_pushed; + } + + private: + JNIEnv* m_env; + bool m_pushed; + }; +} + namespace MAT_NS_BEGIN { HttpClient_Android::HttpRequest::~HttpRequest() noexcept @@ -22,7 +51,16 @@ namespace MAT_NS_BEGIN { return; } + ReleaseJavaRequest(env); + } + } + + void HttpClient_Android::HttpRequest::ReleaseJavaRequest(JNIEnv* env) + { + if (m_java_request) + { env->DeleteGlobalRef(m_java_request); + m_java_request = nullptr; } } @@ -88,7 +126,7 @@ namespace MAT_NS_BEGIN IHttpResponseCallback* callback) { JNIEnv* env = nullptr; - std::string const& target_id(request->GetId()); + std::string const target_id(request->GetId()); if (s_java_vm->AttachCurrentThread(&env, nullptr) != JNI_OK) { @@ -134,14 +172,22 @@ namespace MAT_NS_BEGIN total_size += u.first.length() + u.second.length(); } - static constexpr int FrameSize = 16; // at most 16 local refs in the frame - auto pushed = env->PushLocalFrame(FrameSize); - if (CheckException(env, r)) + LocalFrame frame(env); + if (CheckException(env, target_id)) { return; } + if (!frame.IsValid()) + { + auto failed = GetAndRemoveRequest(target_id); + if (failed) + { + CallbackForCancel(env, failed); + } + return; + } auto header_buffer = env->NewByteArray(static_cast(total_size)); - if (CheckException(env, r)) + if (CheckException(env, target_id)) { return; } @@ -156,7 +202,7 @@ namespace MAT_NS_BEGIN { env->SetByteArrayRegion(header_buffer, offset, key_len, reinterpret_cast(u.first.data())); - if (CheckException(env, r)) + if (CheckException(env, target_id)) { return; } @@ -168,7 +214,7 @@ namespace MAT_NS_BEGIN { env->SetByteArrayRegion(header_buffer, offset, value_len, reinterpret_cast(u.second.data())); - if (CheckException(env, r)) + if (CheckException(env, target_id)) { return; } @@ -176,39 +222,39 @@ namespace MAT_NS_BEGIN offset += value_len; } auto header_lengths = env->NewIntArray(static_cast(index_buffer.size())); - if (CheckException(env, r)) + if (CheckException(env, target_id)) { return; } env->SetIntArrayRegion(header_lengths, 0, index_buffer.size(), index_buffer.data()); - if (CheckException(env, r)) + if (CheckException(env, target_id)) { return; } auto body = env->NewByteArray(r->m_body.size()); - if (CheckException(env, r)) + if (CheckException(env, target_id)) { return; } env->SetByteArrayRegion(body, 0, r->m_body.size(), reinterpret_cast(r->m_body.data())); - if (CheckException(env, r)) + if (CheckException(env, target_id)) { return; } auto java_id = env->NewStringUTF(request->GetId().c_str()); - if (CheckException(env, r)) + if (CheckException(env, target_id)) { return; } auto url_id = env->NewStringUTF(r->m_url.c_str()); - if (CheckException(env, r)) + if (CheckException(env, target_id)) { return; } auto method_id = env->NewStringUTF(r->m_method.c_str()); - if (CheckException(env, r)) + if (CheckException(env, target_id)) { return; } @@ -231,10 +277,9 @@ namespace MAT_NS_BEGIN header_lengths, header_buffer); } - if (pushed == JNI_OK) + if (CheckException(env, target_id)) { - // pass the return value "result" through the local frame pop - result = env->PopLocalFrame(result); + return; } HttpRequest* cancel_me = nullptr; @@ -263,6 +308,10 @@ namespace MAT_NS_BEGIN } } } + if (CheckException(env, target_id)) + { + return; + } if (cancel_me) { CallbackForCancel(env, cancel_me); @@ -270,6 +319,7 @@ namespace MAT_NS_BEGIN else { env->CallVoidMethod(m_client, m_execute_id, result); + CheckException(env, target_id); } } @@ -278,8 +328,18 @@ namespace MAT_NS_BEGIN if (request->m_java_request && env) { auto request_class = env->GetObjectClass(request->m_java_request); - auto cancel_method = env->GetMethodID(request_class, "cancel", "(Z)Z"); - env->CallBooleanMethod(request->m_java_request, cancel_method, static_cast(JNI_TRUE)); + auto cancel_method = request_class ? env->GetMethodID(request_class, "cancel", "(Z)Z") : nullptr; + if (cancel_method) + { + env->CallBooleanMethod(request->m_java_request, cancel_method, static_cast(JNI_TRUE)); + } + env->DeleteLocalRef(request_class); + request->ReleaseJavaRequest(env); + if (env->ExceptionCheck()) + { + env->ExceptionDescribe(); + env->ExceptionClear(); + } } if (request->m_callback) { @@ -379,11 +439,12 @@ namespace MAT_NS_BEGIN env->DeleteGlobalRef(m_client); } m_client = env->NewGlobalRef(client_); - m_client_class = env->GetObjectClass(m_client); - m_create_id = env->GetMethodID(m_client_class, "createTask", + auto client_class = env->GetObjectClass(m_client); + m_create_id = env->GetMethodID(client_class, "createTask", "(Ljava/lang/String;Ljava/lang/String;[BLjava/lang/String;[I[B)Ljava/util/concurrent/FutureTask;"); - m_execute_id = env->GetMethodID(m_client_class, "executeTask", + m_execute_id = env->GetMethodID(client_class, "executeTask", "(Ljava/util/concurrent/FutureTask;)V"); + env->DeleteLocalRef(client_class); env->GetJavaVM(&s_java_vm); } @@ -485,7 +546,7 @@ namespace MAT_NS_BEGIN return s_client; } - bool HttpClient_Android::CheckException(JNIEnv* env, HttpRequest* request) + bool HttpClient_Android::CheckException(JNIEnv* env, std::string const& id) { if (env->ExceptionCheck() == JNI_FALSE) { @@ -493,7 +554,11 @@ namespace MAT_NS_BEGIN } env->ExceptionDescribe(); env->ExceptionClear(); - CallbackForCancel(env, request); + auto request = GetAndRemoveRequest(id); + if (request) + { + CallbackForCancel(env, request); + } return true; } @@ -559,6 +624,7 @@ extern "C" JNIEXPORT void { return; } + request->ReleaseJavaRequest(env); auto callback = request->GetCallback(); auto response = new Microsoft::Applications::Events::HttpClient_Android::HttpResponse(request->GetId()); response->SetResponse(statusCode); diff --git a/lib/http/HttpClient_Android.hpp b/lib/http/HttpClient_Android.hpp index 65d4af7a5..d3779bc5a 100644 --- a/lib/http/HttpClient_Android.hpp +++ b/lib/http/HttpClient_Android.hpp @@ -159,6 +159,8 @@ namespace MAT_NS_BEGIN m_parent.EraseRequest(this); } + void ReleaseJavaRequest(JNIEnv* env); + protected: HttpClient_Android& m_parent; HttpHeaders m_headers; @@ -199,7 +201,6 @@ namespace MAT_NS_BEGIN std::vector m_requests; jobject m_client = nullptr; - jclass m_client_class = nullptr; jmethodID m_send_id = nullptr; jmethodID m_create_id = nullptr; jmethodID m_execute_id = nullptr; @@ -209,7 +210,7 @@ namespace MAT_NS_BEGIN static std::shared_ptr s_client; static std::string s_cache_file_path; - bool CheckException(JNIEnv* env, HttpRequest* request); + bool CheckException(JNIEnv* env, std::string const& id); }; } diff --git a/tests/android-http-client/CMakeLists.txt b/tests/android-http-client/CMakeLists.txt new file mode 100644 index 000000000..c6d28dbb8 --- /dev/null +++ b/tests/android-http-client/CMakeLists.txt @@ -0,0 +1,25 @@ +cmake_minimum_required(VERSION 3.19...3.31) +project(AndroidHttpClientTests LANGUAGES C CXX) + +if(NOT ANDROID) + message(FATAL_ERROR "These JNI lifetime tests must run on Android ART.") +endif() + +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +set(MATSDK_BUILD_JNI_WRAPPER OFF CACHE BOOL "" FORCE) +set(MATSDK_BUILD_UNIT_TESTS OFF CACHE BOOL "" FORCE) +set(MATSDK_BUILD_FUNC_TESTS OFF CACHE BOOL "" FORCE) +set(MATSDK_BUILD_TEST_TOOL OFF CACHE BOOL "" FORCE) +set(MATSDK_BUILD_PACKAGE OFF CACHE BOOL "" FORCE) +add_subdirectory(../.. sdk) + +add_library(android-http-client-tests SHARED android-http-client-tests.cpp) +target_compile_features(android-http-client-tests PRIVATE cxx_std_14) +target_include_directories(android-http-client-tests PRIVATE + ../../lib + ../../lib/include + ../../lib/include/mat + ../../lib/pal + ../../lib/utils) +target_link_libraries(android-http-client-tests PRIVATE + mat matsdk_internal_config matsdk_build_options) diff --git a/tests/android-http-client/HttpClient.java b/tests/android-http-client/HttpClient.java new file mode 100644 index 000000000..e985143b7 --- /dev/null +++ b/tests/android-http-client/HttpClient.java @@ -0,0 +1,112 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +package com.microsoft.applications.events; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.FutureTask; + +// A deterministic Java peer for the production C++ client, without network or AndroidX dependencies. +public final class HttpClient { + private final ArrayList> tasks = new ArrayList<>(); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private int mode; + + private final class FutureShim extends FutureTask { + FutureShim(String id) { + super(() -> { + dispatchCallback(id, mode == 1 ? -1 : 200, new String[0], new byte[0]); + return true; + }); + } + + @Override + public boolean cancel(boolean interrupt) { + if (mode == 9) { + throw new IllegalStateException("Injected cancel failure"); + } + return super.cancel(interrupt); + } + } + + public FutureTask createTask( + String url, String method, byte[] body, String id, int[] lengths, byte[] headers) { + FutureShim task = new FutureShim(id); + tasks.add(new WeakReference<>(task)); + if (mode == 5) { + cancelPreparing(id); + } + if (mode == 6) { + throw new IllegalStateException("Injected create failure"); + } + return mode == 12 ? null : task; + } + + public void executeTask(FutureTask task) { + if (mode == 7) { + throw new IllegalStateException("Injected execute failure"); + } + if (mode == 0 || mode == 1 || mode == 8) { + task.run(); + } else if (mode == 10 || mode == 11) { + executor.execute(task); + } + } + + private native void dispatchCallback(String id, int status, String[] headers, byte[] body); + private native void cancelPreparing(String id); + private native void startBatch(int mode, int count); + private native boolean finishBatch(); + + public static void main(String[] args) throws Exception { + System.load(args[0]); + HttpClient client = new HttpClient(); + String[] cases = {"success", "network-failure", "cancel", "cancel-all", "early-cancel", + "preparing-cancel", "create-exception", "execute-exception", + "callback-deletes-request", "cancel-exception", "async-success", "cancel-completion-race", + "null-task"}; + String selected = args.length > 1 ? args[1] : "all"; + int stressCount = args.length > 2 ? Integer.parseInt(args[2]) : 60000; + boolean ran = false; + try { + for (int mode = 0; mode < cases.length; ++mode) { + if (!selected.equals("all") && !selected.equals(cases[mode])) { + continue; + } + ran = true; + client.mode = mode; + client.tasks.clear(); + client.startBatch(mode, mode == 0 ? stressCount : mode == 6 || mode == 7 || mode == 9 ? 4 : 1024); + int live = 0; + for (int attempt = 0; attempt < 20; ++attempt) { + System.gc(); + System.runFinalization(); + Thread.sleep(50); + live = 0; + for (WeakReference task : client.tasks) { + if (task.get() != null) { + ++live; + } + } + if (live == 0) { + break; + } + } + boolean callbacksPassed = client.finishBatch(); + if (!callbacksPassed || live != 0) { + throw new AssertionError(cases[mode] + ": callbacks=" + callbacksPassed + ", retained=" + live); + } + System.out.println("PASS " + cases[mode] + ": retained FutureShim objects=" + live); + } + } finally { + client.executor.shutdownNow(); + } + if (!ran) { + throw new IllegalArgumentException("Unknown test case: " + selected); + } + } +} diff --git a/tests/android-http-client/android-http-client-tests.cpp b/tests/android-http-client/android-http-client-tests.cpp new file mode 100644 index 000000000..6fcb99568 --- /dev/null +++ b/tests/android-http-client/android-http-client-tests.cpp @@ -0,0 +1,162 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +#include "http/HttpClient_Android.hpp" + +#include +#include +#include +#include +#include + +using namespace Microsoft::Applications::Events; + +namespace +{ + JavaVM* vm = nullptr; + std::thread worker; + std::mutex mutex; + std::condition_variable changed; + bool ready = false; + bool finish = false; + bool passed = false; + + class Callback : public IHttpResponseCallback + { + public: + void OnHttpResponse(IHttpResponse* response) override + { + std::unique_ptr owned(response); + result = response->GetResult(); + if (deleteRequest) + { + delete request; + request = nullptr; + } + ++calls; + } + + IHttpRequest* request = nullptr; + bool deleteRequest = false; + std::atomic calls{0}; + HttpResult result = HttpResult_LocalFailure; + }; + + void RunBatch(jint mode, jint count) + { + JNIEnv* attached = nullptr; + vm->AttachCurrentThread(&attached, nullptr); + auto nativeClient = HttpClient_Android::GetClientInstance(); + std::vector> callbacks; + for (int i = 0; i < count; ++i) + { + std::unique_ptr callback(new Callback); + callback->request = nativeClient->CreateRequest(); + callback->request->SetUrl("https://unused.invalid/"); + callback->request->SetMethod("POST"); + callback->request->GetHeaders().set("test", "value"); + std::vector body{1, 2, 3}; + callback->request->SetBody(body); + callback->deleteRequest = mode == 8; + if (mode == 4) + { + nativeClient->CancelRequestAsync(callback->request->GetId()); + } + nativeClient->SendRequestAsync(callback->request, callback.get()); + if (mode == 2 || mode == 9 || mode == 11) + { + nativeClient->CancelRequestAsync(callback->request->GetId()); + } + if (mode == 3) + { + callbacks.push_back(std::move(callback)); + } + else + { + if (mode == 10 || mode == 11) + { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (callback->calls == 0 && std::chrono::steady_clock::now() < deadline) + { + std::this_thread::yield(); + } + } + auto expected = HttpResult_Aborted; + if (mode == 0 || mode == 8 || mode == 10) + { + expected = HttpResult_OK; + } + else if (mode == 1) + { + expected = HttpResult_NetworkFailure; + } + passed = passed && callback->calls == 1 && + (callback->result == expected || (mode == 11 && callback->result == HttpResult_OK)); + callbacks.push_back(std::move(callback)); + } + passed = passed && !attached->ExceptionCheck(); + } + if (mode == 3) + { + nativeClient->CancelAllRequests(); + for (auto const& callback : callbacks) + { + passed = passed && callback->calls == 1 && callback->result == HttpResult_Aborted; + } + } + { + std::unique_lock lock(mutex); + ready = true; + changed.notify_all(); + while (!finish) + { + changed.wait(lock); + } + } + // Keep native requests AND the attached thread alive until Java checks weak references. + for (auto const& callback : callbacks) + { + delete callback->request; + } + nativeClient.reset(); + vm->DetachCurrentThread(); + } +} + +extern "C" JNIEXPORT void JNICALL +Java_com_microsoft_applications_events_HttpClient_cancelPreparing(JNIEnv* env, jobject, jstring id) +{ + const char* text = env->GetStringUTFChars(id, nullptr); + HttpClient_Android::GetClientInstance()->CancelRequestAsync(text); + env->ReleaseStringUTFChars(id, text); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_microsoft_applications_events_HttpClient_startBatch(JNIEnv* env, jobject client, jint mode, jint count) +{ + env->GetJavaVM(&vm); + HttpClient_Android::CreateClientInstance(env, client); + ready = false; + finish = false; + passed = true; + worker = std::thread(RunBatch, mode, count); + std::unique_lock lock(mutex); + while (!ready) + { + changed.wait(lock); + } +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_microsoft_applications_events_HttpClient_finishBatch(JNIEnv* env, jobject client) +{ + { + std::lock_guard lock(mutex); + finish = true; + changed.notify_all(); + } + worker.join(); + HttpClient_Android::DeleteClientInstance(env, client); + return passed ? JNI_TRUE : JNI_FALSE; +} diff --git a/tests/android-http-client/run-tests.py b/tests/android-http-client/run-tests.py new file mode 100644 index 000000000..85dcb4105 --- /dev/null +++ b/tests/android-http-client/run-tests.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# +# Copyright (c) Microsoft Corporation. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +"""Run the production Android HttpClient JNI lifetime tests on a booted emulator.""" + +import argparse +import os +from pathlib import Path +import subprocess +import tempfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sdk", required=True, type=Path) + parser.add_argument("--library", required=True, type=Path) + parser.add_argument("--java-home", type=Path, default=os.environ.get("JAVA_HOME")) + parser.add_argument("--case", default="all") + parser.add_argument("--stress-count", type=int, default=60000) + args = parser.parse_args() + suffix = ".exe" if os.name == "nt" else "" + adb = args.sdk / "platform-tools" / ("adb" + suffix) + javac = args.java_home / "bin" / ("javac" + suffix) if args.java_home else "javac" + build_tools = sorted( + (args.sdk / "build-tools").iterdir(), + key=lambda path: tuple(int(part) for part in path.name.split(".")), + )[-1] + d8 = build_tools / ("d8.bat" if os.name == "nt" else "d8") + + def run(*command, **kwargs): + environment = os.environ.copy() + if args.java_home: + environment["JAVA_HOME"] = str(args.java_home) + return subprocess.run( + [str(arg) for arg in command], check=True, timeout=180, env=environment, **kwargs) + + with tempfile.TemporaryDirectory(prefix="matsdk-jni-") as directory: + output = Path(directory) + run(javac, "--release", "8", "-d", output, + Path(__file__).with_name("HttpClient.java")) + libraries = ["--lib", args.java_home] if args.java_home else [] + run(d8, "--min-api", "24", *libraries, "--output", output, *output.rglob("*.class")) + remote = "/data/local/tmp/" + output.name + run(adb, "shell", "mkdir", "-p", remote) + try: + run(adb, "push", output / "classes.dex", remote + "/classes.dex") + run(adb, "push", args.library.resolve(), remote + "/libandroid-http-client-tests.so") + run(adb, "shell", "CLASSPATH=" + remote + "/classes.dex", + "app_process", "-Xcheck:jni", "/system/bin", + "com.microsoft.applications.events.HttpClient", + remote + "/libandroid-http-client-tests.so", args.case, args.stress_count) + finally: + run(adb, "shell", "rm", "-f", remote + "/classes.dex", + remote + "/libandroid-http-client-tests.so") + run(adb, "shell", "rmdir", remote) + + +if __name__ == "__main__": + main() diff --git a/tests/android-storage/CMakeLists.txt b/tests/android-storage/CMakeLists.txt new file mode 100644 index 000000000..b54cf50ef --- /dev/null +++ b/tests/android-storage/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 3.19...3.31) +project(AndroidStorageSelectionTests LANGUAGES C CXX) + +if(NOT ANDROID OR NOT DEFINED EXPECT_ROOM) + message(FATAL_ERROR "Configure with the Android toolchain and -DEXPECT_ROOM=ON or OFF.") +endif() + +add_subdirectory(../../lib/android_build/maesdk/src/main/cpp aar) + +if((EXPECT_ROOM AND NOT MATSDK_ANDROID_USE_ROOM) + OR (NOT EXPECT_ROOM AND MATSDK_ANDROID_USE_ROOM)) + message(FATAL_ERROR "The AAR wrapper overrode the selected storage backend.") +endif() +get_target_property(sources mat SOURCES) +get_target_property(definitions matsdk_internal_config INTERFACE_COMPILE_DEFINITIONS) +if(EXPECT_ROOM) + if(NOT "offline/OfflineStorage_Room.cpp" IN_LIST sources + OR "offline/OfflineStorage_SQLite.cpp" IN_LIST sources + OR NOT "USE_ROOM" IN_LIST definitions) + message(FATAL_ERROR "Room storage sources and compile definitions do not agree.") + endif() +else() + if(NOT "offline/OfflineStorage_SQLite.cpp" IN_LIST sources + OR "offline/OfflineStorage_Room.cpp" IN_LIST sources + OR "USE_ROOM" IN_LIST definitions + OR NOT TARGET SQLite3::SQLite3) + message(FATAL_ERROR "SQLite storage sources, dependency, and compile definitions do not agree.") + endif() +endif() +message(STATUS "AAR storage selection passed: Room=${MATSDK_ANDROID_USE_ROOM}") diff --git a/tests/android-storage/options-tests.cmake b/tests/android-storage/options-tests.cmake new file mode 100644 index 000000000..4bd9fdb32 --- /dev/null +++ b/tests/android-storage/options-tests.cmake @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.19) + +function(check_options expected canonical legacy) + unset(MATSDK_ANDROID_USE_ROOM CACHE) + unset(USE_ROOM CACHE) + if(NOT canonical STREQUAL "unset") + set(MATSDK_ANDROID_USE_ROOM "${canonical}" CACHE BOOL "") + endif() + if(NOT legacy STREQUAL "unset") + set(USE_ROOM "${legacy}" CACHE BOOL "") + endif() + set(CMAKE_SYSTEM_NAME Android) + include("${CMAKE_CURRENT_LIST_DIR}/../../cmake/MatsdkOptions.cmake") + if((expected AND NOT MATSDK_ANDROID_USE_ROOM) + OR (NOT expected AND MATSDK_ANDROID_USE_ROOM)) + message(FATAL_ERROR "Unexpected Room selection: canonical=${canonical}, legacy=${legacy}") + endif() + if(expected AND NOT MATSDK_SQLITE_PROVIDER_RESOLVED STREQUAL "NONE") + message(FATAL_ERROR "Room must not depend on SQLite by default.") + elseif(NOT expected AND NOT MATSDK_SQLITE_PROVIDER_RESOLVED STREQUAL "VENDORED") + message(FATAL_ERROR "SQLite storage must resolve its native dependency.") + endif() +endfunction() + +check_options(OFF unset unset) +check_options(ON ON unset) +check_options(OFF OFF unset) +check_options(ON unset ON) +check_options(OFF unset OFF) +check_options(ON ON OFF) +check_options(OFF OFF ON) +message(STATUS "Android storage option compatibility and precedence passed")