mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-21 12:00:53 +02:00
GetMethodID maps each name to a JniMethod enum, so dispatch compares numbers instead of strings. Calls that reach no handler print one "no handler for" line per class, method and signature. Unknown gameActivity_ callbacks are recorded on the CallVoidMethod path too. The FMOD write path reuses a per-thread buffer instead of allocating.
3375 lines
130 KiB
C++
3375 lines
130 KiB
C++
// Modified by vii from komaruworld/mocktail. See README "About this fork".
|
|
#include "jnivm/jnivm.h"
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
#include <array>
|
|
#include <chrono>
|
|
#include <condition_variable>
|
|
#include <cstdarg>
|
|
#include <cstdint>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#if defined(__x86_64__)
|
|
#include <xmmintrin.h>
|
|
#endif
|
|
|
|
namespace jnivm {
|
|
namespace {
|
|
|
|
class JniVmTest : public ::testing::Test {
|
|
protected:
|
|
void SetUp() override {
|
|
unsetenv("MOCKTAIL_ROBLOX_COOKIES");
|
|
unsetenv("MOCKTAIL_ROBLOX_USER_ID");
|
|
unsetenv("MOCKTAIL_ROBLOX_USERNAME");
|
|
unsetenv("MOCKTAIL_JNI_STRING_TRACE");
|
|
unsetenv("MOCKTAIL_TRACE_ALL");
|
|
unsetenv("MOCKTAIL_FULL_TRACE");
|
|
unsetenv("MOCKTAIL_RESOLVED_THEME_INTERNAL");
|
|
vm_ = std::make_shared<VM>();
|
|
}
|
|
|
|
void TearDown() override {
|
|
unsetenv("MOCKTAIL_ROBLOX_COOKIES");
|
|
unsetenv("MOCKTAIL_ROBLOX_USER_ID");
|
|
unsetenv("MOCKTAIL_ROBLOX_USERNAME");
|
|
unsetenv("MOCKTAIL_JNI_STRING_TRACE");
|
|
unsetenv("MOCKTAIL_TRACE_ALL");
|
|
unsetenv("MOCKTAIL_FULL_TRACE");
|
|
unsetenv("MOCKTAIL_RESOLVED_THEME_INTERNAL");
|
|
}
|
|
|
|
std::shared_ptr<VM> vm_;
|
|
};
|
|
|
|
std::string ReadJavaString(JNIEnv *env, jstring value) {
|
|
if (!value) {
|
|
return {};
|
|
}
|
|
const char *chars = env->GetStringUTFChars(value, nullptr);
|
|
if (!chars) {
|
|
return {};
|
|
}
|
|
const std::string result(chars);
|
|
env->ReleaseStringUTFChars(value, chars);
|
|
return result;
|
|
}
|
|
|
|
struct MemStorageCallbackProbe {
|
|
std::mutex mutex;
|
|
std::condition_variable condition;
|
|
int calls = 0;
|
|
bool block = false;
|
|
bool entered = false;
|
|
bool release = false;
|
|
std::vector<std::string> values;
|
|
};
|
|
|
|
void ProbeMemStorageItemSet(void *context, JNIEnv *env, jstring value) {
|
|
auto *probe = static_cast<MemStorageCallbackProbe *>(context);
|
|
std::unique_lock<std::mutex> lock(probe->mutex);
|
|
++probe->calls;
|
|
probe->values.push_back(ReadJavaString(env, value));
|
|
probe->entered = true;
|
|
probe->condition.notify_all();
|
|
probe->condition.wait(lock,
|
|
[probe]() { return !probe->block || probe->release; });
|
|
}
|
|
|
|
struct DataModelNotificationProbe {
|
|
int calls = 0;
|
|
int app_bridge_calls = 0;
|
|
int native_overlay_calls = 0;
|
|
int open_web_activity_calls = 0;
|
|
int cookie_sync_calls = 0;
|
|
int cookie_set_calls = 0;
|
|
std::string type;
|
|
std::string data;
|
|
std::string title;
|
|
std::string url;
|
|
std::string cookie;
|
|
std::string cookie_set_url;
|
|
};
|
|
|
|
void ProbeDataModelNotification(void *context, JNIEnv *env, jstring type,
|
|
jstring data) {
|
|
auto *probe = static_cast<DataModelNotificationProbe *>(context);
|
|
++probe->calls;
|
|
probe->type = ReadJavaString(env, type);
|
|
probe->data = ReadJavaString(env, data);
|
|
}
|
|
|
|
void ProbeAppBridgeNotification(void* context, JNIEnv* env, jstring type,
|
|
jstring data) {
|
|
auto* probe = static_cast<DataModelNotificationProbe*>(context);
|
|
++probe->app_bridge_calls;
|
|
probe->type = ReadJavaString(env, type);
|
|
probe->data = ReadJavaString(env, data);
|
|
}
|
|
|
|
void ProbeNativeOverlay(void* context, JNIEnv* env, jstring title,
|
|
jstring url) {
|
|
auto* probe = static_cast<DataModelNotificationProbe*>(context);
|
|
++probe->native_overlay_calls;
|
|
probe->title = ReadJavaString(env, title);
|
|
probe->url = ReadJavaString(env, url);
|
|
}
|
|
|
|
void ProbeOpenWebActivity(void* context, JNIEnv* env, jstring url,
|
|
jstring title) {
|
|
auto* probe = static_cast<DataModelNotificationProbe*>(context);
|
|
++probe->open_web_activity_calls;
|
|
probe->url = ReadJavaString(env, url);
|
|
probe->title = ReadJavaString(env, title);
|
|
}
|
|
|
|
void ProbeCookieSync(void* context, JNIEnv* env, jstring cookie) {
|
|
auto* probe = static_cast<DataModelNotificationProbe*>(context);
|
|
++probe->cookie_sync_calls;
|
|
probe->cookie = ReadJavaString(env, cookie);
|
|
}
|
|
|
|
void ProbeCookieSet(void* context, JNIEnv* env, jstring cookie, jstring url) {
|
|
auto* probe = static_cast<DataModelNotificationProbe*>(context);
|
|
++probe->cookie_set_calls;
|
|
probe->cookie = ReadJavaString(env, cookie);
|
|
probe->cookie_set_url = ReadJavaString(env, url);
|
|
}
|
|
|
|
struct CredentialSinkProbe {
|
|
int calls = 0;
|
|
std::string credential;
|
|
};
|
|
|
|
bool ProbeCredentialStore(void* context, const char* data, std::size_t size) {
|
|
auto* probe = static_cast<CredentialSinkProbe*>(context);
|
|
if (probe == nullptr || data == nullptr || size == 0) {
|
|
return false;
|
|
}
|
|
++probe->calls;
|
|
probe->credential.assign(data, size);
|
|
return true;
|
|
}
|
|
|
|
MemStorageCallbackCallbacks ProbeMemStorageCallbacks() {
|
|
MemStorageCallbackCallbacks callbacks;
|
|
callbacks.on_item_set = ProbeMemStorageItemSet;
|
|
return callbacks;
|
|
}
|
|
|
|
RobloxCredentialView TestCredentialProvider(const void *context) {
|
|
const auto *credential = static_cast<const std::string *>(context);
|
|
return credential != nullptr
|
|
? RobloxCredentialView{credential->data(), credential->size()}
|
|
: RobloxCredentialView{};
|
|
}
|
|
|
|
jstring QuickLoginCookieGetter(JNIEnv* env, jclass, jstring) {
|
|
return env->NewStringUTF(
|
|
".ROBLOSECURITY=_|quick-login-cookie; RBXEventTracker=ignored");
|
|
}
|
|
|
|
struct FmodAudioDeviceProbe {
|
|
int init_calls = 0;
|
|
int write_calls = 0;
|
|
int close_calls = 0;
|
|
int shutdown_calls = 0;
|
|
const void *last_identity = nullptr;
|
|
int channels = 0;
|
|
int sample_rate_hz = 0;
|
|
int block_size_frames = 0;
|
|
int block_count = 0;
|
|
std::vector<std::uint8_t> bytes;
|
|
};
|
|
|
|
struct AndroidWindowProbe {
|
|
int calls = 0;
|
|
int flags = 0;
|
|
int mask = 0;
|
|
};
|
|
|
|
bool ProbeAndroidWindowFlags(void* context, int flags, int mask) {
|
|
auto* probe = static_cast<AndroidWindowProbe*>(context);
|
|
++probe->calls;
|
|
probe->flags = flags;
|
|
probe->mask = mask;
|
|
return true;
|
|
}
|
|
|
|
bool ProbeFmodInit(void *context, const void *identity, int channels,
|
|
int sample_rate_hz, int block_size_frames, int block_count) {
|
|
auto *probe = static_cast<FmodAudioDeviceProbe *>(context);
|
|
++probe->init_calls;
|
|
probe->last_identity = identity;
|
|
probe->channels = channels;
|
|
probe->sample_rate_hz = sample_rate_hz;
|
|
probe->block_size_frames = block_size_frames;
|
|
probe->block_count = block_count;
|
|
return true;
|
|
}
|
|
|
|
bool ProbeFmodWrite(void *context, const void *identity,
|
|
const std::uint8_t *data, std::size_t size) {
|
|
auto *probe = static_cast<FmodAudioDeviceProbe *>(context);
|
|
++probe->write_calls;
|
|
probe->last_identity = identity;
|
|
probe->bytes.assign(data, data + size);
|
|
return true;
|
|
}
|
|
|
|
bool ProbeFmodClose(void *context, const void *identity) {
|
|
auto *probe = static_cast<FmodAudioDeviceProbe *>(context);
|
|
++probe->close_calls;
|
|
probe->last_identity = identity;
|
|
return true;
|
|
}
|
|
|
|
void ProbeFmodShutdown(void *context) {
|
|
++static_cast<FmodAudioDeviceProbe *>(context)->shutdown_calls;
|
|
}
|
|
|
|
FmodAudioDeviceCallbacks ProbeFmodCallbacks() {
|
|
FmodAudioDeviceCallbacks callbacks;
|
|
callbacks.init = ProbeFmodInit;
|
|
callbacks.write = ProbeFmodWrite;
|
|
callbacks.close = ProbeFmodClose;
|
|
callbacks.shutdown = ProbeFmodShutdown;
|
|
return callbacks;
|
|
}
|
|
|
|
struct RobloxTextInputProbe {
|
|
std::mutex mutex;
|
|
std::condition_variable condition;
|
|
int show_calls = 0;
|
|
int hide_calls = 0;
|
|
int replace_calls = 0;
|
|
int properties_calls = 0;
|
|
int shutdown_calls = 0;
|
|
bool block_show = false;
|
|
bool show_entered = false;
|
|
bool release_show = false;
|
|
RobloxTextInputShowRequest request;
|
|
std::string replacement;
|
|
};
|
|
|
|
void ProbeTextInputShow(void *context,
|
|
const RobloxTextInputShowRequest &request) {
|
|
auto *probe = static_cast<RobloxTextInputProbe *>(context);
|
|
std::unique_lock<std::mutex> lock(probe->mutex);
|
|
++probe->show_calls;
|
|
probe->request = request;
|
|
probe->show_entered = true;
|
|
probe->condition.notify_all();
|
|
probe->condition.wait(
|
|
lock, [probe]() { return !probe->block_show || probe->release_show; });
|
|
}
|
|
|
|
void ProbeTextInputHide(void *context) {
|
|
++static_cast<RobloxTextInputProbe *>(context)->hide_calls;
|
|
}
|
|
|
|
void ProbeTextInputReplace(void *context, const std::string &text) {
|
|
auto *probe = static_cast<RobloxTextInputProbe *>(context);
|
|
++probe->replace_calls;
|
|
probe->replacement = text;
|
|
}
|
|
|
|
void ProbeTextInputPropertiesChanged(void *context) {
|
|
++static_cast<RobloxTextInputProbe *>(context)->properties_calls;
|
|
}
|
|
|
|
void ProbeTextInputShutdown(void *context) {
|
|
++static_cast<RobloxTextInputProbe *>(context)->shutdown_calls;
|
|
}
|
|
|
|
RobloxTextInputCallbacks ProbeTextInputCallbacks() {
|
|
RobloxTextInputCallbacks callbacks;
|
|
callbacks.show = ProbeTextInputShow;
|
|
callbacks.hide = ProbeTextInputHide;
|
|
callbacks.replace_text = ProbeTextInputReplace;
|
|
callbacks.properties_changed = ProbeTextInputPropertiesChanged;
|
|
callbacks.shutdown = ProbeTextInputShutdown;
|
|
return callbacks;
|
|
}
|
|
|
|
jboolean CallBooleanMethodVForTest(JNIEnv *env, jobject obj,
|
|
jmethodID method_id, ...) {
|
|
va_list args;
|
|
va_start(args, method_id);
|
|
const jboolean result =
|
|
env->functions->CallBooleanMethodV(env, obj, method_id, args);
|
|
va_end(args);
|
|
return result;
|
|
}
|
|
|
|
jobject CallObjectMethodVForTest(JNIEnv *env, jobject obj, jmethodID method_id,
|
|
...) {
|
|
va_list args;
|
|
va_start(args, method_id);
|
|
jobject result = env->functions->CallObjectMethodV(env, obj, method_id, args);
|
|
va_end(args);
|
|
return result;
|
|
}
|
|
|
|
void CallVoidMethodVForTest(JNIEnv *env, jobject obj, jmethodID method_id,
|
|
...) {
|
|
va_list args;
|
|
va_start(args, method_id);
|
|
env->functions->CallVoidMethodV(env, obj, method_id, args);
|
|
va_end(args);
|
|
}
|
|
|
|
jobject NewObjectVForTest(JNIEnv *env, jclass cls, jmethodID method_id, ...) {
|
|
va_list args;
|
|
va_start(args, method_id);
|
|
jobject result = env->functions->NewObjectV(env, cls, method_id, args);
|
|
va_end(args);
|
|
return result;
|
|
}
|
|
|
|
void CallStaticVoidMethodVForTest(JNIEnv *env, jclass cls, jmethodID method_id,
|
|
...) {
|
|
va_list args;
|
|
va_start(args, method_id);
|
|
env->functions->CallStaticVoidMethodV(env, cls, method_id, args);
|
|
va_end(args);
|
|
}
|
|
|
|
jobject MakeTextBoxInfo(JNIEnv *env) {
|
|
jclass cls = env->FindClass("com/roblox/engine/jni/model/NativeTextBoxInfo");
|
|
const jmethodID constructor =
|
|
env->GetMethodID(cls, "<init>", "(FFFFFZIIIIIIZZ)V");
|
|
jobject info =
|
|
env->NewObject(cls, constructor, 1.0, 2.0, 300.0, 40.0, 18.0, JNI_TRUE, 1,
|
|
2, 0x10203040, 4, 5, 6, JNI_TRUE, JNI_FALSE);
|
|
const auto set_float = [env, cls, info](const char *name, jfloat value) {
|
|
env->SetFloatField(info, env->GetFieldID(cls, name, "F"), value);
|
|
};
|
|
const auto set_int = [env, cls, info](const char *name, jint value) {
|
|
env->SetIntField(info, env->GetFieldID(cls, name, "I"), value);
|
|
};
|
|
const auto set_bool = [env, cls, info](const char *name, jboolean value) {
|
|
env->SetBooleanField(info, env->GetFieldID(cls, name, "Z"), value);
|
|
};
|
|
set_float("x", 1.0f);
|
|
set_float("y", 2.0f);
|
|
set_float("width", 300.0f);
|
|
set_float("height", 40.0f);
|
|
set_float("fontSize", 18.0f);
|
|
set_bool("multiline", JNI_TRUE);
|
|
set_int("xAlignment", 1);
|
|
set_int("yAlignment", 2);
|
|
set_int("textColor", 0x10203040);
|
|
set_int("font", 4);
|
|
set_int("textInputType", 5);
|
|
set_int("returnKeyType", 6);
|
|
set_bool("manualFocusRelease", JNI_TRUE);
|
|
set_bool("textWrapped", JNI_FALSE);
|
|
return info;
|
|
}
|
|
|
|
TEST_F(JniVmTest, JavaVmPointerIsNotNull) {
|
|
EXPECT_NE(vm_->GetJavaVM(), nullptr);
|
|
}
|
|
|
|
TEST_F(JniVmTest, JniEnvPointerIsNotNull) {
|
|
EXPECT_NE(vm_->GetJNIEnv(), nullptr);
|
|
}
|
|
|
|
TEST_F(JniVmTest, EveryJniFunctionSlotHoldsAFunction) {
|
|
JNIEnv* env = vm_->GetJNIEnv();
|
|
ASSERT_NE(env, nullptr);
|
|
// The first four slots are reserved and null by definition.
|
|
constexpr std::size_t kReservedSlots = 4;
|
|
std::array<void*, sizeof(JNINativeInterface_) / sizeof(void*)> slots;
|
|
std::memcpy(slots.data(), env->functions, sizeof(JNINativeInterface_));
|
|
std::string null_slots;
|
|
for (std::size_t slot = kReservedSlots; slot < slots.size(); ++slot) {
|
|
if (slots[slot] == nullptr) {
|
|
null_slots += " " + std::to_string(slot);
|
|
}
|
|
}
|
|
EXPECT_TRUE(null_slots.empty()) << "null JNI slots:" << null_slots;
|
|
}
|
|
|
|
TEST_F(JniVmTest, UnimplementedJniFunctionsReturnZeroInsteadOfCrashing) {
|
|
JNIEnv* env = vm_->GetJNIEnv();
|
|
ASSERT_NE(env, nullptr);
|
|
EXPECT_EQ(env->NewIntArray(4), nullptr);
|
|
EXPECT_EQ(env->CallNonvirtualIntMethod(nullptr, nullptr, nullptr), 0);
|
|
EXPECT_EQ(env->CallNonvirtualDoubleMethod(nullptr, nullptr, nullptr), 0.0);
|
|
env->SetIntArrayRegion(nullptr, 0, 0, nullptr);
|
|
}
|
|
|
|
#if defined(__x86_64__)
|
|
constexpr unsigned int kAudioFpMode = (1U << 15) | (1U << 6);
|
|
|
|
class ScopedMxcsr final {
|
|
public:
|
|
ScopedMxcsr() : saved_(_mm_getcsr()) {}
|
|
~ScopedMxcsr() { _mm_setcsr(saved_); }
|
|
|
|
private:
|
|
unsigned int saved_;
|
|
};
|
|
|
|
// Volatile input ensures the multiply executes in SSE with the current MXCSR,
|
|
// rather than being constant-folded using the compiler's default FP model.
|
|
float MultiplyAudioSample(volatile float* sample, float gain) {
|
|
return _mm_cvtss_f32(_mm_mul_ss(_mm_set_ss(*sample), _mm_set_ss(gain)));
|
|
}
|
|
|
|
TEST_F(JniVmTest, FmodMixerFlushesSubnormalAudioWithoutChangingParentThread) {
|
|
ScopedMxcsr restore;
|
|
_mm_setcsr(0x1f80); // IEEE defaults; FTZ/DAZ disabled.
|
|
JavaVM* java_vm = vm_->GetJavaVM();
|
|
std::thread mixer([java_vm]() {
|
|
ScopedMxcsr restore_worker;
|
|
volatile float tail = 0x1p-140f;
|
|
volatile float quiet_normal = 0x1p-126f;
|
|
volatile float audible = 0.25f;
|
|
EXPECT_GT(MultiplyAudioSample(&tail, 1.0f), 0.0f);
|
|
EXPECT_GT(MultiplyAudioSample(&quiet_normal, 0.5f), 0.0f);
|
|
const unsigned int before = _mm_getcsr();
|
|
char name[] = "FMOD mixer thread";
|
|
JavaVMAttachArgs args{JNI_VERSION_1_6, name, nullptr};
|
|
void* env = nullptr;
|
|
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
|
|
EXPECT_EQ(_mm_getcsr(), before | kAudioFpMode);
|
|
EXPECT_EQ(MultiplyAudioSample(&tail, 1.0f), 0.0f);
|
|
EXPECT_EQ(MultiplyAudioSample(&quiet_normal, 0.5f), 0.0f);
|
|
EXPECT_EQ(MultiplyAudioSample(&audible, 0.5f), 0.125f);
|
|
// Reattaching with no name must not lose the mixer's saved FP state.
|
|
ASSERT_EQ(java_vm->AttachCurrentThread(&env, nullptr), JNI_OK);
|
|
EXPECT_EQ(_mm_getcsr() & kAudioFpMode, kAudioFpMode);
|
|
ASSERT_EQ(java_vm->DetachCurrentThread(), JNI_OK);
|
|
EXPECT_EQ(_mm_getcsr() & kAudioFpMode, before & kAudioFpMode);
|
|
EXPECT_GT(MultiplyAudioSample(&tail, 1.0f), 0.0f);
|
|
});
|
|
mixer.join();
|
|
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
|
}
|
|
|
|
TEST_F(JniVmTest, FmodDaemonThreadsRestoreExistingModesAndOtherControls) {
|
|
ScopedMxcsr restore;
|
|
JavaVM* java_vm = vm_->GetJavaVM();
|
|
for (const char* name : {"FMOD mixer thread", "FMOD feeder thread",
|
|
"FMOD Convolution thread", "FMOD Worker Thread"}) {
|
|
for (const unsigned int mode : {0U, 1U << 6, 1U << 15, kAudioFpMode}) {
|
|
_mm_setcsr(0x1f80 | mode | _MM_ROUND_DOWN);
|
|
const unsigned int before = _mm_getcsr();
|
|
JavaVMAttachArgs args{JNI_VERSION_1_6, const_cast<char*>(name), nullptr};
|
|
void* env = nullptr;
|
|
ASSERT_EQ(java_vm->AttachCurrentThreadAsDaemon(&env, &args), JNI_OK);
|
|
EXPECT_EQ(_mm_getcsr(), before | kAudioFpMode);
|
|
// Guest changes to other controls/status must survive detach.
|
|
_mm_setcsr((_mm_getcsr() & ~_MM_ROUND_MASK) | _MM_ROUND_UP | 0x20U);
|
|
const unsigned int after_guest = _mm_getcsr();
|
|
ASSERT_EQ(java_vm->DetachCurrentThread(), JNI_OK);
|
|
EXPECT_EQ(_mm_getcsr(), (after_guest & ~kAudioFpMode) | mode);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_F(JniVmTest, OrdinaryJniThreadsKeepIeeeFloatingPointBehavior) {
|
|
ScopedMxcsr restore;
|
|
_mm_setcsr(0x1f80);
|
|
JavaVM* java_vm = vm_->GetJavaVM();
|
|
for (const char* name : {"Main", "RBX Worker", "FMOD mixer thread extra",
|
|
"FMOD file thread", "", static_cast<const char*>(nullptr)}) {
|
|
JavaVMAttachArgs args{JNI_VERSION_1_6, const_cast<char*>(name), nullptr};
|
|
void* env = nullptr;
|
|
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
|
|
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
|
// JNI ignores new names when a thread is already attached.
|
|
args.name = const_cast<char*>("FMOD mixer thread");
|
|
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
|
|
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
|
ASSERT_EQ(java_vm->DetachCurrentThread(), JNI_OK);
|
|
}
|
|
void* env = nullptr;
|
|
ASSERT_EQ(java_vm->AttachCurrentThread(&env, nullptr), JNI_OK);
|
|
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
|
EXPECT_EQ(java_vm->DetachCurrentThread(), JNI_OK);
|
|
}
|
|
|
|
TEST_F(JniVmTest, RejectedFmodAttachDoesNotChangeFloatingPointMode) {
|
|
ScopedMxcsr restore;
|
|
_mm_setcsr(0x1f80);
|
|
JavaVM* java_vm = vm_->GetJavaVM();
|
|
char name[] = "FMOD mixer thread";
|
|
JavaVMAttachArgs args{JNI_VERSION_1_6, name, nullptr};
|
|
EXPECT_EQ(java_vm->AttachCurrentThread(nullptr, &args), JNI_EINVAL);
|
|
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
|
JavaVM foreign = {java_vm->functions};
|
|
void* env = nullptr;
|
|
EXPECT_EQ(foreign.AttachCurrentThread(&env, &args), JNI_EINVAL);
|
|
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
|
}
|
|
|
|
TEST_F(JniVmTest, ChangingVmRestoresFmodFloatingPointMode) {
|
|
ScopedMxcsr restore;
|
|
_mm_setcsr(0x1f80);
|
|
JavaVM* java_vm = vm_->GetJavaVM();
|
|
char name[] = "FMOD mixer thread";
|
|
JavaVMAttachArgs args{JNI_VERSION_1_6, name, nullptr};
|
|
void* env = nullptr;
|
|
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
|
|
{
|
|
VM temporary;
|
|
// Merely constructing/destroying another VM must not change this thread.
|
|
}
|
|
EXPECT_EQ(_mm_getcsr() & kAudioFpMode, kAudioFpMode);
|
|
{
|
|
VM other;
|
|
ASSERT_EQ(other.GetJavaVM()->AttachCurrentThread(&env, nullptr), JNI_OK);
|
|
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
|
}
|
|
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
|
|
{
|
|
VM other;
|
|
ASSERT_NE(other.GetJNIEnv(), nullptr);
|
|
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
|
}
|
|
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
|
|
vm_.reset();
|
|
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
|
}
|
|
#endif
|
|
|
|
TEST_F(JniVmTest, DiscardedTemporaryVmKeepsOriginalUsable) {
|
|
JavaVM* java_vm = vm_->GetJavaVM();
|
|
JNIEnv* original_env = vm_->GetJNIEnv();
|
|
{
|
|
auto candidate = std::make_unique<VM>();
|
|
EXPECT_EQ(VM::FromJavaVM(java_vm), vm_.get());
|
|
}
|
|
|
|
void* queried_env = nullptr;
|
|
ASSERT_EQ(java_vm->GetEnv(&queried_env, JNI_VERSION_1_6), JNI_OK);
|
|
EXPECT_EQ(queried_env, original_env);
|
|
EXPECT_EQ(VM::FromJavaVM(java_vm), vm_.get());
|
|
|
|
// JNI_OnLoad also attaches Roblox worker threads to the surviving VM.
|
|
std::thread worker([java_vm]() {
|
|
void* attached_env = nullptr;
|
|
ASSERT_EQ(java_vm->AttachCurrentThread(&attached_env, nullptr), JNI_OK);
|
|
ASSERT_NE(attached_env, nullptr);
|
|
JavaVM* owner = nullptr;
|
|
EXPECT_EQ(static_cast<JNIEnv*>(attached_env)->GetJavaVM(&owner), JNI_OK);
|
|
EXPECT_EQ(owner, java_vm);
|
|
EXPECT_EQ(java_vm->DetachCurrentThread(), JNI_OK);
|
|
});
|
|
worker.join();
|
|
}
|
|
|
|
TEST_F(JniVmTest, SurvivingVmReattachesAfterTemporaryVmUsedTheThread) {
|
|
JavaVM* java_vm = vm_->GetJavaVM();
|
|
const auto* original_functions = vm_->GetJNIEnv()->functions;
|
|
{
|
|
auto candidate = std::make_unique<VM>();
|
|
EXPECT_NE(candidate->GetJNIEnv()->functions, original_functions);
|
|
}
|
|
|
|
JNIEnv* env = vm_->GetJNIEnv();
|
|
EXPECT_EQ(env->functions, original_functions);
|
|
void* queried_env = nullptr;
|
|
EXPECT_EQ(java_vm->GetEnv(&queried_env, JNI_VERSION_1_6), JNI_OK);
|
|
EXPECT_EQ(queried_env, env);
|
|
JavaVM* owner = nullptr;
|
|
EXPECT_EQ(env->GetJavaVM(&owner), JNI_OK);
|
|
EXPECT_EQ(owner, java_vm);
|
|
}
|
|
|
|
TEST_F(JniVmTest, InvocationUsesTheRequestedVmNotTheNewestVm) {
|
|
JavaVM* original = vm_->GetJavaVM();
|
|
vm_->GetJNIEnv();
|
|
auto candidate = std::make_unique<VM>();
|
|
JavaVM* other = candidate->GetJavaVM();
|
|
void* env = nullptr;
|
|
EXPECT_EQ(other->GetEnv(&env, JNI_VERSION_1_6), JNI_EDETACHED);
|
|
EXPECT_EQ(env, nullptr);
|
|
|
|
const auto* other_functions = candidate->GetJNIEnv()->functions;
|
|
vm_->RestoreFunctions();
|
|
EXPECT_EQ(candidate->GetJNIEnv()->functions, other_functions);
|
|
EXPECT_EQ(original->GetEnv(&env, JNI_VERSION_1_6), JNI_EDETACHED);
|
|
EXPECT_EQ(env, nullptr);
|
|
EXPECT_EQ(original->DetachCurrentThread(), JNI_EDETACHED);
|
|
EXPECT_EQ(other->GetEnv(&env, JNI_VERSION_1_6), JNI_OK);
|
|
|
|
ASSERT_EQ(original->AttachCurrentThread(&env, nullptr), JNI_OK);
|
|
ASSERT_NE(env, nullptr);
|
|
JavaVM* owner = nullptr;
|
|
EXPECT_EQ(static_cast<JNIEnv*>(env)->GetJavaVM(&owner), JNI_OK);
|
|
EXPECT_EQ(owner, original);
|
|
EXPECT_EQ(other->GetEnv(&env, JNI_VERSION_1_6), JNI_EDETACHED);
|
|
EXPECT_EQ(env, nullptr);
|
|
}
|
|
|
|
TEST_F(JniVmTest, ForeignVmIsRejectedWithoutDetachingTheOwner) {
|
|
JavaVM* java_vm = vm_->GetJavaVM();
|
|
JNIEnv* original_env = vm_->GetJNIEnv();
|
|
JavaVM foreign = {java_vm->functions};
|
|
void* env = original_env;
|
|
EXPECT_EQ(VM::FromJavaVM(&foreign), nullptr);
|
|
EXPECT_EQ(foreign.GetEnv(&env, JNI_VERSION_1_6), JNI_EINVAL);
|
|
EXPECT_EQ(env, nullptr);
|
|
env = original_env;
|
|
EXPECT_EQ(foreign.AttachCurrentThread(&env, nullptr), JNI_EINVAL);
|
|
EXPECT_EQ(env, nullptr);
|
|
EXPECT_EQ(foreign.DetachCurrentThread(), JNI_EINVAL);
|
|
EXPECT_EQ(java_vm->GetEnv(&env, JNI_VERSION_1_6), JNI_OK);
|
|
EXPECT_EQ(env, original_env);
|
|
}
|
|
|
|
TEST_F(JniVmTest, DestroyingOlderVmDoesNotUnregisterItsReplacement) {
|
|
JavaVM* retired_vm = vm_->GetJavaVM();
|
|
auto replacement = std::make_unique<VM>();
|
|
JavaVM* java_vm = replacement->GetJavaVM();
|
|
JNIEnv* replacement_env = replacement->GetJNIEnv();
|
|
vm_.reset();
|
|
|
|
EXPECT_EQ(VM::FromJavaVM(retired_vm), nullptr);
|
|
EXPECT_EQ(VM::FromJavaVM(java_vm), replacement.get());
|
|
void* env = nullptr;
|
|
EXPECT_EQ(java_vm->GetEnv(&env, JNI_VERSION_1_6), JNI_OK);
|
|
EXPECT_EQ(env, replacement_env);
|
|
}
|
|
|
|
TEST_F(JniVmTest, GuestWrappedTablesKeepTheirOwnerOnRepeatedAttach) {
|
|
JavaVM* java_vm = vm_->GetJavaVM();
|
|
JNIEnv* env = vm_->GetJNIEnv();
|
|
JNIInvokeInterface_ wrapped_vm = *java_vm->functions;
|
|
JNINativeInterface_ wrapped_env = *env->functions;
|
|
java_vm->functions = &wrapped_vm;
|
|
env->functions = &wrapped_env;
|
|
{
|
|
auto candidate = std::make_unique<VM>();
|
|
}
|
|
|
|
EXPECT_EQ(VM::FromJavaVM(java_vm), vm_.get());
|
|
void* attached_env = nullptr;
|
|
EXPECT_EQ(java_vm->AttachCurrentThread(&attached_env, nullptr), JNI_OK);
|
|
EXPECT_EQ(attached_env, env);
|
|
EXPECT_EQ(env->functions, &wrapped_env);
|
|
EXPECT_EQ(java_vm->functions, &wrapped_vm);
|
|
void* queried_env = nullptr;
|
|
EXPECT_EQ(java_vm->GetEnv(&queried_env, JNI_VERSION_1_6), JNI_OK);
|
|
EXPECT_EQ(queried_env, env);
|
|
vm_->RestoreFunctions();
|
|
}
|
|
|
|
TEST_F(JniVmTest, TemporaryVmOnAnotherThreadDoesNotDetachTheOriginal) {
|
|
JavaVM* java_vm = vm_->GetJavaVM();
|
|
JNIEnv* original_env = vm_->GetJNIEnv();
|
|
std::thread worker([]() {
|
|
auto candidate = std::make_unique<VM>();
|
|
candidate->GetJNIEnv();
|
|
});
|
|
worker.join();
|
|
void* queried_env = nullptr;
|
|
EXPECT_EQ(java_vm->GetEnv(&queried_env, JNI_VERSION_1_6), JNI_OK);
|
|
EXPECT_EQ(queried_env, original_env);
|
|
EXPECT_EQ(VM::FromJavaVM(java_vm), vm_.get());
|
|
}
|
|
|
|
TEST_F(JniVmTest, TraceAllNeverPrintsJStringContentOrPrefix) {
|
|
constexpr char kCookieShapedSecret[] =
|
|
".ROBLOSECURITY=_|SECRET_CANARY_PREFIX_never_log_this_value";
|
|
setenv("MOCKTAIL_TRACE_ALL", "1", 1);
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jstring value = env->NewStringUTF(kCookieShapedSecret);
|
|
ASSERT_NE(value, nullptr);
|
|
|
|
testing::internal::CaptureStdout();
|
|
testing::internal::CaptureStderr();
|
|
const char *chars = env->GetStringUTFChars(value, nullptr);
|
|
const std::size_t returned_length = chars ? std::strlen(chars) : 0;
|
|
env->ReleaseStringUTFChars(value, chars);
|
|
const std::string stderr_output = testing::internal::GetCapturedStderr();
|
|
const std::string stdout_output = testing::internal::GetCapturedStdout();
|
|
const std::string trace_output = stdout_output + stderr_output;
|
|
|
|
ASSERT_NE(chars, nullptr);
|
|
EXPECT_EQ(returned_length, std::strlen(kCookieShapedSecret));
|
|
EXPECT_NE(trace_output.find("GetStringUTFChars"), std::string::npos);
|
|
EXPECT_NE(trace_output.find("len="), std::string::npos);
|
|
EXPECT_EQ(trace_output.find(kCookieShapedSecret), std::string::npos);
|
|
EXPECT_EQ(trace_output.find(".ROBLOSECURITY"), std::string::npos);
|
|
EXPECT_EQ(trace_output.find("SECRET_CANARY_PREFIX"), std::string::npos);
|
|
}
|
|
|
|
// Creates strings, which fixes the process-wide trace setting, so it stays
|
|
// below the test that sets MOCKTAIL_TRACE_ALL.
|
|
TEST_F(JniVmTest, ReleasingAReferenceTwiceDoesNotGiveOneHandleToTwoObjects) {
|
|
JNIEnv* env = vm_->GetJNIEnv();
|
|
ASSERT_NE(env, nullptr);
|
|
jstring stale = env->NewStringUTF("stale");
|
|
env->DeleteLocalRef(stale);
|
|
env->DeleteLocalRef(stale);
|
|
jstring first = env->NewStringUTF("first");
|
|
jstring second = env->NewStringUTF("second");
|
|
EXPECT_NE(first, second);
|
|
EXPECT_EQ(ReadJavaString(env, first), "first");
|
|
EXPECT_EQ(ReadJavaString(env, second), "second");
|
|
}
|
|
|
|
TEST_F(JniVmTest, InitialClassCountIsZero) {
|
|
EXPECT_EQ(vm_->GetClassCount(), 0u);
|
|
}
|
|
|
|
TEST_F(JniVmTest, RegisterClassReturnsNonNull) {
|
|
auto cls = vm_->RegisterClass("android/content/Context");
|
|
EXPECT_NE(cls, nullptr);
|
|
}
|
|
|
|
TEST_F(JniVmTest, RegisterClassPreservesName) {
|
|
const std::string kName = "com/roblox/client/RobloxActivity";
|
|
auto cls = vm_->RegisterClass(kName);
|
|
ASSERT_NE(cls, nullptr);
|
|
EXPECT_EQ(cls->GetName(), kName);
|
|
}
|
|
|
|
TEST_F(JniVmTest, RegisterClassIsDeduplicated) {
|
|
auto cls1 = vm_->RegisterClass("rbx/JNIRobloxSettings");
|
|
auto cls2 = vm_->RegisterClass("rbx/JNIRobloxSettings");
|
|
EXPECT_EQ(cls1.get(), cls2.get());
|
|
}
|
|
|
|
TEST_F(JniVmTest, ClassCountIncrements) {
|
|
vm_->RegisterClass("android/content/Context");
|
|
vm_->RegisterClass("android/app/Activity");
|
|
EXPECT_EQ(vm_->GetClassCount(), 2u);
|
|
}
|
|
|
|
TEST_F(JniVmTest, DuplicateRegistrationDoesNotInflateCount) {
|
|
vm_->RegisterClass("android/content/Context");
|
|
vm_->RegisterClass("android/content/Context");
|
|
EXPECT_EQ(vm_->GetClassCount(), 1u);
|
|
}
|
|
|
|
TEST_F(JniVmTest, FindClassReturnsNullForUnknownClass) {
|
|
EXPECT_EQ(vm_->FindClass("not/Registered"), nullptr);
|
|
}
|
|
|
|
TEST_F(JniVmTest, FindClassReturnsRegisteredInstance) {
|
|
auto registered = vm_->RegisterClass("android/os/Bundle");
|
|
auto found = vm_->FindClass("android/os/Bundle");
|
|
EXPECT_EQ(registered.get(), found.get());
|
|
}
|
|
|
|
TEST_F(JniVmTest, RepeatedClassLookupsReuseTheSameHandle) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass first = env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
|
|
ASSERT_NE(first, nullptr);
|
|
jclass string_class = env->FindClass("java/lang/String");
|
|
ASSERT_NE(string_class, nullptr);
|
|
// Exceeds the JNI segment capacity (100000); without interned class handles
|
|
// every lookup consumes a permanent slot and the table runs out here.
|
|
constexpr int kIterations = 120000;
|
|
for (int i = 0; i < kIterations; ++i) {
|
|
ASSERT_EQ(env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface"),
|
|
first);
|
|
jstring value = env->NewStringUTF("probe");
|
|
ASSERT_NE(value, nullptr);
|
|
jclass value_class = env->GetObjectClass(value);
|
|
ASSERT_EQ(value_class, string_class);
|
|
ASSERT_EQ(env->GetObjectClass(value), value_class);
|
|
env->DeleteLocalRef(value);
|
|
}
|
|
}
|
|
|
|
TEST_F(JniVmTest, RegisteredMethodIsFound) {
|
|
auto cls = vm_->RegisterClass("rbx/JNIRobloxSettings");
|
|
cls->RegisterMethod("nativeInitClientSettings", "()V",
|
|
[](JNIEnv *, jobject) {});
|
|
EXPECT_NE(cls->FindMethod("nativeInitClientSettings", "()V"), nullptr);
|
|
}
|
|
|
|
TEST_F(JniVmTest, MethodLookupWithWrongSignatureReturnsNull) {
|
|
auto cls = vm_->RegisterClass("rbx/JNIRobloxSettings");
|
|
cls->RegisterMethod("nativeInitClientSettings", "()V",
|
|
[](JNIEnv *, jobject) {});
|
|
EXPECT_EQ(cls->FindMethod("nativeInitClientSettings", "(I)V"), nullptr);
|
|
}
|
|
|
|
TEST_F(JniVmTest, LookupOfNonExistentMethodReturnsNull) {
|
|
auto cls = vm_->RegisterClass("android/content/Context");
|
|
EXPECT_EQ(cls->FindMethod("getSystemService",
|
|
"(Ljava/lang/String;)Ljava/lang/Object;"),
|
|
nullptr);
|
|
}
|
|
|
|
TEST_F(JniVmTest, MethodCallbackIsInvoked) {
|
|
auto cls = vm_->RegisterClass("rbx/JNIRobloxSettings");
|
|
bool invoked = false;
|
|
cls->RegisterMethod("nativeInitClientSettings", "()V",
|
|
[&invoked](JNIEnv *, jobject) { invoked = true; });
|
|
|
|
const MethodCallback *cb = cls->FindMethod("nativeInitClientSettings", "()V");
|
|
ASSERT_NE(cb, nullptr);
|
|
(*cb)(vm_->GetJNIEnv(), nullptr);
|
|
|
|
EXPECT_TRUE(invoked);
|
|
}
|
|
|
|
TEST_F(JniVmTest, CallbackReceivesCorrectJniEnv) {
|
|
auto cls = vm_->RegisterClass("android/content/Context");
|
|
JNIEnv *received_env = nullptr;
|
|
cls->RegisterMethod("onCreate", "()V",
|
|
[&](JNIEnv *env, jobject) { received_env = env; });
|
|
|
|
const MethodCallback *cb = cls->FindMethod("onCreate", "()V");
|
|
ASSERT_NE(cb, nullptr);
|
|
(*cb)(vm_->GetJNIEnv(), nullptr);
|
|
|
|
EXPECT_EQ(received_env, vm_->GetJNIEnv());
|
|
}
|
|
|
|
TEST_F(JniVmTest, NetworkUtilsReturnsMockIpv4Address) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass cls = env->FindClass("com/roblox/engine/jni/util/NetworkUtils");
|
|
ASSERT_NE(cls, nullptr);
|
|
|
|
jmethodID method = env->GetStaticMethodID(cls, "getPublicIPv4Addresseses",
|
|
"()Ljava/lang/String;");
|
|
ASSERT_NE(method, nullptr);
|
|
|
|
auto result = static_cast<jstring>(env->CallStaticObjectMethod(cls, method));
|
|
ASSERT_NE(result, nullptr);
|
|
|
|
const char *chars = env->GetStringUTFChars(result, nullptr);
|
|
ASSERT_NE(chars, nullptr);
|
|
EXPECT_STREQ(chars, "127.0.0.1");
|
|
env->ReleaseStringUTFChars(result, chars);
|
|
}
|
|
|
|
TEST_F(JniVmTest, JniCookieManagerReturnsConfiguredCookie) {
|
|
setenv("MOCKTAIL_ROBLOX_COOKIES", ".ROBLOSECURITY=test-cookie-value", 1);
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass cls =
|
|
env->FindClass("com/roblox/universalapp/cookie/JNICookieManager");
|
|
ASSERT_NE(cls, nullptr);
|
|
|
|
jmethodID method = env->GetStaticMethodID(
|
|
cls, "getCookie", "(Ljava/lang/String;)Ljava/lang/String;");
|
|
ASSERT_NE(method, nullptr);
|
|
|
|
jstring domain = env->NewStringUTF("roblox.com");
|
|
auto result =
|
|
static_cast<jstring>(env->CallStaticObjectMethod(cls, method, domain));
|
|
ASSERT_NE(result, nullptr);
|
|
|
|
const char *chars = env->GetStringUTFChars(result, nullptr);
|
|
ASSERT_NE(chars, nullptr);
|
|
EXPECT_STREQ(chars, ".ROBLOSECURITY=test-cookie-value");
|
|
env->ReleaseStringUTFChars(result, chars);
|
|
}
|
|
|
|
TEST_F(JniVmTest, NativeSettingsCookieNetscapeFormatUsesConfiguredCookie) {
|
|
setenv("MOCKTAIL_ROBLOX_COOKIES", ".ROBLOSECURITY=test-cookie-value", 1);
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass cls = env->FindClass("com/roblox/engine/jni/NativeSettingsInterface");
|
|
ASSERT_NE(cls, nullptr);
|
|
|
|
jmethodID method =
|
|
env->GetStaticMethodID(cls, "nativeGetCookiesInNetscapeFormat",
|
|
"(Ljava/lang/String;)Ljava/lang/String;");
|
|
ASSERT_NE(method, nullptr);
|
|
|
|
jstring domain = env->NewStringUTF("https://www.roblox.com/");
|
|
auto result =
|
|
static_cast<jstring>(env->CallStaticObjectMethod(cls, method, domain));
|
|
ASSERT_NE(result, nullptr);
|
|
|
|
const char *chars = env->GetStringUTFChars(result, nullptr);
|
|
ASSERT_NE(chars, nullptr);
|
|
EXPECT_STREQ(chars,
|
|
".www.roblox.com\tTRUE\t/\tTRUE\t2147483647\t"
|
|
".ROBLOSECURITY\ttest-cookie-value");
|
|
env->ReleaseStringUTFChars(result, chars);
|
|
}
|
|
|
|
TEST_F(JniVmTest, TypedCredentialProviderOverridesFallbackAndJavaSetter) {
|
|
setenv("MOCKTAIL_ROBLOX_COOKIES", ".ROBLOSECURITY=_|untrusted-fallback-b", 1);
|
|
std::string credential = ".ROBLOSECURITY=_|typed-credential-a";
|
|
vm_->SetRobloxCredentialProvider(&credential, &TestCredentialProvider);
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass cls =
|
|
env->FindClass("com/roblox/universalapp/cookie/JNICookieManager");
|
|
ASSERT_NE(cls, nullptr);
|
|
jmethodID get_cookie = env->GetStaticMethodID(
|
|
cls, "getCookie", "(Ljava/lang/String;)Ljava/lang/String;");
|
|
ASSERT_NE(get_cookie, nullptr);
|
|
jstring domain = env->NewStringUTF("roblox.com");
|
|
|
|
auto first = static_cast<jstring>(
|
|
env->CallStaticObjectMethod(cls, get_cookie, domain));
|
|
EXPECT_EQ(ReadJavaString(env, first), credential);
|
|
|
|
setenv("MOCKTAIL_ROBLOX_COOKIES", ".ROBLOSECURITY=_|replacement-fallback-c",
|
|
1);
|
|
jobject manager = env->AllocObject(cls);
|
|
jmethodID set_cookie = env->GetMethodID(
|
|
cls, "setCookie", "(Ljava/lang/String;Ljava/lang/String;)V");
|
|
ASSERT_NE(set_cookie, nullptr);
|
|
env->CallVoidMethod(
|
|
manager, set_cookie, domain,
|
|
env->NewStringUTF(".ROBLOSECURITY=_|java-setter-replacement-d"));
|
|
auto second = static_cast<jstring>(
|
|
env->CallStaticObjectMethod(cls, get_cookie, domain));
|
|
EXPECT_EQ(ReadJavaString(env, second), credential);
|
|
vm_->ClearRobloxCredentialProvider();
|
|
}
|
|
|
|
TEST_F(JniVmTest, EmptyTypedCredentialBlocksFallbackForGuest) {
|
|
setenv("MOCKTAIL_ROBLOX_COOKIES", ".ROBLOSECURITY=_|untrusted-fallback", 1);
|
|
const std::string credential;
|
|
vm_->SetRobloxCredentialProvider(&credential, &TestCredentialProvider);
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass cls =
|
|
env->FindClass("com/roblox/universalapp/cookie/JNICookieManager");
|
|
jmethodID get_cookie = env->GetStaticMethodID(
|
|
cls, "getCookie", "(Ljava/lang/String;)Ljava/lang/String;");
|
|
auto result = static_cast<jstring>(env->CallStaticObjectMethod(
|
|
cls, get_cookie, env->NewStringUTF("roblox.com")));
|
|
EXPECT_TRUE(ReadJavaString(env, result).empty());
|
|
|
|
jmethodID load_from_disk =
|
|
env->GetStaticMethodID(cls, "setCookiesFromDisk", "()Z");
|
|
EXPECT_EQ(env->CallStaticBooleanMethod(cls, load_from_disk), JNI_FALSE);
|
|
vm_->ClearRobloxCredentialProvider();
|
|
}
|
|
|
|
TEST_F(JniVmTest, PersistedNativeCredentialSupersedesGuestProvider) {
|
|
const std::string guest;
|
|
vm_->SetRobloxCredentialProvider(&guest, &TestCredentialProvider);
|
|
auto credential_probe = std::make_shared<CredentialSinkProbe>();
|
|
vm_->SetRobloxCredentialSink(
|
|
credential_probe, RobloxCredentialSinkCallbacks{&ProbeCredentialStore});
|
|
const std::string native_credential =
|
|
".ROBLOSECURITY=_|persisted-native-credential";
|
|
|
|
ASSERT_TRUE(vm_->DispatchRobloxCredential(native_credential.data(),
|
|
native_credential.size()));
|
|
std::string current;
|
|
ASSERT_TRUE(vm_->CopyRobloxCredentialFromProvider(¤t));
|
|
EXPECT_EQ(current, native_credential);
|
|
EXPECT_EQ(credential_probe->calls, 1);
|
|
EXPECT_EQ(credential_probe->credential, native_credential);
|
|
|
|
vm_->ClearRobloxCredentialProvider();
|
|
EXPECT_FALSE(vm_->CopyRobloxCredentialFromProvider(¤t));
|
|
EXPECT_TRUE(current.empty());
|
|
}
|
|
|
|
TEST_F(JniVmTest, NativeUserJavaInterfaceReturnsInjectedIdentityExactly) {
|
|
RobloxAuthIdentity identity;
|
|
identity.user_id = 123456789;
|
|
identity.username = "AccountName";
|
|
identity.display_name = "Display Name";
|
|
vm_->SetRobloxAuthIdentity(identity);
|
|
|
|
// Injected identity takes precedence over environment fallbacks.
|
|
setenv("MOCKTAIL_ROBLOX_USER_ID", "77", 1);
|
|
setenv("MOCKTAIL_ROBLOX_USERNAME", "EnvironmentName", 1);
|
|
setenv("MOCKTAIL_RESOLVED_THEME_INTERNAL", "Light", 1);
|
|
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass cls =
|
|
env->FindClass("com/roblox/engine/jni/user/NativeUserJavaInterface");
|
|
ASSERT_NE(cls, nullptr);
|
|
|
|
jmethodID get_user_id = env->GetStaticMethodID(cls, "getUserId", "()J");
|
|
ASSERT_NE(get_user_id, nullptr);
|
|
EXPECT_EQ(env->CallStaticLongMethod(cls, get_user_id), identity.user_id);
|
|
|
|
const auto ReadIdentityMethod = [env, cls](const char *method_name) {
|
|
jmethodID method =
|
|
env->GetStaticMethodID(cls, method_name, "()Ljava/lang/String;");
|
|
EXPECT_NE(method, nullptr);
|
|
return ReadJavaString(
|
|
env, static_cast<jstring>(env->CallStaticObjectMethod(cls, method)));
|
|
};
|
|
EXPECT_EQ(ReadIdentityMethod("getUsername"), identity.username);
|
|
EXPECT_EQ(ReadIdentityMethod("getDisplayName"), identity.display_name);
|
|
EXPECT_EQ(ReadIdentityMethod("getAlternateName"), identity.display_name);
|
|
EXPECT_EQ(ReadIdentityMethod("getLastLoggedInUser"), identity.username);
|
|
EXPECT_EQ(ReadIdentityMethod("getLastLoggedInUserId"), "123456789");
|
|
EXPECT_EQ(ReadIdentityMethod("getTheme"), "Light");
|
|
|
|
jclass system_theme =
|
|
env->FindClass("com/roblox/universalapp/systemtheme/SystemThemeProtocol");
|
|
ASSERT_NE(system_theme, nullptr);
|
|
jmethodID is_available =
|
|
env->GetStaticMethodID(system_theme, "isSystemThemeAvailable", "()Z");
|
|
jmethodID get_system_theme =
|
|
env->GetStaticMethodID(system_theme, "getSystemTheme", "()I");
|
|
EXPECT_EQ(env->CallStaticBooleanMethod(system_theme, is_available), JNI_TRUE);
|
|
EXPECT_EQ(env->CallStaticIntMethod(system_theme, get_system_theme), 3);
|
|
}
|
|
|
|
TEST_F(JniVmTest, RobloxAuthIdentitySnapshotIsIndependentAndClearable) {
|
|
RobloxAuthIdentity source;
|
|
source.user_id = 42;
|
|
source.username = "OriginalUser";
|
|
source.display_name = "Original Display";
|
|
vm_->SetRobloxAuthIdentity(source);
|
|
|
|
source.username = "ChangedAfterSet";
|
|
RobloxAuthIdentity snapshot = vm_->GetRobloxAuthIdentitySnapshot();
|
|
EXPECT_EQ(snapshot.user_id, 42);
|
|
EXPECT_EQ(snapshot.username, "OriginalUser");
|
|
EXPECT_EQ(snapshot.display_name, "Original Display");
|
|
|
|
snapshot.display_name = "ChangedSnapshot";
|
|
EXPECT_EQ(vm_->GetRobloxAuthIdentitySnapshot().display_name,
|
|
"Original Display");
|
|
|
|
vm_->ClearRobloxAuthIdentity();
|
|
const RobloxAuthIdentity cleared = vm_->GetRobloxAuthIdentitySnapshot();
|
|
EXPECT_EQ(cleared.user_id, -1);
|
|
EXPECT_TRUE(cleared.username.empty());
|
|
EXPECT_TRUE(cleared.display_name.empty());
|
|
}
|
|
|
|
TEST_F(JniVmTest, InvalidInjectedIdentityNormalizesToUnresolved) {
|
|
RobloxAuthIdentity identity;
|
|
identity.user_id = 0;
|
|
identity.username = "MustNotLeak";
|
|
identity.display_name = "Must Not Leak";
|
|
|
|
vm_->SetRobloxAuthIdentity(identity);
|
|
const RobloxAuthIdentity snapshot = vm_->GetRobloxAuthIdentitySnapshot();
|
|
|
|
EXPECT_EQ(snapshot.user_id, -1);
|
|
EXPECT_TRUE(snapshot.username.empty());
|
|
EXPECT_TRUE(snapshot.display_name.empty());
|
|
}
|
|
|
|
TEST_F(JniVmTest, NativeUserJavaInterfaceSupportsExplicitResearchFallback) {
|
|
setenv("MOCKTAIL_ROBLOX_USER_ID", "123456789", 1);
|
|
setenv("MOCKTAIL_ROBLOX_USERNAME", "ResearchUser", 1);
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass cls =
|
|
env->FindClass("com/roblox/engine/jni/user/NativeUserJavaInterface");
|
|
ASSERT_NE(cls, nullptr);
|
|
|
|
jmethodID method = env->GetStaticMethodID(cls, "getUserId", "()J");
|
|
ASSERT_NE(method, nullptr);
|
|
|
|
EXPECT_EQ(env->CallStaticLongMethod(cls, method), 123456789);
|
|
|
|
jmethodID get_username =
|
|
env->GetStaticMethodID(cls, "getUsername", "()Ljava/lang/String;");
|
|
ASSERT_NE(get_username, nullptr);
|
|
EXPECT_EQ(
|
|
ReadJavaString(env, static_cast<jstring>(
|
|
env->CallStaticObjectMethod(cls, get_username))),
|
|
"ResearchUser");
|
|
}
|
|
|
|
TEST_F(JniVmTest, CookieAloneDoesNotPopulateOrAuthenticateIdentity) {
|
|
setenv("MOCKTAIL_ROBLOX_COOKIES", ".ROBLOSECURITY=test-cookie-value", 1);
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass cls =
|
|
env->FindClass("com/roblox/engine/jni/user/NativeUserJavaInterface");
|
|
ASSERT_NE(cls, nullptr);
|
|
|
|
jmethodID method = env->GetStaticMethodID(cls, "getUserId", "()J");
|
|
ASSERT_NE(method, nullptr);
|
|
|
|
EXPECT_EQ(env->CallStaticLongMethod(cls, method), -1);
|
|
|
|
for (const char *method_name : {"getUsername", "getDisplayName",
|
|
"getAlternateName", "getLastLoggedInUser"}) {
|
|
SCOPED_TRACE(method_name);
|
|
jmethodID string_method =
|
|
env->GetStaticMethodID(cls, method_name, "()Ljava/lang/String;");
|
|
ASSERT_NE(string_method, nullptr);
|
|
EXPECT_TRUE(
|
|
ReadJavaString(env, static_cast<jstring>(env->CallStaticObjectMethod(
|
|
cls, string_method)))
|
|
.empty());
|
|
}
|
|
|
|
jmethodID last_user_id = env->GetStaticMethodID(cls, "getLastLoggedInUserId",
|
|
"()Ljava/lang/String;");
|
|
ASSERT_NE(last_user_id, nullptr);
|
|
EXPECT_EQ(
|
|
ReadJavaString(env, static_cast<jstring>(
|
|
env->CallStaticObjectMethod(cls, last_user_id))),
|
|
"-1");
|
|
|
|
const RobloxAuthIdentity snapshot = vm_->GetRobloxAuthIdentitySnapshot();
|
|
EXPECT_EQ(snapshot.user_id, -1);
|
|
EXPECT_TRUE(snapshot.username.empty());
|
|
EXPECT_TRUE(snapshot.display_name.empty());
|
|
}
|
|
|
|
TEST_F(JniVmTest, VideoCodecsReturnsEmptyObjectArray) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass cls =
|
|
env->FindClass("com/roblox/engine/jni/video/MediaCodecInfoUtils");
|
|
ASSERT_NE(cls, nullptr);
|
|
|
|
jmethodID method = env->GetStaticMethodID(
|
|
cls, "getVideoCodecs",
|
|
"()[Lcom/roblox/engine/jni/video/VideoCodecCapability;");
|
|
ASSERT_NE(method, nullptr);
|
|
|
|
auto result =
|
|
static_cast<jobjectArray>(env->CallStaticObjectMethod(cls, method));
|
|
ASSERT_NE(result, nullptr);
|
|
EXPECT_EQ(env->GetArrayLength(result), 0);
|
|
}
|
|
|
|
TEST_F(JniVmTest, FloatArrayElementsAreMutable) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jfloatArray array = env->NewFloatArray(2);
|
|
ASSERT_NE(array, nullptr);
|
|
|
|
const jfloat values[] = {60.0f, 120.0f};
|
|
env->SetFloatArrayRegion(array, 0, 2, values);
|
|
|
|
EXPECT_EQ(env->GetArrayLength(array), 2);
|
|
jboolean is_copy = JNI_TRUE;
|
|
jfloat *elements = env->GetFloatArrayElements(array, &is_copy);
|
|
ASSERT_NE(elements, nullptr);
|
|
EXPECT_EQ(is_copy, JNI_FALSE);
|
|
EXPECT_FLOAT_EQ(elements[0], 60.0f);
|
|
EXPECT_FLOAT_EQ(elements[1], 120.0f);
|
|
env->ReleaseFloatArrayElements(array, elements, 0);
|
|
}
|
|
|
|
TEST_F(JniVmTest, ObjectArrayInitializerSurvivesLocalDeletionAndSlotReuse) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass string_class = env->FindClass("java/lang/String");
|
|
jstring initial = env->NewStringUTF("original initializer");
|
|
ASSERT_NE(initial, nullptr);
|
|
jobjectArray array = env->NewObjectArray(1, string_class, initial);
|
|
ASSERT_NE(array, nullptr);
|
|
env->DeleteLocalRef(initial);
|
|
|
|
jstring replacement = env->NewStringUTF("new allocation");
|
|
ASSERT_NE(replacement, nullptr);
|
|
auto element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
|
|
ASSERT_NE(element, nullptr);
|
|
EXPECT_NE(element, replacement);
|
|
EXPECT_EQ(ReadJavaString(env, element), "original initializer");
|
|
env->DeleteLocalRef(element);
|
|
env->DeleteLocalRef(array);
|
|
env->DeleteLocalRef(replacement);
|
|
env->DeleteLocalRef(string_class);
|
|
}
|
|
|
|
TEST_F(JniVmTest, ObjectArrayDeletionReleasesSetElementSlot) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass string_class = env->FindClass("java/lang/String");
|
|
jobjectArray array = env->NewObjectArray(1, string_class, nullptr);
|
|
ASSERT_NE(array, nullptr);
|
|
jstring element = env->NewStringUTF("held by array");
|
|
ASSERT_NE(element, nullptr);
|
|
env->SetObjectArrayElement(array, 0, element);
|
|
env->DeleteLocalRef(element);
|
|
env->DeleteLocalRef(array);
|
|
|
|
// Only compare the saved handle value; never call JNI with a deleted ref.
|
|
jstring replacement = env->NewStringUTF("reuses released slot");
|
|
ASSERT_NE(replacement, nullptr);
|
|
EXPECT_EQ(replacement, element);
|
|
EXPECT_EQ(ReadJavaString(env, replacement), "reuses released slot");
|
|
env->DeleteLocalRef(replacement);
|
|
env->DeleteLocalRef(string_class);
|
|
}
|
|
|
|
TEST_F(JniVmTest, ObjectArrayInitializerSurvivesLocalFramePop) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass string_class = env->FindClass("java/lang/String");
|
|
ASSERT_EQ(env->PushLocalFrame(4), JNI_OK);
|
|
jstring initial = env->NewStringUTF("frame initializer");
|
|
ASSERT_NE(initial, nullptr);
|
|
jobjectArray array = env->NewObjectArray(1, string_class, initial);
|
|
ASSERT_NE(array, nullptr);
|
|
array = static_cast<jobjectArray>(env->PopLocalFrame(array));
|
|
ASSERT_NE(array, nullptr);
|
|
|
|
jstring replacement = env->NewStringUTF("after frame pop");
|
|
ASSERT_NE(replacement, nullptr);
|
|
auto element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
|
|
ASSERT_NE(element, nullptr);
|
|
EXPECT_NE(element, replacement);
|
|
EXPECT_EQ(ReadJavaString(env, element), "frame initializer");
|
|
env->DeleteLocalRef(element);
|
|
env->DeleteLocalRef(array);
|
|
jstring recycled = env->NewStringUTF("released frame initializer");
|
|
ASSERT_NE(recycled, nullptr);
|
|
EXPECT_EQ(recycled, initial);
|
|
env->DeleteLocalRef(recycled);
|
|
env->DeleteLocalRef(replacement);
|
|
env->DeleteLocalRef(string_class);
|
|
}
|
|
|
|
TEST_F(JniVmTest, ObjectArrayRepeatedInitializerOwnsEveryPosition) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass string_class = env->FindClass("java/lang/String");
|
|
jstring initial = env->NewStringUTF("three retained positions");
|
|
ASSERT_NE(initial, nullptr);
|
|
jobjectArray array = env->NewObjectArray(3, string_class, initial);
|
|
ASSERT_NE(array, nullptr);
|
|
env->DeleteLocalRef(initial);
|
|
env->SetObjectArrayElement(array, 0, nullptr);
|
|
env->SetObjectArrayElement(array, 1, nullptr);
|
|
|
|
jstring replacement = env->NewStringUTF("last position still owns original");
|
|
ASSERT_NE(replacement, nullptr);
|
|
auto remaining = static_cast<jstring>(env->GetObjectArrayElement(array, 2));
|
|
ASSERT_NE(remaining, nullptr);
|
|
EXPECT_NE(remaining, replacement);
|
|
EXPECT_EQ(ReadJavaString(env, remaining), "three retained positions");
|
|
env->DeleteLocalRef(remaining);
|
|
env->SetObjectArrayElement(array, 2, nullptr);
|
|
jstring recycled = env->NewStringUTF("last position released");
|
|
ASSERT_NE(recycled, nullptr);
|
|
EXPECT_EQ(recycled, initial);
|
|
env->DeleteLocalRef(array);
|
|
EXPECT_EQ(ReadJavaString(env, recycled), "last position released");
|
|
env->DeleteLocalRef(recycled);
|
|
env->DeleteLocalRef(replacement);
|
|
env->DeleteLocalRef(string_class);
|
|
}
|
|
|
|
TEST_F(JniVmTest, ObjectArrayReplacementReleasesSameDifferentAndNullElements) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass string_class = env->FindClass("java/lang/String");
|
|
jobjectArray array = env->NewObjectArray(1, string_class, nullptr);
|
|
ASSERT_NE(array, nullptr);
|
|
jstring first = env->NewStringUTF("first");
|
|
jstring second = env->NewStringUTF("second");
|
|
ASSERT_NE(first, nullptr);
|
|
ASSERT_NE(second, nullptr);
|
|
env->SetObjectArrayElement(array, 0, first);
|
|
env->SetObjectArrayElement(array, 0, first);
|
|
env->DeleteLocalRef(first);
|
|
auto element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
|
|
ASSERT_NE(element, nullptr);
|
|
EXPECT_EQ(ReadJavaString(env, element), "first");
|
|
env->DeleteLocalRef(element);
|
|
|
|
env->SetObjectArrayElement(array, 0, second);
|
|
jstring first_reuse = env->NewStringUTF("first slot reused");
|
|
ASSERT_NE(first_reuse, nullptr);
|
|
EXPECT_EQ(first_reuse, first);
|
|
env->DeleteLocalRef(second);
|
|
element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
|
|
ASSERT_NE(element, nullptr);
|
|
EXPECT_EQ(ReadJavaString(env, element), "second");
|
|
env->DeleteLocalRef(element);
|
|
env->SetObjectArrayElement(array, 0, nullptr);
|
|
EXPECT_EQ(env->GetObjectArrayElement(array, 0), nullptr);
|
|
jstring second_reuse = env->NewStringUTF("second slot reused");
|
|
ASSERT_NE(second_reuse, nullptr);
|
|
EXPECT_EQ(second_reuse, second);
|
|
env->DeleteLocalRef(array);
|
|
env->DeleteLocalRef(first_reuse);
|
|
env->DeleteLocalRef(second_reuse);
|
|
env->DeleteLocalRef(string_class);
|
|
}
|
|
|
|
TEST_F(JniVmTest, ObjectArrayDeletionRecursivelyReleasesNestedElements) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass string_class = env->FindClass("java/lang/String");
|
|
jclass object_class = env->FindClass("java/lang/Object");
|
|
jstring initial = env->NewStringUTF("nested string");
|
|
ASSERT_NE(initial, nullptr);
|
|
jobjectArray inner = env->NewObjectArray(1, string_class, initial);
|
|
ASSERT_NE(inner, nullptr);
|
|
jobjectArray outer = env->NewObjectArray(1, object_class, inner);
|
|
ASSERT_NE(outer, nullptr);
|
|
env->DeleteLocalRef(initial);
|
|
env->DeleteLocalRef(inner);
|
|
|
|
auto retained_inner =
|
|
static_cast<jobjectArray>(env->GetObjectArrayElement(outer, 0));
|
|
ASSERT_NE(retained_inner, nullptr);
|
|
auto element =
|
|
static_cast<jstring>(env->GetObjectArrayElement(retained_inner, 0));
|
|
ASSERT_NE(element, nullptr);
|
|
EXPECT_EQ(ReadJavaString(env, element), "nested string");
|
|
env->DeleteLocalRef(element);
|
|
env->DeleteLocalRef(retained_inner);
|
|
env->DeleteLocalRef(outer);
|
|
jstring recycled = env->NewStringUTF("recursive release completed");
|
|
ASSERT_NE(recycled, nullptr);
|
|
EXPECT_EQ(recycled, initial);
|
|
env->DeleteLocalRef(recycled);
|
|
env->DeleteLocalRef(string_class);
|
|
env->DeleteLocalRef(object_class);
|
|
}
|
|
|
|
TEST_F(JniVmTest, ObjectArrayNullAndEmptyArraysDoNotRetainInitializer) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass string_class = env->FindClass("java/lang/String");
|
|
jstring initial = env->NewStringUTF("not stored in an empty array");
|
|
ASSERT_NE(initial, nullptr);
|
|
jobjectArray empty = env->NewObjectArray(0, string_class, initial);
|
|
jobjectArray nulls = env->NewObjectArray(3, string_class, nullptr);
|
|
ASSERT_NE(empty, nullptr);
|
|
ASSERT_NE(nulls, nullptr);
|
|
EXPECT_EQ(env->GetArrayLength(empty), 0);
|
|
EXPECT_EQ(env->GetArrayLength(nulls), 3);
|
|
for (jsize index = 0; index < 3; ++index) {
|
|
EXPECT_EQ(env->GetObjectArrayElement(nulls, index), nullptr);
|
|
env->SetObjectArrayElement(nulls, index, nullptr);
|
|
}
|
|
env->DeleteLocalRef(initial);
|
|
jstring recycled = env->NewStringUTF("empty initializer was released");
|
|
ASSERT_NE(recycled, nullptr);
|
|
EXPECT_EQ(recycled, initial);
|
|
env->DeleteLocalRef(empty);
|
|
env->DeleteLocalRef(nulls);
|
|
EXPECT_EQ(ReadJavaString(env, recycled), "empty initializer was released");
|
|
env->DeleteLocalRef(recycled);
|
|
env->DeleteLocalRef(string_class);
|
|
}
|
|
|
|
TEST_F(JniVmTest, ObjectArraySelfReferenceCanBeBrokenWithValidExternalReference) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass object_class = env->FindClass("java/lang/Object");
|
|
jobjectArray array = env->NewObjectArray(2, object_class, nullptr);
|
|
ASSERT_NE(array, nullptr);
|
|
jstring initial = env->NewStringUTF("owned beside self reference");
|
|
ASSERT_NE(initial, nullptr);
|
|
env->SetObjectArrayElement(array, 0, array);
|
|
env->SetObjectArrayElement(array, 1, initial);
|
|
auto external = static_cast<jobjectArray>(env->NewGlobalRef(array));
|
|
ASSERT_NE(external, nullptr);
|
|
env->DeleteLocalRef(array);
|
|
env->DeleteLocalRef(initial);
|
|
|
|
jobject self = env->GetObjectArrayElement(external, 0);
|
|
ASSERT_NE(self, nullptr);
|
|
EXPECT_EQ(env->IsSameObject(self, external), JNI_TRUE);
|
|
env->DeleteLocalRef(self);
|
|
env->SetObjectArrayElement(external, 0, nullptr);
|
|
auto element = static_cast<jstring>(env->GetObjectArrayElement(external, 1));
|
|
ASSERT_NE(element, nullptr);
|
|
EXPECT_EQ(ReadJavaString(env, element), "owned beside self reference");
|
|
env->DeleteLocalRef(element);
|
|
env->DeleteGlobalRef(external);
|
|
jstring recycled = env->NewStringUTF("broken cycle released its element");
|
|
ASSERT_NE(recycled, nullptr);
|
|
EXPECT_EQ(recycled, initial);
|
|
env->DeleteLocalRef(recycled);
|
|
env->DeleteLocalRef(object_class);
|
|
}
|
|
|
|
TEST_F(JniVmTest, ObjectArraySurvivesTemporaryVmTeardownAndEnvironmentReattachment) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass string_class = env->FindClass("java/lang/String");
|
|
jstring initial = env->NewStringUTF("surviving VM array");
|
|
ASSERT_NE(initial, nullptr);
|
|
jobjectArray array = env->NewObjectArray(1, string_class, initial);
|
|
ASSERT_NE(array, nullptr);
|
|
env->DeleteLocalRef(initial);
|
|
{
|
|
VM temporary;
|
|
ASSERT_NE(temporary.GetJNIEnv(), nullptr);
|
|
}
|
|
// Temporary VM teardown invalidates the thread's environment; reacquire it
|
|
// before invoking JNI on references owned by the surviving VM.
|
|
env = vm_->GetJNIEnv();
|
|
ASSERT_NE(env, nullptr);
|
|
auto element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
|
|
ASSERT_NE(element, nullptr);
|
|
EXPECT_EQ(ReadJavaString(env, element), "surviving VM array");
|
|
env->DeleteLocalRef(element);
|
|
env->DeleteLocalRef(array);
|
|
jstring recycled = env->NewStringUTF("surviving array released");
|
|
ASSERT_NE(recycled, nullptr);
|
|
EXPECT_EQ(recycled, initial);
|
|
env->DeleteLocalRef(recycled);
|
|
env->DeleteLocalRef(string_class);
|
|
}
|
|
|
|
TEST_F(JniVmTest, ObjectArrayLiveAtProcessExitDoesNotReenterDestroyedTables) {
|
|
ASSERT_EXIT(
|
|
{
|
|
VM temporary;
|
|
JNIEnv *env = temporary.GetJNIEnv();
|
|
if (env == nullptr) std::_Exit(10);
|
|
jclass string_class = env->FindClass("java/lang/String");
|
|
jclass object_class = env->FindClass("java/lang/Object");
|
|
jstring initial = env->NewStringUTF("live at shutdown");
|
|
if (initial == nullptr) std::_Exit(11);
|
|
jobjectArray inner = env->NewObjectArray(3, string_class, initial);
|
|
if (inner == nullptr) std::_Exit(12);
|
|
jobjectArray outer = env->NewObjectArray(1, object_class, inner);
|
|
if (outer == nullptr) std::_Exit(13);
|
|
env->DeleteLocalRef(initial);
|
|
env->DeleteLocalRef(inner);
|
|
// Run global destructors with outer/inner/string still alive. Release
|
|
// of elements belongs to explicit reference release, not ~PseudoArray.
|
|
std::exit(0);
|
|
},
|
|
::testing::ExitedWithCode(0), "");
|
|
}
|
|
|
|
constexpr int kMoreThanHandleCapacity = 150000;
|
|
|
|
TEST_F(JniVmTest, RepeatedFindClassDoesNotExhaustHandles) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
for (int i = 0; i < kMoreThanHandleCapacity; ++i) {
|
|
jclass cls = env->FindClass("android/view/Surface");
|
|
ASSERT_NE(cls, nullptr) << "iteration " << i;
|
|
env->DeleteLocalRef(cls);
|
|
}
|
|
jstring text = env->NewStringUTF("still allocatable");
|
|
ASSERT_NE(text, nullptr);
|
|
env->DeleteLocalRef(text);
|
|
}
|
|
|
|
TEST_F(JniVmTest, RepeatedGetObjectClassDoesNotExhaustHandles) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass surface_class = env->FindClass("android/view/Surface");
|
|
jobject surface = env->AllocObject(surface_class);
|
|
ASSERT_NE(surface, nullptr);
|
|
for (int i = 0; i < kMoreThanHandleCapacity; ++i) {
|
|
jclass cls = env->GetObjectClass(surface);
|
|
ASSERT_NE(cls, nullptr) << "iteration " << i;
|
|
env->DeleteLocalRef(cls);
|
|
}
|
|
env->DeleteLocalRef(surface);
|
|
}
|
|
|
|
TEST_F(JniVmTest, ClassHandlesStayValidAfterDeleteLocalRef) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass first = env->FindClass("android/view/Surface");
|
|
env->DeleteLocalRef(first);
|
|
jclass second = env->FindClass("android/view/Surface");
|
|
ASSERT_NE(second, nullptr);
|
|
EXPECT_NE(env->GetMethodID(second, "isValid", "()Z"), nullptr);
|
|
jobject surface = env->AllocObject(second);
|
|
ASSERT_NE(surface, nullptr);
|
|
EXPECT_TRUE(env->IsInstanceOf(surface, second));
|
|
env->DeleteLocalRef(surface);
|
|
}
|
|
|
|
TEST_F(JniVmTest, LocalRefsDeletedInCreationOrderFreeTheirSlots) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
const auto start = std::chrono::steady_clock::now();
|
|
for (int round = 0; round < 3; ++round) {
|
|
std::vector<jstring> strings;
|
|
strings.reserve(60000);
|
|
for (int i = 0; i < 60000; ++i) {
|
|
jstring text = env->NewStringUTF("x");
|
|
ASSERT_NE(text, nullptr) << "round " << round << " string " << i;
|
|
strings.push_back(text);
|
|
}
|
|
for (jstring text : strings) {
|
|
env->DeleteLocalRef(text);
|
|
}
|
|
}
|
|
EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds(5));
|
|
}
|
|
|
|
TEST_F(JniVmTest, FullHandleTableReportsLiveObjectsByClassOnce) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
std::vector<jstring> strings;
|
|
::testing::internal::CaptureStderr();
|
|
for (int i = 0; i < kMoreThanHandleCapacity; ++i) {
|
|
jstring text = env->NewStringUTF("fill");
|
|
if (text == nullptr) {
|
|
break;
|
|
}
|
|
strings.push_back(text);
|
|
}
|
|
EXPECT_EQ(env->NewStringUTF("still full"), nullptr);
|
|
const std::string output = ::testing::internal::GetCapturedStderr();
|
|
for (jstring text : strings) {
|
|
env->DeleteLocalRef(text);
|
|
}
|
|
EXPECT_LT(strings.size(), static_cast<std::size_t>(kMoreThanHandleCapacity));
|
|
const std::size_t report = output.find("[JNI] handle table full");
|
|
ASSERT_NE(report, std::string::npos) << output;
|
|
EXPECT_NE(output.find("java/lang/String", report), std::string::npos)
|
|
<< output;
|
|
EXPECT_EQ(output.find("[JNI] handle table full", report + 1),
|
|
std::string::npos);
|
|
EXPECT_NE(env->NewStringUTF("free again"), nullptr);
|
|
}
|
|
|
|
TEST_F(JniVmTest, PopLocalFrameFreesLocalsCreatedInsideIt) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
for (int i = 0; i < kMoreThanHandleCapacity; ++i) {
|
|
ASSERT_EQ(env->PushLocalFrame(4), JNI_OK);
|
|
ASSERT_NE(env->NewStringUTF("frame local"), nullptr) << "iteration " << i;
|
|
env->PopLocalFrame(nullptr);
|
|
}
|
|
}
|
|
|
|
TEST_F(JniVmTest, ObjectMethodCanReturnMockClassLoader) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass cls = env->FindClass("java/lang/Object");
|
|
ASSERT_NE(cls, nullptr);
|
|
jobject obj = env->AllocObject(cls);
|
|
ASSERT_NE(obj, nullptr);
|
|
|
|
jmethodID method =
|
|
env->GetMethodID(cls, "getClassLoader", "()Ljava/lang/ClassLoader;");
|
|
ASSERT_NE(method, nullptr);
|
|
|
|
jobject loader = env->CallObjectMethod(obj, method);
|
|
EXPECT_NE(loader, nullptr);
|
|
}
|
|
|
|
TEST_F(JniVmTest, AndroidContextReturnsCoreObjectGraph) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass context_class = env->FindClass("android/content/Context");
|
|
ASSERT_NE(context_class, nullptr);
|
|
jobject context = env->AllocObject(context_class);
|
|
ASSERT_NE(context, nullptr);
|
|
|
|
jmethodID get_assets = env->GetMethodID(
|
|
context_class, "getAssets", "()Landroid/content/res/AssetManager;");
|
|
jmethodID get_resources = env->GetMethodID(
|
|
context_class, "getResources", "()Landroid/content/res/Resources;");
|
|
jmethodID get_package_manager =
|
|
env->GetMethodID(context_class, "getPackageManager",
|
|
"()Landroid/content/pm/PackageManager;");
|
|
jmethodID get_application_info =
|
|
env->GetMethodID(context_class, "getApplicationInfo",
|
|
"()Landroid/content/pm/ApplicationInfo;");
|
|
|
|
EXPECT_NE(env->CallObjectMethod(context, get_assets), nullptr);
|
|
EXPECT_NE(env->CallObjectMethod(context, get_resources), nullptr);
|
|
EXPECT_NE(env->CallObjectMethod(context, get_package_manager), nullptr);
|
|
|
|
jobject app_info = env->CallObjectMethod(context, get_application_info);
|
|
ASSERT_NE(app_info, nullptr);
|
|
jclass app_info_class = env->FindClass("android/content/pm/ApplicationInfo");
|
|
jfieldID source_dir =
|
|
env->GetFieldID(app_info_class, "sourceDir", "Ljava/lang/String;");
|
|
auto source_dir_value =
|
|
static_cast<jstring>(env->GetObjectField(app_info, source_dir));
|
|
ASSERT_NE(source_dir_value, nullptr);
|
|
EXPECT_STREQ(env->GetStringUTFChars(source_dir_value, nullptr),
|
|
"rbx_bin/sober_apk/base.apk");
|
|
}
|
|
|
|
TEST_F(JniVmTest, AndroidContextReturnsWindowServiceAndSharedPreferences) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass context_class = env->FindClass("android/content/Context");
|
|
ASSERT_NE(context_class, nullptr);
|
|
jobject context = env->AllocObject(context_class);
|
|
|
|
jmethodID get_system_service =
|
|
env->GetMethodID(context_class, "getSystemService",
|
|
"(Ljava/lang/String;)Ljava/lang/Object;");
|
|
jobject window_manager = env->CallObjectMethod(context, get_system_service,
|
|
env->NewStringUTF("window"));
|
|
ASSERT_NE(window_manager, nullptr);
|
|
|
|
jclass window_manager_class = env->GetObjectClass(window_manager);
|
|
jmethodID get_default_display = env->GetMethodID(
|
|
window_manager_class, "getDefaultDisplay", "()Landroid/view/Display;");
|
|
EXPECT_NE(env->CallObjectMethod(window_manager, get_default_display),
|
|
nullptr);
|
|
|
|
jmethodID get_shared_preferences =
|
|
env->GetMethodID(context_class, "getSharedPreferences",
|
|
"(Ljava/lang/String;I)"
|
|
"Landroid/content/SharedPreferences;");
|
|
jobject preferences = env->CallObjectMethod(context, get_shared_preferences,
|
|
env->NewStringUTF("mocktail"), 0);
|
|
ASSERT_NE(preferences, nullptr);
|
|
|
|
jclass preferences_class = env->GetObjectClass(preferences);
|
|
jmethodID get_string =
|
|
env->GetMethodID(preferences_class, "getString",
|
|
"(Ljava/lang/String;Ljava/lang/String;)"
|
|
"Ljava/lang/String;");
|
|
auto value = static_cast<jstring>(env->CallObjectMethod(
|
|
preferences, get_string, env->NewStringUTF("missing"),
|
|
env->NewStringUTF("fallback")));
|
|
ASSERT_NE(value, nullptr);
|
|
EXPECT_STREQ(env->GetStringUTFChars(value, nullptr), "fallback");
|
|
}
|
|
|
|
TEST_F(JniVmTest, NewStringUtfIsObjectFieldCompatible) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass params_class =
|
|
env->FindClass("com/roblox/client/startup/AppBridgeInitParams");
|
|
ASSERT_NE(params_class, nullptr);
|
|
jobject params = env->AllocObject(params_class);
|
|
ASSERT_NE(params, nullptr);
|
|
|
|
jstring asset_path = env->NewStringUTF("/tmp/mocktail/assets/content");
|
|
ASSERT_NE(asset_path, nullptr);
|
|
|
|
jfieldID field =
|
|
env->GetFieldID(params_class, "assetPath", "Ljava/lang/String;");
|
|
env->SetObjectField(params, field, asset_path);
|
|
|
|
auto stored = static_cast<jstring>(env->GetObjectField(params, field));
|
|
ASSERT_EQ(stored, asset_path);
|
|
EXPECT_EQ(env->GetStringUTFLength(stored), 28);
|
|
EXPECT_STREQ(env->GetStringUTFChars(stored, nullptr),
|
|
"/tmp/mocktail/assets/content");
|
|
|
|
char region[9] = {};
|
|
env->GetStringUTFRegion(stored, 5, 8, region);
|
|
EXPECT_STREQ(region, "mocktail");
|
|
|
|
const jchar *chars = env->GetStringChars(stored, nullptr);
|
|
ASSERT_NE(chars, nullptr);
|
|
EXPECT_EQ(chars[0], static_cast<jchar>('/'));
|
|
}
|
|
|
|
TEST_F(JniVmTest, JniStringsPreserveUtf16SurrogatePairsAndModifiedUtf8) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
const jchar unicode[] = {0xd83d, 0xde42, 0x00e9};
|
|
jstring value = env->NewString(unicode, 3);
|
|
ASSERT_NE(value, nullptr);
|
|
EXPECT_EQ(env->GetStringLength(value), 3);
|
|
|
|
jboolean utf16_is_copy = JNI_TRUE;
|
|
const jchar *utf16 = env->GetStringChars(value, &utf16_is_copy);
|
|
ASSERT_NE(utf16, nullptr);
|
|
EXPECT_EQ(utf16_is_copy, JNI_FALSE);
|
|
EXPECT_EQ(utf16[0], 0xd83d);
|
|
EXPECT_EQ(utf16[1], 0xde42);
|
|
EXPECT_EQ(utf16[2], 0x00e9);
|
|
env->ReleaseStringChars(value, utf16);
|
|
|
|
const std::string expected_modified_utf8("\xed\xa0\xbd\xed\xb9\x82\xc3\xa9",
|
|
8);
|
|
EXPECT_EQ(env->GetStringUTFLength(value), 8);
|
|
jboolean utf8_is_copy = JNI_TRUE;
|
|
const char *modified_utf8 = env->GetStringUTFChars(value, &utf8_is_copy);
|
|
ASSERT_NE(modified_utf8, nullptr);
|
|
EXPECT_EQ(utf8_is_copy, JNI_FALSE);
|
|
EXPECT_EQ(std::string(modified_utf8, 8), expected_modified_utf8);
|
|
env->ReleaseStringUTFChars(value, modified_utf8);
|
|
|
|
char region[9] = {};
|
|
env->GetStringUTFRegion(value, 0, 3, region);
|
|
EXPECT_EQ(std::string(region, 8), expected_modified_utf8);
|
|
jchar utf16_region[3] = {};
|
|
env->GetStringRegion(value, 0, 3, utf16_region);
|
|
EXPECT_EQ(std::vector<jchar>(utf16_region, utf16_region + 3),
|
|
std::vector<jchar>(unicode, unicode + 3));
|
|
}
|
|
|
|
TEST_F(JniVmTest, NewStringUtfAcceptsUnicodeAndUsesJniModifiedUtf8) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
const char standard_utf8[] = "\xf0\x9f\x99\x82\xc3\xa9";
|
|
jstring value = env->NewStringUTF(standard_utf8);
|
|
ASSERT_NE(value, nullptr);
|
|
EXPECT_EQ(env->GetStringLength(value), 3);
|
|
EXPECT_EQ(env->GetStringUTFLength(value), 8);
|
|
const char *modified_utf8 = env->GetStringUTFChars(value, nullptr);
|
|
ASSERT_NE(modified_utf8, nullptr);
|
|
EXPECT_EQ(std::string(modified_utf8, 8),
|
|
std::string("\xed\xa0\xbd\xed\xb9\x82\xc3\xa9", 8));
|
|
env->ReleaseStringUTFChars(value, modified_utf8);
|
|
}
|
|
|
|
TEST_F(JniVmTest, JniStringsPreserveEmbeddedNullAsModifiedUtf8) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
const jchar unicode[] = {'A', 0, 'B'};
|
|
jstring value = env->NewString(unicode, 3);
|
|
ASSERT_NE(value, nullptr);
|
|
EXPECT_EQ(env->GetStringLength(value), 3);
|
|
EXPECT_EQ(env->GetStringUTFLength(value), 4);
|
|
const char *modified_utf8 = env->GetStringUTFChars(value, nullptr);
|
|
ASSERT_NE(modified_utf8, nullptr);
|
|
EXPECT_EQ(std::string(modified_utf8, 4), std::string("A\xc0\x80"
|
|
"B",
|
|
4));
|
|
const jchar *utf16 = env->GetStringChars(value, nullptr);
|
|
ASSERT_NE(utf16, nullptr);
|
|
EXPECT_EQ(utf16[0], 'A');
|
|
EXPECT_EQ(utf16[1], 0);
|
|
EXPECT_EQ(utf16[2], 'B');
|
|
}
|
|
|
|
TEST_F(JniVmTest, JniStringsPreserveUnpairedSurrogatesDeterministically) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
const jchar unicode[] = {0xd83d, 'x', 0xdc00};
|
|
jstring value = env->NewString(unicode, 3);
|
|
ASSERT_NE(value, nullptr);
|
|
EXPECT_EQ(env->GetStringLength(value), 3);
|
|
const jchar *utf16 = env->GetStringChars(value, nullptr);
|
|
ASSERT_NE(utf16, nullptr);
|
|
EXPECT_EQ(std::vector<jchar>(utf16, utf16 + 3),
|
|
std::vector<jchar>(unicode, unicode + 3));
|
|
EXPECT_EQ(env->GetStringUTFLength(value), 7);
|
|
const char *modified_utf8 = env->GetStringUTFChars(value, nullptr);
|
|
EXPECT_EQ(std::string(modified_utf8, 7),
|
|
std::string("\xed\xa0\xbdx\xed\xb0\x80", 7));
|
|
|
|
jstring from_modified = env->NewStringUTF("\xed\xa0\xbd");
|
|
EXPECT_EQ(env->GetStringLength(from_modified), 1);
|
|
const jchar *decoded = env->GetStringChars(from_modified, nullptr);
|
|
ASSERT_NE(decoded, nullptr);
|
|
EXPECT_EQ(decoded[0], 0xd83d);
|
|
}
|
|
|
|
TEST_F(JniVmTest, JavaStringGetBytesReturnsUtf8AcrossObjectCallForms) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
const jchar unicode[] = {'A', 0, 0x00e9, 0xd83d, 0xde42, 0xd83d, 'x', 0xdc00};
|
|
jstring value = env->NewString(unicode, 8);
|
|
ASSERT_NE(value, nullptr);
|
|
jclass string_class = env->GetObjectClass(value);
|
|
ASSERT_NE(string_class, nullptr);
|
|
jmethodID get_bytes =
|
|
env->GetMethodID(string_class, "getBytes", "(Ljava/lang/String;)[B");
|
|
ASSERT_NE(get_bytes, nullptr);
|
|
|
|
const std::vector<std::uint8_t> expected = {
|
|
0x41, 0x00, 0xc3, 0xa9, 0xf0, 0x9f, 0x99, 0x82, 0x3f, 0x78, 0x3f};
|
|
const auto read_bytes = [env](jbyteArray array) {
|
|
const jsize size = env->GetArrayLength(array);
|
|
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(size));
|
|
if (size > 0) {
|
|
env->GetByteArrayRegion(array, 0, size,
|
|
reinterpret_cast<jbyte *>(bytes.data()));
|
|
}
|
|
return bytes;
|
|
};
|
|
|
|
auto direct = static_cast<jbyteArray>(
|
|
env->CallObjectMethod(value, get_bytes, env->NewStringUTF("UTF-8")));
|
|
ASSERT_NE(direct, nullptr);
|
|
EXPECT_EQ(read_bytes(direct), expected);
|
|
jboolean is_copy = JNI_TRUE;
|
|
jbyte *direct_elements = env->GetByteArrayElements(direct, &is_copy);
|
|
ASSERT_NE(direct_elements, nullptr);
|
|
EXPECT_EQ(is_copy, JNI_FALSE);
|
|
EXPECT_EQ(
|
|
std::vector<std::uint8_t>(
|
|
reinterpret_cast<std::uint8_t *>(direct_elements),
|
|
reinterpret_cast<std::uint8_t *>(direct_elements) + expected.size()),
|
|
expected);
|
|
env->ReleaseByteArrayElements(direct, direct_elements, JNI_ABORT);
|
|
|
|
auto v_result = static_cast<jbyteArray>(CallObjectMethodVForTest(
|
|
env, value, get_bytes, env->NewStringUTF("utf8")));
|
|
ASSERT_NE(v_result, nullptr);
|
|
EXPECT_EQ(read_bytes(v_result), expected);
|
|
|
|
jvalue args[1] = {};
|
|
args[0].l = env->NewStringUTF("unicode-1-1-utf-8");
|
|
auto a_result =
|
|
static_cast<jbyteArray>(env->CallObjectMethodA(value, get_bytes, args));
|
|
ASSERT_NE(a_result, nullptr);
|
|
EXPECT_EQ(read_bytes(a_result), expected);
|
|
}
|
|
|
|
TEST_F(JniVmTest, JavaStringGetBytesRejectsUnsupportedOrMismatchedCalls) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jstring value = env->NewStringUTF("text");
|
|
jclass string_class = env->GetObjectClass(value);
|
|
jmethodID get_bytes =
|
|
env->GetMethodID(string_class, "getBytes", "(Ljava/lang/String;)[B");
|
|
|
|
jvalue unsupported[1] = {};
|
|
unsupported[0].l = env->NewStringUTF("ISO-8859-1");
|
|
EXPECT_EQ(env->CallObjectMethodA(value, get_bytes, unsupported), nullptr);
|
|
EXPECT_EQ(env->CallObjectMethodA(value, get_bytes, nullptr), nullptr);
|
|
|
|
jmethodID wrong_signature =
|
|
env->GetMethodID(string_class, "getBytes", "()[B");
|
|
EXPECT_EQ(env->CallObjectMethod(value, wrong_signature), nullptr);
|
|
|
|
jclass object_class = env->FindClass("java/lang/Object");
|
|
jobject object = env->AllocObject(object_class);
|
|
EXPECT_EQ(
|
|
env->CallObjectMethod(object, get_bytes, env->NewStringUTF("UTF-8")),
|
|
nullptr);
|
|
}
|
|
|
|
TEST_F(JniVmTest, RobloxActivityReturnsStableSurfaceGraph) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass activity_class = env->FindClass("com/roblox/client/RobloxActivity");
|
|
ASSERT_NE(activity_class, nullptr);
|
|
jobject activity = env->AllocObject(activity_class);
|
|
ASSERT_NE(activity, nullptr);
|
|
|
|
jmethodID get_window =
|
|
env->GetMethodID(activity_class, "getWindow", "()Landroid/view/Window;");
|
|
jobject window = env->CallObjectMethod(activity, get_window);
|
|
ASSERT_NE(window, nullptr);
|
|
|
|
jclass window_class = env->GetObjectClass(window);
|
|
jmethodID get_decor_view =
|
|
env->GetMethodID(window_class, "getDecorView", "()Landroid/view/View;");
|
|
jobject view = env->CallObjectMethod(window, get_decor_view);
|
|
ASSERT_NE(view, nullptr);
|
|
|
|
jclass view_class = env->GetObjectClass(view);
|
|
jmethodID get_holder = env->GetMethodID(view_class, "getHolder",
|
|
"()Landroid/view/SurfaceHolder;");
|
|
jobject holder = env->CallObjectMethod(view, get_holder);
|
|
ASSERT_NE(holder, nullptr);
|
|
|
|
jclass holder_class = env->GetObjectClass(holder);
|
|
jmethodID get_surface =
|
|
env->GetMethodID(holder_class, "getSurface", "()Landroid/view/Surface;");
|
|
jobject surface = env->CallObjectMethod(holder, get_surface);
|
|
ASSERT_NE(surface, nullptr);
|
|
|
|
jclass surface_class = env->GetObjectClass(surface);
|
|
jmethodID is_valid = env->GetMethodID(surface_class, "isValid", "()Z");
|
|
EXPECT_EQ(env->CallBooleanMethod(surface, is_valid), JNI_TRUE);
|
|
}
|
|
|
|
TEST_F(JniVmTest, NativeGlJavaInterfaceReturnsDeviceStaticParams) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass gl_class =
|
|
env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
|
|
ASSERT_NE(gl_class, nullptr);
|
|
|
|
jmethodID get_device_static_params = env->GetStaticMethodID(
|
|
gl_class, "getDeviceStaticParams",
|
|
"()Lcom/roblox/engine/jni/model/DeviceStaticParams;");
|
|
ASSERT_NE(get_device_static_params, nullptr);
|
|
|
|
jobject params =
|
|
env->CallStaticObjectMethod(gl_class, get_device_static_params);
|
|
ASSERT_NE(params, nullptr);
|
|
|
|
jclass params_class =
|
|
env->FindClass("com/roblox/engine/jni/model/DeviceStaticParams");
|
|
jfieldID manufacturer =
|
|
env->GetFieldID(params_class, "manufacturer", "Ljava/lang/String;");
|
|
auto value = static_cast<jstring>(env->GetObjectField(params, manufacturer));
|
|
ASSERT_NE(value, nullptr);
|
|
EXPECT_STREQ(env->GetStringUTFChars(value, nullptr), "Mocktail");
|
|
|
|
jfieldID os_version =
|
|
env->GetFieldID(params_class, "osVersion", "Ljava/lang/String;");
|
|
auto os_version_value =
|
|
static_cast<jstring>(env->GetObjectField(params, os_version));
|
|
ASSERT_NE(os_version_value, nullptr);
|
|
EXPECT_STREQ(env->GetStringUTFChars(os_version_value, nullptr), "Android 13");
|
|
|
|
jfieldID cpu64_bit = env->GetFieldID(params_class, "cpu64Bit", "Z");
|
|
EXPECT_EQ(env->GetBooleanField(params, cpu64_bit), JNI_TRUE);
|
|
}
|
|
|
|
TEST_F(JniVmTest, DeviceStaticParamsUseTypedPlatformIdentity) {
|
|
PlatformIdentity pixel;
|
|
pixel.touch_enabled = true;
|
|
pixel.mouse_enabled = false;
|
|
pixel.keyboard_enabled = false;
|
|
pixel.pc_hardware = false;
|
|
pixel.platform_name = "Android";
|
|
pixel.device_name = "Google Pixel 7";
|
|
pixel.manufacturer = "Google";
|
|
pixel.model = "Pixel 7";
|
|
pixel.brand = "google";
|
|
pixel.device_code = "panther";
|
|
pixel.device_sku = "panther";
|
|
pixel.soc_model = "Google Tensor G2";
|
|
vm_->SetPlatformIdentity(pixel);
|
|
|
|
JNIEnv* env = vm_->GetJNIEnv();
|
|
jclass gl_class =
|
|
env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
|
|
jmethodID get_device_static_params = env->GetStaticMethodID(
|
|
gl_class, "getDeviceStaticParams",
|
|
"()Lcom/roblox/engine/jni/model/DeviceStaticParams;");
|
|
jobject params =
|
|
env->CallStaticObjectMethod(gl_class, get_device_static_params);
|
|
ASSERT_NE(params, nullptr);
|
|
jclass params_class = env->GetObjectClass(params);
|
|
const auto read_string = [env, params, params_class](const char* name) {
|
|
jfieldID field = env->GetFieldID(params_class, name, "Ljava/lang/String;");
|
|
return ReadJavaString(
|
|
env, static_cast<jstring>(env->GetObjectField(params, field)));
|
|
};
|
|
|
|
EXPECT_EQ(read_string("deviceName"), "Google Pixel 7");
|
|
EXPECT_EQ(read_string("manufacturer"), "Google");
|
|
EXPECT_EQ(read_string("model"), "Pixel 7");
|
|
EXPECT_EQ(read_string("brand"), "google");
|
|
EXPECT_EQ(read_string("device"), "panther");
|
|
EXPECT_EQ(read_string("deviceSku"), "panther");
|
|
EXPECT_EQ(read_string("socModel"), "Google Tensor G2");
|
|
EXPECT_EQ(read_string("osVersion"), "Android 13");
|
|
|
|
jclass build_info = env->FindClass("org/webrtc/voiceengine/BuildInfo");
|
|
jmethodID get_device_model = env->GetStaticMethodID(
|
|
build_info, "getDeviceModel", "()Ljava/lang/String;");
|
|
EXPECT_EQ(
|
|
ReadJavaString(env, static_cast<jstring>(env->CallStaticObjectMethod(
|
|
build_info, get_device_model))),
|
|
"Pixel 7");
|
|
}
|
|
|
|
TEST_F(JniVmTest, NativeGlJavaInterfaceReturnsDefaultEngineCallback) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass gl_class =
|
|
env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
|
|
ASSERT_NE(gl_class, nullptr);
|
|
|
|
jmethodID get_implementation =
|
|
env->GetStaticMethodID(gl_class, "getImplementation",
|
|
"()Lcom/roblox/engine/jni/EngineJavaCallback2;");
|
|
ASSERT_NE(get_implementation, nullptr);
|
|
|
|
jobject implementation =
|
|
env->CallStaticObjectMethod(gl_class, get_implementation);
|
|
ASSERT_NE(implementation, nullptr);
|
|
|
|
jfieldID implementation_field =
|
|
env->GetStaticFieldID(gl_class, "sImplementation",
|
|
"Lcom/roblox/engine/jni/EngineJavaCallback2;");
|
|
ASSERT_NE(implementation_field, nullptr);
|
|
EXPECT_EQ(env->GetStaticObjectField(gl_class, implementation_field),
|
|
implementation);
|
|
|
|
jclass callback_class =
|
|
env->FindClass("com/roblox/engine/jni/EngineJavaCallback2");
|
|
ASSERT_NE(callback_class, nullptr);
|
|
jmethodID exit_game = env->GetMethodID(callback_class, "a", "(I)V");
|
|
ASSERT_NE(exit_game, nullptr);
|
|
env->CallVoidMethod(implementation, exit_game, 0);
|
|
}
|
|
|
|
TEST_F(JniVmTest, NativeGlJavaInterfaceStoresEngineCallback) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass gl_class =
|
|
env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
|
|
ASSERT_NE(gl_class, nullptr);
|
|
jclass callback_class =
|
|
env->FindClass("com/roblox/engine/jni/EngineJavaCallback2");
|
|
ASSERT_NE(callback_class, nullptr);
|
|
jclass listener_class =
|
|
env->FindClass("com/roblox/engine/jni/OnAppBridgeNotificationListener");
|
|
ASSERT_NE(listener_class, nullptr);
|
|
|
|
jobject implementation = env->AllocObject(callback_class);
|
|
ASSERT_NE(implementation, nullptr);
|
|
jobject listener = env->AllocObject(listener_class);
|
|
ASSERT_NE(listener, nullptr);
|
|
|
|
jmethodID set_implementation =
|
|
env->GetStaticMethodID(gl_class, "setImplementation",
|
|
"(Lcom/roblox/engine/jni/EngineJavaCallback2;)V");
|
|
ASSERT_NE(set_implementation, nullptr);
|
|
env->CallStaticVoidMethod(gl_class, set_implementation, implementation);
|
|
|
|
jmethodID set_listener = env->GetStaticMethodID(
|
|
gl_class, "setAppBridgeNotificationListener",
|
|
"(Lcom/roblox/engine/jni/OnAppBridgeNotificationListener;)V");
|
|
ASSERT_NE(set_listener, nullptr);
|
|
env->CallStaticVoidMethod(gl_class, set_listener, listener);
|
|
|
|
jfieldID implementation_field =
|
|
env->GetStaticFieldID(gl_class, "sImplementation",
|
|
"Lcom/roblox/engine/jni/EngineJavaCallback2;");
|
|
ASSERT_NE(implementation_field, nullptr);
|
|
EXPECT_EQ(env->GetStaticObjectField(gl_class, implementation_field),
|
|
implementation);
|
|
}
|
|
|
|
TEST_F(JniVmTest, NativeGlJavaInterfaceDispatchesDataModelNotification) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass gl_class =
|
|
env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
|
|
ASSERT_NE(gl_class, nullptr);
|
|
jclass callback_class =
|
|
env->FindClass("com/roblox/engine/jni/EngineJavaCallback2");
|
|
ASSERT_NE(callback_class, nullptr);
|
|
jclass listener_class =
|
|
env->FindClass("com/roblox/engine/jni/OnAppBridgeNotificationListener");
|
|
ASSERT_NE(listener_class, nullptr);
|
|
|
|
jobject implementation = env->AllocObject(callback_class);
|
|
ASSERT_NE(implementation, nullptr);
|
|
jobject listener = env->AllocObject(listener_class);
|
|
ASSERT_NE(listener, nullptr);
|
|
auto notification_probe = std::make_shared<DataModelNotificationProbe>();
|
|
vm_->SetRobloxDataModelNotificationCallbacks(
|
|
notification_probe,
|
|
RobloxDataModelNotificationCallbacks{&ProbeDataModelNotification});
|
|
|
|
jmethodID set_implementation =
|
|
env->GetStaticMethodID(gl_class, "setImplementation",
|
|
"(Lcom/roblox/engine/jni/EngineJavaCallback2;)V");
|
|
ASSERT_NE(set_implementation, nullptr);
|
|
env->CallStaticVoidMethod(gl_class, set_implementation, implementation);
|
|
|
|
jmethodID set_listener = env->GetStaticMethodID(
|
|
gl_class, "setAppBridgeNotificationListener",
|
|
"(Lcom/roblox/engine/jni/OnAppBridgeNotificationListener;)V");
|
|
ASSERT_NE(set_listener, nullptr);
|
|
env->CallStaticVoidMethod(gl_class, set_listener, listener);
|
|
|
|
jmethodID on_data_model_notification =
|
|
env->GetStaticMethodID(gl_class, "onDataModelNotificationCallback",
|
|
"(Ljava/lang/String;Ljava/lang/String;)V");
|
|
ASSERT_NE(on_data_model_notification, nullptr);
|
|
env->CallStaticVoidMethod(gl_class, on_data_model_notification,
|
|
env->NewStringUTF("APP_READY"),
|
|
env->NewStringUTF("{\"ready\":true}"));
|
|
|
|
jfieldID type_field = env->GetFieldID(
|
|
callback_class, "lastDataModelNotificationType", "Ljava/lang/String;");
|
|
ASSERT_NE(type_field, nullptr);
|
|
auto type =
|
|
static_cast<jstring>(env->GetObjectField(implementation, type_field));
|
|
ASSERT_NE(type, nullptr);
|
|
EXPECT_STREQ(env->GetStringUTFChars(type, nullptr), "APP_READY");
|
|
|
|
jfieldID data_field = env->GetFieldID(
|
|
callback_class, "lastDataModelNotificationData", "Ljava/lang/String;");
|
|
ASSERT_NE(data_field, nullptr);
|
|
auto data =
|
|
static_cast<jstring>(env->GetObjectField(implementation, data_field));
|
|
ASSERT_NE(data, nullptr);
|
|
EXPECT_STREQ(env->GetStringUTFChars(data, nullptr), "{\"ready\":true}");
|
|
|
|
jfieldID app_ready = env->GetFieldID(callback_class, "appReady", "Z");
|
|
ASSERT_NE(app_ready, nullptr);
|
|
EXPECT_EQ(env->GetBooleanField(implementation, app_ready), JNI_TRUE);
|
|
|
|
jfieldID listener_type_field = env->GetFieldID(
|
|
listener_class, "lastAppBridgeNotificationType", "Ljava/lang/String;");
|
|
ASSERT_NE(listener_type_field, nullptr);
|
|
auto listener_type =
|
|
static_cast<jstring>(env->GetObjectField(listener, listener_type_field));
|
|
ASSERT_NE(listener_type, nullptr);
|
|
EXPECT_STREQ(env->GetStringUTFChars(listener_type, nullptr), "APP_READY");
|
|
|
|
jfieldID listener_data_field = env->GetFieldID(
|
|
listener_class, "lastAppBridgeNotificationData", "Ljava/lang/String;");
|
|
ASSERT_NE(listener_data_field, nullptr);
|
|
auto listener_data =
|
|
static_cast<jstring>(env->GetObjectField(listener, listener_data_field));
|
|
ASSERT_NE(listener_data, nullptr);
|
|
EXPECT_STREQ(env->GetStringUTFChars(listener_data, nullptr),
|
|
"{\"ready\":true}");
|
|
|
|
jfieldID listener_app_ready =
|
|
env->GetFieldID(listener_class, "appReady", "Z");
|
|
ASSERT_NE(listener_app_ready, nullptr);
|
|
EXPECT_EQ(env->GetBooleanField(listener, listener_app_ready), JNI_TRUE);
|
|
EXPECT_EQ(notification_probe->calls, 1);
|
|
EXPECT_EQ(notification_probe->type, "APP_READY");
|
|
EXPECT_EQ(notification_probe->data, "{\"ready\":true}");
|
|
|
|
jmethodID callback_notification = env->GetMethodID(
|
|
callback_class, "f", "(Ljava/lang/String;Ljava/lang/String;)V");
|
|
ASSERT_NE(callback_notification, nullptr);
|
|
jvalue callback_args[2] = {};
|
|
callback_args[0].l = env->NewStringUTF("OPEN_CUSTOM_WEBVIEW");
|
|
callback_args[1].l = env->NewStringUTF("{\"url\":\"www:games/2\"}");
|
|
env->CallVoidMethodA(implementation, callback_notification, callback_args);
|
|
EXPECT_EQ(notification_probe->calls, 2);
|
|
EXPECT_EQ(notification_probe->type, "OPEN_CUSTOM_WEBVIEW");
|
|
|
|
jclass wrong_class = env->FindClass("example/EngineJavaCallback2");
|
|
jobject wrong_receiver = env->AllocObject(wrong_class);
|
|
env->CallVoidMethod(wrong_receiver, callback_notification, callback_args[0].l,
|
|
callback_args[1].l);
|
|
jmethodID wrong_signature = env->GetMethodID(callback_class, "f", "(I)V");
|
|
env->CallVoidMethod(implementation, wrong_signature, 7);
|
|
env->CallStaticVoidMethod(wrong_class, on_data_model_notification,
|
|
callback_args[0].l, callback_args[1].l);
|
|
EXPECT_EQ(notification_probe->calls, 2);
|
|
|
|
vm_->ClearRobloxDataModelNotificationCallbacks();
|
|
env->CallStaticVoidMethod(gl_class, on_data_model_notification,
|
|
env->NewStringUTF("OPEN_CUSTOM_WEBVIEW"),
|
|
env->NewStringUTF("{\"url\":\"www:games/1\"}"));
|
|
EXPECT_EQ(notification_probe->calls, 2);
|
|
}
|
|
|
|
TEST_F(JniVmTest, NativeGlJavaInterfaceDispatchesPlatformWebCallbacks) {
|
|
JNIEnv* env = vm_->GetJNIEnv();
|
|
jclass gl_class =
|
|
env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
|
|
ASSERT_NE(gl_class, nullptr);
|
|
auto probe = std::make_shared<DataModelNotificationProbe>();
|
|
vm_->SetRobloxDataModelNotificationCallbacks(
|
|
probe, RobloxDataModelNotificationCallbacks{&ProbeDataModelNotification,
|
|
&ProbeAppBridgeNotification,
|
|
&ProbeNativeOverlay});
|
|
|
|
jmethodID app_bridge =
|
|
env->GetStaticMethodID(gl_class, "onAppBridgeNotification",
|
|
"(Ljava/lang/String;Ljava/lang/String;)V");
|
|
jmethodID native_overlay = env->GetStaticMethodID(
|
|
gl_class, "openNativeOverlay", "(Ljava/lang/String;Ljava/lang/String;)V");
|
|
ASSERT_NE(app_bridge, nullptr);
|
|
ASSERT_NE(native_overlay, nullptr);
|
|
|
|
jstring captcha_type = env->NewStringUTF("OPEN_CAPTCHA_VIEW");
|
|
jstring captcha_data = env->NewStringUTF(
|
|
R"({"captchaType":"login","cvalueType":"username","cvalue":"user"})");
|
|
env->CallStaticVoidMethod(gl_class, app_bridge, captcha_type, captcha_data);
|
|
EXPECT_EQ(probe->app_bridge_calls, 1);
|
|
EXPECT_EQ(probe->type, "OPEN_CAPTCHA_VIEW");
|
|
EXPECT_EQ(
|
|
probe->data,
|
|
R"({"captchaType":"login","cvalueType":"username","cvalue":"user"})");
|
|
|
|
jvalue overlay_args[2] = {};
|
|
overlay_args[0].l = env->NewStringUTF("Account verification");
|
|
overlay_args[1].l = env->NewStringUTF("https://www.roblox.com/login");
|
|
env->CallStaticVoidMethodA(gl_class, native_overlay, overlay_args);
|
|
EXPECT_EQ(probe->native_overlay_calls, 1);
|
|
EXPECT_EQ(probe->title, "Account verification");
|
|
EXPECT_EQ(probe->url, "https://www.roblox.com/login");
|
|
|
|
jclass wrong_class = env->FindClass("example/NativeGLJavaInterface");
|
|
env->CallStaticVoidMethodA(wrong_class, native_overlay, overlay_args);
|
|
EXPECT_EQ(probe->native_overlay_calls, 1);
|
|
|
|
vm_->ClearRobloxDataModelNotificationCallbacks();
|
|
env->CallStaticVoidMethod(gl_class, app_bridge, captcha_type, captcha_data);
|
|
env->CallStaticVoidMethodA(gl_class, native_overlay, overlay_args);
|
|
EXPECT_EQ(probe->app_bridge_calls, 1);
|
|
EXPECT_EQ(probe->native_overlay_calls, 1);
|
|
}
|
|
|
|
TEST_F(JniVmTest, MainGameActivityDispatchesOpenWebActivity) {
|
|
JNIEnv* env = vm_->GetJNIEnv();
|
|
jclass activity_class =
|
|
env->FindClass("com/roblox/client/startup/MainGameActivity");
|
|
ASSERT_NE(activity_class, nullptr);
|
|
jobject activity = env->AllocObject(activity_class);
|
|
ASSERT_NE(activity, nullptr);
|
|
auto probe = std::make_shared<DataModelNotificationProbe>();
|
|
vm_->SetRobloxDataModelNotificationCallbacks(
|
|
probe, RobloxDataModelNotificationCallbacks{
|
|
&ProbeDataModelNotification, &ProbeAppBridgeNotification,
|
|
&ProbeNativeOverlay, &ProbeOpenWebActivity});
|
|
|
|
jmethodID open = env->GetMethodID(activity_class, "openWebActivity",
|
|
"(Ljava/lang/String;Ljava/lang/String;)V");
|
|
ASSERT_NE(open, nullptr);
|
|
env->CallVoidMethod(activity, open,
|
|
env->NewStringUTF("https://www.roblox.com/login"),
|
|
env->NewStringUTF("Log in"));
|
|
EXPECT_EQ(probe->open_web_activity_calls, 1);
|
|
EXPECT_EQ(probe->url, "https://www.roblox.com/login");
|
|
EXPECT_EQ(probe->title, "Log in");
|
|
|
|
jclass wrong_class = env->FindClass("example/MainGameActivity");
|
|
jobject wrong_activity = env->AllocObject(wrong_class);
|
|
env->CallVoidMethod(wrong_activity, open,
|
|
env->NewStringUTF("https://www.roblox.com/signup"),
|
|
env->NewStringUTF("Sign up"));
|
|
EXPECT_EQ(probe->open_web_activity_calls, 1);
|
|
|
|
vm_->ClearRobloxDataModelNotificationCallbacks();
|
|
env->CallVoidMethod(activity, open,
|
|
env->NewStringUTF("https://www.roblox.com/signup"),
|
|
env->NewStringUTF("Sign up"));
|
|
EXPECT_EQ(probe->open_web_activity_calls, 1);
|
|
}
|
|
|
|
TEST_F(JniVmTest, GameActivityDispatchesTypedAndroidWindowFlags) {
|
|
JNIEnv* env = vm_->GetJNIEnv();
|
|
jclass activity_interface_class = env->FindClass("android/app/Activity");
|
|
jclass activity_class =
|
|
env->FindClass("com/roblox/client/startup/MainGameActivity");
|
|
jclass game_activity_class =
|
|
env->FindClass("com/google/androidgamesdk/GameActivity");
|
|
ASSERT_NE(activity_interface_class, nullptr);
|
|
ASSERT_NE(activity_class, nullptr);
|
|
ASSERT_NE(game_activity_class, nullptr);
|
|
jobject activity_interface = env->AllocObject(activity_interface_class);
|
|
jobject activity = env->AllocObject(activity_class);
|
|
jobject game_activity = env->AllocObject(game_activity_class);
|
|
auto probe = std::make_shared<AndroidWindowProbe>();
|
|
AndroidWindowCallbacks callbacks;
|
|
callbacks.set_flags = &ProbeAndroidWindowFlags;
|
|
vm_->SetAndroidWindowCallbacks(probe, callbacks);
|
|
|
|
jmethodID set_flags =
|
|
env->GetMethodID(activity_class, "setWindowFlags", "(II)V");
|
|
ASSERT_NE(set_flags, nullptr);
|
|
env->CallVoidMethod(activity, set_flags, 0x400, 0x400);
|
|
EXPECT_EQ(probe->calls, 1);
|
|
EXPECT_EQ(probe->flags, 0x400);
|
|
EXPECT_EQ(probe->mask, 0x400);
|
|
|
|
CallVoidMethodVForTest(env, game_activity, set_flags, 0, 0x400);
|
|
EXPECT_EQ(probe->calls, 2);
|
|
EXPECT_EQ(probe->flags, 0);
|
|
EXPECT_EQ(probe->mask, 0x400);
|
|
|
|
jvalue args[2] = {};
|
|
args[0].i = 0x800;
|
|
args[1].i = 0x800;
|
|
env->CallVoidMethodA(activity, set_flags, args);
|
|
EXPECT_EQ(probe->calls, 3);
|
|
EXPECT_EQ(probe->flags, 0x800);
|
|
EXPECT_EQ(probe->mask, 0x800);
|
|
|
|
// LuaApp passes the concrete object as android.app.Activity. The inherited
|
|
// method ID must still dispatch to the host callback.
|
|
env->CallVoidMethod(activity_interface, set_flags, 0x400, 0x400);
|
|
EXPECT_EQ(probe->calls, 4);
|
|
EXPECT_EQ(probe->flags, 0x400);
|
|
EXPECT_EQ(probe->mask, 0x400);
|
|
|
|
jclass wrong_class = env->FindClass("example/MainGameActivity");
|
|
env->CallVoidMethod(env->AllocObject(wrong_class), set_flags, 0x400, 0x400);
|
|
jmethodID wrong_signature =
|
|
env->GetMethodID(activity_class, "setWindowFlags", "(J)V");
|
|
env->CallVoidMethod(activity, wrong_signature, static_cast<jlong>(0x400));
|
|
EXPECT_EQ(probe->calls, 4);
|
|
|
|
vm_->ClearAndroidWindowCallbacks();
|
|
env->CallVoidMethod(activity, set_flags, 0, 0x400);
|
|
EXPECT_EQ(probe->calls, 4);
|
|
}
|
|
|
|
TEST_F(JniVmTest, MainGameActivitySynchronizesTypedCookieAndGuestEmpty) {
|
|
JNIEnv* env = vm_->GetJNIEnv();
|
|
jclass activity_class =
|
|
env->FindClass("com/roblox/client/startup/MainGameActivity");
|
|
jobject activity = env->AllocObject(activity_class);
|
|
auto probe = std::make_shared<DataModelNotificationProbe>();
|
|
vm_->SetRobloxDataModelNotificationCallbacks(
|
|
probe, RobloxDataModelNotificationCallbacks{
|
|
&ProbeDataModelNotification, &ProbeAppBridgeNotification,
|
|
&ProbeNativeOverlay, &ProbeOpenWebActivity, &ProbeCookieSync});
|
|
jmethodID sync =
|
|
env->GetMethodID(activity_class, "syncCookiesFromEngine", "()V");
|
|
ASSERT_NE(sync, nullptr);
|
|
|
|
const std::string authenticated = ".ROBLOSECURITY=_|typed-cookie";
|
|
vm_->SetRobloxCredentialProvider(&authenticated, &TestCredentialProvider);
|
|
env->CallVoidMethod(activity, sync);
|
|
EXPECT_EQ(probe->cookie_sync_calls, 1);
|
|
EXPECT_EQ(probe->cookie, authenticated);
|
|
|
|
const std::string guest;
|
|
vm_->SetRobloxCredentialProvider(&guest, &TestCredentialProvider);
|
|
env->CallVoidMethodA(activity, sync, nullptr);
|
|
EXPECT_EQ(probe->cookie_sync_calls, 2);
|
|
EXPECT_TRUE(probe->cookie.empty());
|
|
}
|
|
|
|
TEST_F(JniVmTest, DidLoginRefreshesCredentialFromNativeCookieJar) {
|
|
const std::string guest;
|
|
vm_->SetRobloxCredentialProvider(&guest, &TestCredentialProvider);
|
|
auto credential_probe = std::make_shared<CredentialSinkProbe>();
|
|
vm_->SetRobloxCredentialSink(
|
|
credential_probe, RobloxCredentialSinkCallbacks{&ProbeCredentialStore});
|
|
vm_->SetRobloxCookieGetter(&QuickLoginCookieGetter);
|
|
|
|
JNIEnv* env = vm_->GetJNIEnv();
|
|
jclass helper_class =
|
|
env->FindClass("com/roblox/client/startup/NativeHelper");
|
|
jobject helper = env->AllocObject(helper_class);
|
|
jmethodID did_login = env->GetMethodID(
|
|
helper_class, "gameActivity_onDidLogInReceived", "(Ljava/lang/String;)V");
|
|
ASSERT_NE(did_login, nullptr);
|
|
|
|
env->CallVoidMethod(helper, did_login,
|
|
env->NewStringUTF(R"({"userId":123})"));
|
|
|
|
EXPECT_EQ(credential_probe->calls, 1);
|
|
EXPECT_EQ(credential_probe->credential,
|
|
".ROBLOSECURITY=_|quick-login-cookie");
|
|
std::string credential;
|
|
ASSERT_TRUE(vm_->CopyRobloxCredentialFromProvider(&credential));
|
|
EXPECT_EQ(credential, ".ROBLOSECURITY=_|quick-login-cookie");
|
|
}
|
|
|
|
TEST_F(JniVmTest, CookieProtocolPersistsAndDispatchesExactSetHandler) {
|
|
JNIEnv* env = vm_->GetJNIEnv();
|
|
auto platform_probe = std::make_shared<DataModelNotificationProbe>();
|
|
RobloxDataModelNotificationCallbacks platform_callbacks;
|
|
platform_callbacks.on_notification = &ProbeDataModelNotification;
|
|
platform_callbacks.on_set_cookie = &ProbeCookieSet;
|
|
vm_->SetRobloxDataModelNotificationCallbacks(platform_probe,
|
|
platform_callbacks);
|
|
auto credential_probe = std::make_shared<CredentialSinkProbe>();
|
|
vm_->SetRobloxCredentialSink(
|
|
credential_probe, RobloxCredentialSinkCallbacks{&ProbeCredentialStore});
|
|
|
|
jclass handler_class = env->FindClass(
|
|
"com/roblox/universalapp/cookie/"
|
|
"JNICookieProtocol$OnSetCookieHandler");
|
|
jobject handler = env->AllocObject(handler_class);
|
|
jmethodID on_set_cookie = env->GetMethodID(
|
|
handler_class, "onSetCookie",
|
|
"([Ljava/lang/String;Ljava/lang/String;)V");
|
|
jclass string_class = env->FindClass("java/lang/String");
|
|
jobjectArray cookies = env->NewObjectArray(2, string_class, nullptr);
|
|
env->SetObjectArrayElement(
|
|
cookies, 0, env->NewStringUTF("RBXEventTracker=ignored; Path=/"));
|
|
env->SetObjectArrayElement(
|
|
cookies, 1,
|
|
env->NewStringUTF(
|
|
".ROBLOSECURITY=_|native-cookie; Domain=.roblox.com; Path=/; "
|
|
"Secure; HttpOnly"));
|
|
jstring url = env->NewStringUTF("https://www.roblox.com/");
|
|
|
|
env->CallVoidMethod(handler, on_set_cookie, cookies, url);
|
|
CallVoidMethodVForTest(env, handler, on_set_cookie, cookies, url);
|
|
jvalue args[2] = {};
|
|
args[0].l = cookies;
|
|
args[1].l = url;
|
|
env->CallVoidMethodA(handler, on_set_cookie, args);
|
|
EXPECT_EQ(platform_probe->cookie_set_calls, 3);
|
|
EXPECT_EQ(platform_probe->cookie, ".ROBLOSECURITY=_|native-cookie");
|
|
EXPECT_EQ(platform_probe->cookie_set_url, "https://www.roblox.com/");
|
|
EXPECT_EQ(credential_probe->calls, 3);
|
|
EXPECT_EQ(credential_probe->credential,
|
|
".ROBLOSECURITY=_|native-cookie");
|
|
|
|
jmethodID wrong_signature =
|
|
env->GetMethodID(handler_class, "onSetCookie", "(Ljava/lang/String;)V");
|
|
env->CallVoidMethod(handler, wrong_signature,
|
|
env->NewStringUTF(".ROBLOSECURITY=_|ignored"));
|
|
jclass wrong_class = env->FindClass("example/OnSetCookieHandler");
|
|
env->CallVoidMethod(env->AllocObject(wrong_class), on_set_cookie, cookies,
|
|
url);
|
|
EXPECT_EQ(platform_probe->cookie_set_calls, 3);
|
|
EXPECT_EQ(credential_probe->calls, 3);
|
|
|
|
vm_->ClearRobloxDataModelNotificationCallbacks();
|
|
env->CallVoidMethod(handler, on_set_cookie, cookies, url);
|
|
EXPECT_EQ(platform_probe->cookie_set_calls, 3);
|
|
EXPECT_EQ(credential_probe->calls, 3);
|
|
EXPECT_FALSE(vm_->DispatchRobloxCookieSet(env, cookies, url));
|
|
}
|
|
|
|
TEST_F(JniVmTest, NativeGlJavaInterfaceStoresAppBridgeNotificationListener) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass gl_class =
|
|
env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
|
|
ASSERT_NE(gl_class, nullptr);
|
|
jclass listener_class =
|
|
env->FindClass("com/roblox/engine/jni/OnAppBridgeNotificationListener");
|
|
ASSERT_NE(listener_class, nullptr);
|
|
|
|
jobject listener = env->AllocObject(listener_class);
|
|
ASSERT_NE(listener, nullptr);
|
|
|
|
jmethodID set_listener = env->GetStaticMethodID(
|
|
gl_class, "setAppBridgeNotificationListener",
|
|
"(Lcom/roblox/engine/jni/OnAppBridgeNotificationListener;)V");
|
|
ASSERT_NE(set_listener, nullptr);
|
|
env->CallStaticVoidMethod(gl_class, set_listener, listener);
|
|
|
|
jfieldID listener_field = env->GetStaticFieldID(
|
|
gl_class, "sAppBridgeNotificationListener",
|
|
"Lcom/roblox/engine/jni/OnAppBridgeNotificationListener;");
|
|
ASSERT_NE(listener_field, nullptr);
|
|
EXPECT_EQ(env->GetStaticObjectField(gl_class, listener_field), listener);
|
|
}
|
|
|
|
TEST_F(JniVmTest, WebRtcBuildInfoReturnsHeadlessDeviceData) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass build_info = env->FindClass("org/webrtc/voiceengine/BuildInfo");
|
|
ASSERT_NE(build_info, nullptr);
|
|
|
|
jmethodID get_device_model = env->GetStaticMethodID(
|
|
build_info, "getDeviceModel", "()Ljava/lang/String;");
|
|
auto model = static_cast<jstring>(
|
|
env->CallStaticObjectMethod(build_info, get_device_model));
|
|
ASSERT_NE(model, nullptr);
|
|
EXPECT_STREQ(env->GetStringUTFChars(model, nullptr), "Mocktail Headless");
|
|
|
|
jmethodID get_sdk_version =
|
|
env->GetStaticMethodID(build_info, "getSdkVersion", "()I");
|
|
EXPECT_EQ(env->CallStaticIntMethod(build_info, get_sdk_version), 33);
|
|
}
|
|
|
|
TEST_F(JniVmTest, FmodJavaShimReportsInitializedNoSoundDevice) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass fmod_class = env->FindClass("org/fmod/FMOD");
|
|
ASSERT_NE(fmod_class, nullptr);
|
|
|
|
jmethodID init = env->GetStaticMethodID(fmod_class, "init",
|
|
"(Landroid/content/Context;)V");
|
|
ASSERT_NE(init, nullptr);
|
|
env->CallStaticVoidMethod(fmod_class, init, nullptr);
|
|
|
|
jmethodID check_init = env->GetStaticMethodID(fmod_class, "checkInit", "()Z");
|
|
ASSERT_NE(check_init, nullptr);
|
|
EXPECT_EQ(env->CallStaticBooleanMethod(fmod_class, check_init), JNI_TRUE);
|
|
|
|
jmethodID sample_rate =
|
|
env->GetStaticMethodID(fmod_class, "getOutputSampleRate", "()I");
|
|
ASSERT_NE(sample_rate, nullptr);
|
|
EXPECT_EQ(env->CallStaticIntMethod(fmod_class, sample_rate), 48000);
|
|
|
|
jmethodID block_size =
|
|
env->GetStaticMethodID(fmod_class, "getOutputBlockSize", "()I");
|
|
ASSERT_NE(block_size, nullptr);
|
|
EXPECT_EQ(env->CallStaticIntMethod(fmod_class, block_size), 512);
|
|
|
|
jmethodID supports_aaudio =
|
|
env->GetStaticMethodID(fmod_class, "supportsAAudio", "()Z");
|
|
ASSERT_NE(supports_aaudio, nullptr);
|
|
EXPECT_EQ(env->CallStaticBooleanMethod(fmod_class, supports_aaudio),
|
|
JNI_FALSE);
|
|
|
|
jmethodID asset_manager = env->GetStaticMethodID(
|
|
fmod_class, "getAssetManager", "()Landroid/content/res/AssetManager;");
|
|
ASSERT_NE(asset_manager, nullptr);
|
|
EXPECT_NE(env->CallStaticObjectMethod(fmod_class, asset_manager), nullptr);
|
|
}
|
|
|
|
TEST_F(JniVmTest, FmodAudioDeviceDispatchesExactDirectVAndAMethods) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass device_class = env->FindClass("org/fmod/AudioDevice");
|
|
ASSERT_NE(device_class, nullptr);
|
|
const jmethodID constructor = env->GetMethodID(device_class, "<init>", "()V");
|
|
const jmethodID init = env->GetMethodID(device_class, "init", "(IIII)Z");
|
|
const jmethodID write = env->GetMethodID(device_class, "write", "([BI)V");
|
|
const jmethodID close = env->GetMethodID(device_class, "close", "()V");
|
|
ASSERT_NE(constructor, nullptr);
|
|
ASSERT_NE(init, nullptr);
|
|
ASSERT_NE(write, nullptr);
|
|
ASSERT_NE(close, nullptr);
|
|
|
|
auto probe = std::make_shared<FmodAudioDeviceProbe>();
|
|
vm_->SetFmodAudioDeviceCallbacks(probe, ProbeFmodCallbacks());
|
|
|
|
jobject direct_device = env->NewObject(device_class, constructor);
|
|
ASSERT_NE(direct_device, nullptr);
|
|
EXPECT_EQ(env->CallBooleanMethod(direct_device, init, 2, 48000, 512, 4),
|
|
JNI_TRUE);
|
|
|
|
const jbyte first_bytes[] = {1, 2, 3, 4, 5, 6};
|
|
jbyteArray first_array = env->NewByteArray(6);
|
|
env->SetByteArrayRegion(first_array, 0, 6, first_bytes);
|
|
env->CallVoidMethod(direct_device, write, first_array, 4);
|
|
EXPECT_EQ(probe->bytes, (std::vector<std::uint8_t>{1, 2, 3, 4}));
|
|
env->CallVoidMethod(direct_device, write, first_array, 0);
|
|
env->CallVoidMethod(direct_device, write, first_array, -1);
|
|
env->CallVoidMethod(direct_device, write, first_array, 7);
|
|
env->CallVoidMethod(direct_device, write, nullptr, 1);
|
|
EXPECT_EQ(probe->write_calls, 1);
|
|
env->CallVoidMethod(direct_device, close);
|
|
|
|
jobject v_device = env->NewObject(device_class, constructor);
|
|
ASSERT_NE(v_device, nullptr);
|
|
EXPECT_EQ(CallBooleanMethodVForTest(env, v_device, init, 1, 44100, 256, 3),
|
|
JNI_TRUE);
|
|
CallVoidMethodVForTest(env, v_device, write, first_array, 6);
|
|
CallVoidMethodVForTest(env, v_device, close);
|
|
|
|
jobject a_device = env->NewObject(device_class, constructor);
|
|
ASSERT_NE(a_device, nullptr);
|
|
jvalue init_args[4] = {};
|
|
init_args[0].i = 6;
|
|
init_args[1].i = 96000;
|
|
init_args[2].i = 128;
|
|
init_args[3].i = 2;
|
|
EXPECT_EQ(env->CallBooleanMethodA(a_device, init, init_args), JNI_TRUE);
|
|
jvalue write_args[2] = {};
|
|
write_args[0].l = first_array;
|
|
write_args[1].i = 2;
|
|
env->CallVoidMethodA(a_device, write, write_args);
|
|
env->CallVoidMethodA(a_device, close, nullptr);
|
|
|
|
EXPECT_EQ(probe->init_calls, 3);
|
|
EXPECT_EQ(probe->write_calls, 3);
|
|
EXPECT_EQ(probe->close_calls, 3);
|
|
EXPECT_EQ(probe->last_identity, a_device);
|
|
EXPECT_EQ(probe->channels, 6);
|
|
EXPECT_EQ(probe->sample_rate_hz, 96000);
|
|
EXPECT_EQ(probe->block_size_frames, 128);
|
|
EXPECT_EQ(probe->block_count, 2);
|
|
|
|
jclass other_class = env->FindClass("example/UnrelatedAudioDevice");
|
|
jobject other_device = env->NewObject(other_class, constructor);
|
|
EXPECT_EQ(env->CallBooleanMethod(other_device, init, 2, 48000, 512, 4),
|
|
JNI_FALSE);
|
|
EXPECT_EQ(probe->init_calls, 3);
|
|
|
|
vm_->ClearFmodAudioDeviceCallbacks();
|
|
EXPECT_EQ(probe->shutdown_calls, 1);
|
|
}
|
|
|
|
TEST_F(JniVmTest, FmodAudioDeviceBindingRetainsContextUntilClear) {
|
|
auto probe = std::make_shared<FmodAudioDeviceProbe>();
|
|
std::weak_ptr<FmodAudioDeviceProbe> weak_probe = probe;
|
|
vm_->SetFmodAudioDeviceCallbacks(probe, ProbeFmodCallbacks());
|
|
probe.reset();
|
|
ASSERT_FALSE(weak_probe.expired());
|
|
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass device_class = env->FindClass("org/fmod/AudioDevice");
|
|
const jmethodID constructor = env->GetMethodID(device_class, "<init>", "()V");
|
|
const jmethodID init = env->GetMethodID(device_class, "init", "(IIII)Z");
|
|
jobject device = env->NewObject(device_class, constructor);
|
|
EXPECT_EQ(CallBooleanMethodVForTest(env, device, init, 2, 48000, 512, 4),
|
|
JNI_TRUE);
|
|
|
|
vm_->ClearFmodAudioDeviceCallbacks();
|
|
EXPECT_TRUE(weak_probe.expired());
|
|
}
|
|
|
|
TEST_F(JniVmTest, RobloxTextInputCapturesExactStaticDirectVAndACalls) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass cls = env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
|
|
ASSERT_NE(cls, nullptr);
|
|
const jmethodID show = env->GetStaticMethodID(
|
|
cls, "showKeyboard",
|
|
"(JZ[BLcom/roblox/engine/jni/model/NativeTextBoxInfo;)V");
|
|
const jmethodID hide = env->GetStaticMethodID(cls, "hideKeyboard", "()V");
|
|
const jmethodID replace = env->GetStaticMethodID(
|
|
cls, "onLuaTextBoxChangedCallback", "(Ljava/lang/String;)V");
|
|
const jmethodID properties =
|
|
env->GetStaticMethodID(cls, "onLuaTextBoxPropertyChangedCallback", "()V");
|
|
ASSERT_NE(show, nullptr);
|
|
|
|
auto probe = std::make_shared<RobloxTextInputProbe>();
|
|
vm_->SetRobloxTextInputCallbacks(probe, ProbeTextInputCallbacks());
|
|
jobject info = MakeTextBoxInfo(env);
|
|
const jbyte utf8[] = {'h', 'e', 'l', 'l', 'o'};
|
|
jbyteArray text = env->NewByteArray(5);
|
|
env->SetByteArrayRegion(text, 0, 5, utf8);
|
|
|
|
env->CallStaticVoidMethod(cls, show, static_cast<jlong>(42), JNI_TRUE, text,
|
|
info);
|
|
EXPECT_EQ(probe->show_calls, 1);
|
|
EXPECT_EQ(probe->request.text_box, 42);
|
|
EXPECT_TRUE(probe->request.show_native_input);
|
|
EXPECT_EQ(probe->request.text, "hello");
|
|
EXPECT_FLOAT_EQ(probe->request.info.x, 1.0f);
|
|
EXPECT_FLOAT_EQ(probe->request.info.font_size, 18.0f);
|
|
EXPECT_TRUE(probe->request.info.multiline);
|
|
EXPECT_EQ(probe->request.info.text_color, 0x10203040);
|
|
EXPECT_EQ(probe->request.info.text_input_type, 5);
|
|
EXPECT_TRUE(probe->request.info.manual_focus_release);
|
|
|
|
CallStaticVoidMethodVForTest(env, cls, show, static_cast<jlong>(42), JNI_TRUE,
|
|
text, info);
|
|
EXPECT_EQ(probe->show_calls, 2);
|
|
|
|
jvalue show_args[4] = {};
|
|
show_args[0].j = 43;
|
|
show_args[1].z = JNI_FALSE;
|
|
show_args[2].l = text;
|
|
show_args[3].l = info;
|
|
env->CallStaticVoidMethodA(cls, show, show_args);
|
|
EXPECT_EQ(probe->show_calls, 3);
|
|
EXPECT_EQ(probe->request.text_box, 43);
|
|
|
|
jmethodID wrong_signature =
|
|
env->GetStaticMethodID(cls, "showKeyboard", "(JLjava/lang/String;)V");
|
|
env->CallStaticVoidMethod(cls, wrong_signature, static_cast<jlong>(44),
|
|
env->NewStringUTF("ignored"));
|
|
jclass wrong_cls = env->FindClass("example/NativeGLJavaInterface");
|
|
env->CallStaticVoidMethod(wrong_cls, show, static_cast<jlong>(44), JNI_FALSE,
|
|
text, info);
|
|
EXPECT_EQ(probe->show_calls, 3);
|
|
|
|
jstring replacement = env->NewStringUTF("engine replacement");
|
|
env->CallStaticVoidMethod(cls, replace, replacement);
|
|
env->CallStaticVoidMethod(cls, replace, replacement);
|
|
env->CallStaticVoidMethod(cls, properties);
|
|
EXPECT_EQ(probe->replace_calls, 1);
|
|
EXPECT_EQ(probe->replacement, "engine replacement");
|
|
EXPECT_EQ(probe->properties_calls, 1);
|
|
|
|
const jchar unicode_replacement[] = {0xd83d, 0xde42, 0x00e9};
|
|
env->CallStaticVoidMethod(cls, replace,
|
|
env->NewString(unicode_replacement, 3));
|
|
EXPECT_EQ(probe->replace_calls, 2);
|
|
EXPECT_EQ(probe->replacement, std::string("\xf0\x9f\x99\x82\xc3\xa9", 6));
|
|
|
|
env->CallStaticVoidMethod(cls, hide);
|
|
env->CallStaticVoidMethodA(cls, hide, nullptr);
|
|
EXPECT_EQ(probe->hide_calls, 1);
|
|
vm_->ClearRobloxTextInputCallbacks();
|
|
EXPECT_EQ(probe->shutdown_calls, 1);
|
|
}
|
|
|
|
TEST_F(JniVmTest, RobloxTextInputCapturesNativeHelperAndRejectsMalformedCalls) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass helper_cls = env->FindClass("com/roblox/client/startup/NativeHelper");
|
|
jmethodID constructor = env->GetMethodID(helper_cls, "<init>", "()V");
|
|
jobject helper = env->NewObject(helper_cls, constructor);
|
|
const jmethodID show = env->GetMethodID(
|
|
helper_cls, "gameActivity_showKeyboard",
|
|
"(JZ[BLcom/roblox/engine/jni/model/NativeTextBoxInfo;)V");
|
|
const jmethodID hide =
|
|
env->GetMethodID(helper_cls, "gameActivity_hideKeyboard", "()V");
|
|
const jmethodID replace = env->GetMethodID(
|
|
helper_cls, "gameActivity_onLuaTextBoxChanged", "(Ljava/lang/String;)V");
|
|
const jmethodID properties = env->GetMethodID(
|
|
helper_cls, "gameActivity_onLuaTextBoxPropertyChanged", "()V");
|
|
auto probe = std::make_shared<RobloxTextInputProbe>();
|
|
vm_->SetRobloxTextInputCallbacks(probe, ProbeTextInputCallbacks());
|
|
jobject info = MakeTextBoxInfo(env);
|
|
jbyteArray text = env->NewByteArray(2);
|
|
const jbyte valid[] = {'o', 'k'};
|
|
env->SetByteArrayRegion(text, 0, 2, valid);
|
|
|
|
env->CallVoidMethod(helper, show, static_cast<jlong>(98), JNI_FALSE, text,
|
|
info);
|
|
EXPECT_EQ(probe->show_calls, 1);
|
|
CallVoidMethodVForTest(env, helper, show, static_cast<jlong>(99), JNI_FALSE,
|
|
text, info);
|
|
EXPECT_EQ(probe->show_calls, 2);
|
|
jvalue args[4] = {};
|
|
args[0].j = 100;
|
|
args[2].l = text;
|
|
args[3].l = info;
|
|
env->CallVoidMethodA(helper, show, args);
|
|
EXPECT_EQ(probe->show_calls, 3);
|
|
|
|
env->CallVoidMethod(helper, show, static_cast<jlong>(0), JNI_FALSE, text,
|
|
info);
|
|
env->CallVoidMethod(helper, show, static_cast<jlong>(101), JNI_FALSE, nullptr,
|
|
info);
|
|
env->CallVoidMethod(helper, show, static_cast<jlong>(101), JNI_FALSE, text,
|
|
nullptr);
|
|
jbyteArray invalid_utf8 = env->NewByteArray(2);
|
|
const jbyte invalid[] = {static_cast<jbyte>(0xc0), static_cast<jbyte>(0x80)};
|
|
env->SetByteArrayRegion(invalid_utf8, 0, 2, invalid);
|
|
env->CallVoidMethod(helper, show, static_cast<jlong>(101), JNI_FALSE,
|
|
invalid_utf8, info);
|
|
EXPECT_EQ(probe->show_calls, 3);
|
|
|
|
jmethodID wrong_signature = env->GetMethodID(
|
|
helper_cls, "gameActivity_showKeyboard", "(JLjava/lang/String;)V");
|
|
env->CallVoidMethod(helper, wrong_signature, static_cast<jlong>(102),
|
|
env->NewStringUTF("ignored"));
|
|
jclass wrong_cls = env->FindClass("example/NativeHelper");
|
|
jobject wrong_obj = env->NewObject(wrong_cls, constructor);
|
|
env->CallVoidMethod(wrong_obj, show, static_cast<jlong>(102), JNI_FALSE, text,
|
|
info);
|
|
EXPECT_EQ(probe->show_calls, 3);
|
|
|
|
env->CallVoidMethod(helper, replace, env->NewStringUTF("updated"));
|
|
env->CallVoidMethodA(helper, properties, nullptr);
|
|
EXPECT_EQ(probe->replace_calls, 1);
|
|
EXPECT_EQ(probe->properties_calls, 1);
|
|
CallVoidMethodVForTest(env, helper, hide);
|
|
EXPECT_EQ(probe->hide_calls, 1);
|
|
}
|
|
|
|
TEST_F(JniVmTest, RobloxTextInputCapturesInstalledEngineJavaCallback) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass callback_class =
|
|
env->FindClass("com/roblox/engine/jni/EngineJavaCallback2");
|
|
ASSERT_NE(callback_class, nullptr);
|
|
const jmethodID constructor =
|
|
env->GetMethodID(callback_class, "<init>", "()V");
|
|
jobject callback = env->NewObject(callback_class, constructor);
|
|
ASSERT_NE(callback, nullptr);
|
|
const jmethodID show = env->GetMethodID(
|
|
callback_class, "q",
|
|
"(JZ[BLcom/roblox/engine/jni/model/NativeTextBoxInfo;)V");
|
|
const jmethodID hide = env->GetMethodID(callback_class, "d", "()V");
|
|
const jmethodID replace =
|
|
env->GetMethodID(callback_class, "g", "(Ljava/lang/String;)V");
|
|
const jmethodID properties = env->GetMethodID(callback_class, "h", "()V");
|
|
ASSERT_NE(show, nullptr);
|
|
ASSERT_NE(hide, nullptr);
|
|
ASSERT_NE(replace, nullptr);
|
|
ASSERT_NE(properties, nullptr);
|
|
|
|
auto probe = std::make_shared<RobloxTextInputProbe>();
|
|
vm_->SetRobloxTextInputCallbacks(probe, ProbeTextInputCallbacks());
|
|
jobject info = MakeTextBoxInfo(env);
|
|
jbyteArray text = env->NewByteArray(5);
|
|
const jbyte value[] = {'l', 'o', 'g', 'i', 'n'};
|
|
env->SetByteArrayRegion(text, 0, 5, value);
|
|
|
|
env->CallVoidMethod(callback, show, static_cast<jlong>(301), JNI_TRUE, text,
|
|
info);
|
|
EXPECT_EQ(probe->show_calls, 1);
|
|
EXPECT_EQ(probe->request.text_box, 301);
|
|
EXPECT_TRUE(probe->request.show_native_input);
|
|
EXPECT_EQ(probe->request.text, "login");
|
|
|
|
CallVoidMethodVForTest(env, callback, show, static_cast<jlong>(302),
|
|
JNI_FALSE, text, info);
|
|
EXPECT_EQ(probe->show_calls, 2);
|
|
EXPECT_EQ(probe->request.text_box, 302);
|
|
|
|
jvalue args[4] = {};
|
|
args[0].j = 303;
|
|
args[1].z = JNI_TRUE;
|
|
args[2].l = text;
|
|
args[3].l = info;
|
|
env->CallVoidMethodA(callback, show, args);
|
|
EXPECT_EQ(probe->show_calls, 3);
|
|
EXPECT_EQ(probe->request.text_box, 303);
|
|
|
|
env->CallVoidMethod(callback, replace, env->NewStringUTF("changed"));
|
|
EXPECT_EQ(probe->replace_calls, 1);
|
|
EXPECT_EQ(probe->replacement, "changed");
|
|
env->CallVoidMethodA(callback, properties, nullptr);
|
|
EXPECT_EQ(probe->properties_calls, 1);
|
|
env->CallVoidMethod(callback, hide);
|
|
EXPECT_EQ(probe->hide_calls, 1);
|
|
|
|
const jmethodID wrong_signature =
|
|
env->GetMethodID(callback_class, "q", "(JLjava/lang/String;)V");
|
|
env->CallVoidMethod(callback, wrong_signature, static_cast<jlong>(304),
|
|
env->NewStringUTF("ignored"));
|
|
jclass wrong_class = env->FindClass("example/EngineJavaCallback2");
|
|
jobject wrong_callback = env->NewObject(wrong_class, constructor);
|
|
env->CallVoidMethod(wrong_callback, show, static_cast<jlong>(304), JNI_FALSE,
|
|
text, info);
|
|
EXPECT_EQ(probe->show_calls, 3);
|
|
}
|
|
|
|
TEST_F(JniVmTest, RobloxTextInputBindingRetainsContextDuringInflightShow) {
|
|
auto probe = std::make_shared<RobloxTextInputProbe>();
|
|
std::weak_ptr<RobloxTextInputProbe> weak_probe = probe;
|
|
probe->block_show = true;
|
|
vm_->SetRobloxTextInputCallbacks(probe, ProbeTextInputCallbacks());
|
|
|
|
RobloxTextInputShowRequest request;
|
|
request.text_box = 7;
|
|
request.text = "sensitive transient text";
|
|
std::thread worker([this, request]() {
|
|
EXPECT_TRUE(vm_->DispatchRobloxTextInputShow(request));
|
|
});
|
|
{
|
|
std::unique_lock<std::mutex> lock(probe->mutex);
|
|
probe->condition.wait(lock, [probe]() { return probe->show_entered; });
|
|
}
|
|
vm_->ClearRobloxTextInputCallbacks();
|
|
probe.reset();
|
|
EXPECT_FALSE(weak_probe.expired());
|
|
auto retained = weak_probe.lock();
|
|
ASSERT_NE(retained, nullptr);
|
|
EXPECT_EQ(retained->shutdown_calls, 0);
|
|
{
|
|
std::lock_guard<std::mutex> lock(retained->mutex);
|
|
retained->release_show = true;
|
|
}
|
|
retained->condition.notify_all();
|
|
worker.join();
|
|
EXPECT_EQ(retained->shutdown_calls, 1);
|
|
retained.reset();
|
|
EXPECT_TRUE(weak_probe.expired());
|
|
}
|
|
|
|
TEST_F(JniVmTest, PlatformSystemDialogHandlerReturnsNoOpInstance) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass handler_class = env->FindClass(
|
|
"com/roblox/protocols/systemdialog/PlatformSystemDialogHandler");
|
|
ASSERT_NE(handler_class, nullptr);
|
|
|
|
jfieldID instance_field = env->GetStaticFieldID(
|
|
handler_class, "INSTANCE",
|
|
"Lcom/roblox/protocols/systemdialog/PlatformSystemDialogHandler;");
|
|
ASSERT_NE(instance_field, nullptr);
|
|
|
|
jobject handler = env->GetStaticObjectField(handler_class, instance_field);
|
|
ASSERT_NE(handler, nullptr);
|
|
|
|
jmethodID is_available =
|
|
env->GetMethodID(handler_class, "isAvailable", "()Z");
|
|
EXPECT_EQ(env->CallBooleanMethod(handler, is_available), JNI_TRUE);
|
|
|
|
jmethodID open = env->GetMethodID(
|
|
handler_class, "open",
|
|
"(Lcom/roblox/protocols/systemdialogplatforminterface/generated/"
|
|
"SystemDialogRequest;"
|
|
"Lcom/roblox/protocols/systemdialogplatforminterface/generated/"
|
|
"ISystemDialogCallback;)J");
|
|
EXPECT_GT(env->CallLongMethod(handler, open, nullptr, nullptr), 0);
|
|
}
|
|
|
|
TEST_F(JniVmTest, NativeGlJavaInterfaceReturnsTextBoxBackingObject) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass gl_class =
|
|
env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
|
|
ASSERT_NE(gl_class, nullptr);
|
|
|
|
jmethodID get_text_box = env->GetStaticMethodID(
|
|
gl_class, "getTextBoxInfo",
|
|
"()Lcom/roblox/engine/jni/model/NativeTextBoxInfo;");
|
|
ASSERT_NE(get_text_box, nullptr);
|
|
|
|
jobject text_box = env->CallStaticObjectMethod(gl_class, get_text_box);
|
|
ASSERT_NE(text_box, nullptr);
|
|
|
|
jclass text_box_class =
|
|
env->FindClass("com/roblox/engine/jni/model/NativeTextBoxInfo");
|
|
ASSERT_NE(text_box_class, nullptr);
|
|
|
|
jfieldID text_field =
|
|
env->GetFieldID(text_box_class, "text", "Ljava/lang/String;");
|
|
ASSERT_NE(text_field, nullptr);
|
|
auto text = static_cast<jstring>(env->GetObjectField(text_box, text_field));
|
|
ASSERT_NE(text, nullptr);
|
|
EXPECT_STREQ(env->GetStringUTFChars(text, nullptr), "");
|
|
|
|
jfieldID cursor_position =
|
|
env->GetFieldID(text_box_class, "cursorPosition", "I");
|
|
ASSERT_NE(cursor_position, nullptr);
|
|
EXPECT_EQ(env->GetIntField(text_box, cursor_position), 0);
|
|
}
|
|
|
|
TEST_F(JniVmTest, MessageBusConnectionIsNoOpAndConnected) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass message_bus_class =
|
|
env->FindClass("com/roblox/universalapp/messagebus/MessageBus");
|
|
ASSERT_NE(message_bus_class, nullptr);
|
|
|
|
jmethodID connect = env->GetStaticMethodID(
|
|
message_bus_class, "connect",
|
|
"()Lcom/roblox/universalapp/messagebus/Connection;");
|
|
ASSERT_NE(connect, nullptr);
|
|
|
|
jobject connection = env->CallStaticObjectMethod(message_bus_class, connect);
|
|
ASSERT_NE(connection, nullptr);
|
|
|
|
jclass connection_class =
|
|
env->FindClass("com/roblox/universalapp/messagebus/Connection");
|
|
ASSERT_NE(connection_class, nullptr);
|
|
|
|
jmethodID is_connected =
|
|
env->GetMethodID(connection_class, "isConnected", "()Z");
|
|
ASSERT_NE(is_connected, nullptr);
|
|
EXPECT_EQ(env->CallBooleanMethod(connection, is_connected), JNI_TRUE);
|
|
|
|
jmethodID disconnect =
|
|
env->GetMethodID(connection_class, "disconnect", "()V");
|
|
ASSERT_NE(disconnect, nullptr);
|
|
env->CallVoidMethod(connection, disconnect);
|
|
EXPECT_EQ(env->CallBooleanMethod(connection, is_connected), JNI_TRUE);
|
|
}
|
|
|
|
TEST_F(JniVmTest, MemStorageCallbackDispatchesExactMethodAcrossCallForms) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
auto probe = std::make_shared<MemStorageCallbackProbe>();
|
|
jobject callback =
|
|
vm_->CreateMemStorageCallback(probe, ProbeMemStorageCallbacks());
|
|
ASSERT_NE(callback, nullptr);
|
|
|
|
jclass callback_class =
|
|
env->FindClass("com/roblox/engine/jni/memstorage/Callback");
|
|
ASSERT_NE(callback_class, nullptr);
|
|
jmethodID on_item_set =
|
|
env->GetMethodID(callback_class, "onItemSet", "(Ljava/lang/String;)V");
|
|
ASSERT_NE(on_item_set, nullptr);
|
|
|
|
env->CallVoidMethod(callback, on_item_set, env->NewStringUTF("open"));
|
|
CallVoidMethodVForTest(env, callback, on_item_set,
|
|
env->NewStringUTF("config"));
|
|
jvalue args[1] = {};
|
|
args[0].l = env->NewStringUTF("close");
|
|
env->CallVoidMethodA(callback, on_item_set, args);
|
|
ASSERT_EQ(probe->values.size(), 3u);
|
|
EXPECT_EQ(probe->values[0], "open");
|
|
EXPECT_EQ(probe->values[1], "config");
|
|
EXPECT_EQ(probe->values[2], "close");
|
|
|
|
env->CallVoidMethod(callback, on_item_set, nullptr);
|
|
jmethodID wrong_signature =
|
|
env->GetMethodID(callback_class, "onItemSet", "()V");
|
|
env->CallVoidMethod(callback, wrong_signature);
|
|
jmethodID wrong_name =
|
|
env->GetMethodID(callback_class, "run", "(Ljava/lang/String;)V");
|
|
env->CallVoidMethod(callback, wrong_name, env->NewStringUTF("ignored"));
|
|
jclass wrong_class = env->FindClass("example/memstorage/Callback");
|
|
jobject wrong_callback = env->AllocObject(wrong_class);
|
|
env->CallVoidMethod(wrong_callback, on_item_set,
|
|
env->NewStringUTF("ignored"));
|
|
EXPECT_EQ(probe->calls, 3);
|
|
|
|
vm_->ClearMemStorageCallback(callback);
|
|
env->CallVoidMethod(callback, on_item_set, env->NewStringUTF("ignored"));
|
|
EXPECT_EQ(probe->calls, 3);
|
|
EXPECT_FALSE(vm_->DispatchMemStorageCallback(callback, env,
|
|
env->NewStringUTF("ignored")));
|
|
|
|
EXPECT_EQ(vm_->CreateMemStorageCallback(nullptr, ProbeMemStorageCallbacks()),
|
|
nullptr);
|
|
EXPECT_EQ(vm_->CreateMemStorageCallback(probe, MemStorageCallbackCallbacks{}),
|
|
nullptr);
|
|
}
|
|
|
|
TEST_F(JniVmTest, MemStorageCallbackRetainsContextDuringInflightDispatch) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
auto probe = std::make_shared<MemStorageCallbackProbe>();
|
|
std::weak_ptr<MemStorageCallbackProbe> weak_probe = probe;
|
|
probe->block = true;
|
|
jobject callback =
|
|
vm_->CreateMemStorageCallback(probe, ProbeMemStorageCallbacks());
|
|
ASSERT_NE(callback, nullptr);
|
|
jstring value = env->NewStringUTF("BrowserService.OpenBrowserWindow");
|
|
|
|
bool dispatched = false;
|
|
std::thread worker([this, callback, env, value, &dispatched]() {
|
|
dispatched = vm_->DispatchMemStorageCallback(callback, env, value);
|
|
});
|
|
{
|
|
std::unique_lock<std::mutex> lock(probe->mutex);
|
|
probe->condition.wait(lock, [probe]() { return probe->entered; });
|
|
}
|
|
|
|
vm_->ClearMemStorageCallback(callback);
|
|
probe.reset();
|
|
EXPECT_FALSE(weak_probe.expired());
|
|
auto retained = weak_probe.lock();
|
|
ASSERT_NE(retained, nullptr);
|
|
{
|
|
std::lock_guard<std::mutex> lock(retained->mutex);
|
|
retained->release = true;
|
|
}
|
|
retained->condition.notify_all();
|
|
worker.join();
|
|
EXPECT_TRUE(dispatched);
|
|
EXPECT_EQ(retained->calls, 1);
|
|
retained.reset();
|
|
EXPECT_TRUE(weak_probe.expired());
|
|
}
|
|
|
|
TEST_F(JniVmTest, MemStorageConnectionConstructorPreservesNativeRef) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass connection_class =
|
|
env->FindClass("com/roblox/engine/jni/memstorage/Connection");
|
|
ASSERT_NE(connection_class, nullptr);
|
|
jmethodID constructor = env->GetMethodID(connection_class, "<init>", "(J)V");
|
|
ASSERT_NE(constructor, nullptr);
|
|
jfieldID ref = env->GetFieldID(connection_class, "ref", "J");
|
|
ASSERT_NE(ref, nullptr);
|
|
|
|
jobject direct = env->NewObject(connection_class, constructor,
|
|
static_cast<jlong>(0x1122334455667788));
|
|
ASSERT_NE(direct, nullptr);
|
|
EXPECT_EQ(env->GetLongField(direct, ref),
|
|
static_cast<jlong>(0x1122334455667788));
|
|
|
|
jobject v_form = NewObjectVForTest(env, connection_class, constructor,
|
|
static_cast<jlong>(0x2233445566778899));
|
|
ASSERT_NE(v_form, nullptr);
|
|
EXPECT_EQ(env->GetLongField(v_form, ref),
|
|
static_cast<jlong>(0x2233445566778899));
|
|
|
|
jvalue args[1] = {};
|
|
args[0].j = static_cast<jlong>(0x33445566778899aa);
|
|
jobject a_form = env->NewObjectA(connection_class, constructor, args);
|
|
ASSERT_NE(a_form, nullptr);
|
|
EXPECT_EQ(env->GetLongField(a_form, ref),
|
|
static_cast<jlong>(0x33445566778899aa));
|
|
|
|
jmethodID wrong_constructor =
|
|
env->GetMethodID(connection_class, "<init>", "()V");
|
|
jobject wrong = env->NewObject(connection_class, wrong_constructor);
|
|
ASSERT_NE(wrong, nullptr);
|
|
EXPECT_EQ(env->GetLongField(wrong, ref), 0);
|
|
}
|
|
|
|
TEST_F(JniVmTest, DesktopPlatformIdentityAdvertisesPcWithoutTouchscreen) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass package_manager_class =
|
|
env->FindClass("android/content/pm/PackageManager");
|
|
ASSERT_NE(package_manager_class, nullptr);
|
|
jobject package_manager = env->AllocObject(package_manager_class);
|
|
ASSERT_NE(package_manager, nullptr);
|
|
jmethodID has_feature = env->GetMethodID(
|
|
package_manager_class, "hasSystemFeature", "(Ljava/lang/String;)Z");
|
|
ASSERT_NE(has_feature, nullptr);
|
|
|
|
jstring pc = env->NewStringUTF("android.hardware.type.pc");
|
|
jstring touchscreen = env->NewStringUTF("android.hardware.touchscreen");
|
|
EXPECT_EQ(env->CallBooleanMethod(package_manager, has_feature, pc), JNI_TRUE);
|
|
EXPECT_EQ(env->CallBooleanMethod(package_manager, has_feature, touchscreen),
|
|
JNI_FALSE);
|
|
|
|
jobject configuration = CreateAndroidConfiguration(env);
|
|
ASSERT_NE(configuration, nullptr);
|
|
jclass configuration_class = env->GetObjectClass(configuration);
|
|
ASSERT_NE(configuration_class, nullptr);
|
|
const auto read_int = [env, configuration,
|
|
configuration_class](const char *name) {
|
|
return env->GetIntField(configuration,
|
|
env->GetFieldID(configuration_class, name, "I"));
|
|
};
|
|
EXPECT_EQ(read_int("touchscreen"), 1);
|
|
EXPECT_EQ(read_int("keyboard"), 2);
|
|
EXPECT_EQ(read_int("keyboardHidden"), 1);
|
|
EXPECT_EQ(read_int("hardKeyboardHidden"), 1);
|
|
|
|
PlatformIdentity touch_profile;
|
|
touch_profile.touch_enabled = true;
|
|
touch_profile.pc_hardware = false;
|
|
vm_->SetPlatformIdentity(touch_profile);
|
|
EXPECT_EQ(env->CallBooleanMethod(package_manager, has_feature, pc),
|
|
JNI_FALSE);
|
|
EXPECT_EQ(env->CallBooleanMethod(package_manager, has_feature, touchscreen),
|
|
JNI_TRUE);
|
|
}
|
|
|
|
TEST_F(JniVmTest, LocalFramesReleaseInnerRefsAndPromoteResult) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jstring keep = env->NewStringUTF("keep");
|
|
ASSERT_NE(keep, nullptr);
|
|
ASSERT_EQ(env->PushLocalFrame(16), JNI_OK);
|
|
jstring inner = env->NewStringUTF("inner");
|
|
ASSERT_NE(inner, nullptr);
|
|
EXPECT_EQ(ReadJavaString(env, inner), "inner");
|
|
EXPECT_EQ(env->PopLocalFrame(nullptr), nullptr);
|
|
EXPECT_EQ(ReadJavaString(env, inner), "");
|
|
EXPECT_EQ(ReadJavaString(env, keep), "keep");
|
|
|
|
ASSERT_EQ(env->PushLocalFrame(16), JNI_OK);
|
|
jstring promoted = env->NewStringUTF("promoted");
|
|
jobject result = env->PopLocalFrame(promoted);
|
|
ASSERT_NE(result, nullptr);
|
|
EXPECT_EQ(ReadJavaString(env, static_cast<jstring>(result)), "promoted");
|
|
}
|
|
|
|
TEST_F(JniVmTest, GlobalRefSurvivesLocalFramePop) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
ASSERT_EQ(env->PushLocalFrame(16), JNI_OK);
|
|
jstring inner = env->NewStringUTF("global");
|
|
jobject global = env->NewGlobalRef(inner);
|
|
ASSERT_NE(global, nullptr);
|
|
env->PopLocalFrame(nullptr);
|
|
EXPECT_EQ(ReadJavaString(env, static_cast<jstring>(global)), "global");
|
|
env->DeleteGlobalRef(global);
|
|
}
|
|
|
|
TEST_F(JniVmTest, EncodedHandlesStayValidPastSignedShiftBoundary) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
ASSERT_EQ(env->PushLocalFrame(16), JNI_OK);
|
|
jstring high = nullptr;
|
|
for (int i = 0; i < 32768; ++i) {
|
|
high = env->NewStringUTF("slot");
|
|
}
|
|
ASSERT_NE(high, nullptr);
|
|
EXPECT_EQ(ReadJavaString(env, high), "slot");
|
|
env->PopLocalFrame(nullptr);
|
|
}
|
|
|
|
TEST_F(JniVmTest, MainGameActivityReturnsNativeHelperAndTracksCallbacks) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass activity_class =
|
|
env->FindClass("com/roblox/client/startup/MainGameActivity");
|
|
ASSERT_NE(activity_class, nullptr);
|
|
jobject activity = env->AllocObject(activity_class);
|
|
ASSERT_NE(activity, nullptr);
|
|
|
|
jmethodID get_native_helper =
|
|
env->GetMethodID(activity_class, "getNativeHelper",
|
|
"()Lcom/roblox/client/startup/NativeHelper;");
|
|
ASSERT_NE(get_native_helper, nullptr);
|
|
jobject helper = env->CallObjectMethod(activity, get_native_helper);
|
|
ASSERT_NE(helper, nullptr);
|
|
|
|
jclass helper_class =
|
|
env->FindClass("com/roblox/client/startup/NativeHelper");
|
|
ASSERT_NE(helper_class, nullptr);
|
|
jfieldID activity_field = env->GetFieldID(
|
|
helper_class, "activity", "Lcom/roblox/client/startup/MainGameActivity;");
|
|
ASSERT_NE(activity_field, nullptr);
|
|
EXPECT_EQ(env->GetObjectField(helper, activity_field), activity);
|
|
|
|
jfieldID is_engine_initialized =
|
|
env->GetFieldID(helper_class, "isEngineInitialized", "Z");
|
|
ASSERT_NE(is_engine_initialized, nullptr);
|
|
EXPECT_EQ(env->GetBooleanField(helper, is_engine_initialized), JNI_FALSE);
|
|
|
|
jmethodID bootstrap =
|
|
env->GetMethodID(activity_class, "bootstrapTheApp", "()V");
|
|
ASSERT_NE(bootstrap, nullptr);
|
|
env->CallVoidMethod(activity, bootstrap);
|
|
|
|
jfieldID bootstrap_started =
|
|
env->GetFieldID(activity_class, "bootstrapStarted", "Z");
|
|
ASSERT_NE(bootstrap_started, nullptr);
|
|
EXPECT_EQ(env->GetBooleanField(activity, bootstrap_started), JNI_TRUE);
|
|
|
|
jmethodID on_engine_initialized =
|
|
env->GetMethodID(helper_class, "gameActivity_onEngineInitialized", "()V");
|
|
ASSERT_NE(on_engine_initialized, nullptr);
|
|
env->CallVoidMethod(helper, on_engine_initialized);
|
|
EXPECT_EQ(env->GetBooleanField(helper, is_engine_initialized), JNI_TRUE);
|
|
}
|
|
|
|
TEST_F(JniVmTest, NativeQuoteInterfaceReportsKeystoreUnavailable) {
|
|
JNIEnv* env = vm_->GetJNIEnv();
|
|
jclass cls = env->FindClass("com/roblox/engine/jni/NativeQuoteInterface");
|
|
ASSERT_NE(cls, nullptr);
|
|
jmethodID method =
|
|
env->GetStaticMethodID(cls, "requestResponse", "([B)[B");
|
|
ASSERT_NE(method, nullptr);
|
|
|
|
jbyteArray challenge = env->NewByteArray(32);
|
|
ASSERT_NE(challenge, nullptr);
|
|
auto result = static_cast<jbyteArray>(
|
|
env->CallStaticObjectMethod(cls, method, challenge));
|
|
ASSERT_NE(result, nullptr);
|
|
|
|
const jsize length = env->GetArrayLength(result);
|
|
ASSERT_GT(length, 2);
|
|
std::vector<jbyte> bytes(static_cast<std::size_t>(length));
|
|
env->GetByteArrayRegion(result, 0, length, bytes.data());
|
|
EXPECT_EQ(bytes[0], 1);
|
|
EXPECT_EQ(bytes[1], 0);
|
|
const std::string message(reinterpret_cast<const char*>(bytes.data() + 2),
|
|
static_cast<std::size_t>(length - 2));
|
|
EXPECT_NE(message.find("AndroidKeyStore"), std::string::npos);
|
|
}
|
|
|
|
TEST_F(JniVmTest, DetachCurrentThreadReleasesThatThreadsLocalRefs) {
|
|
JavaVM *java_vm = vm_->GetJavaVM();
|
|
for (int round = 0; round < 4; ++round) {
|
|
bool all_allocated = true;
|
|
std::thread worker([java_vm, &all_allocated]() {
|
|
JNIEnv *env = nullptr;
|
|
if (java_vm->AttachCurrentThread(reinterpret_cast<void **>(&env), nullptr) !=
|
|
JNI_OK) {
|
|
all_allocated = false;
|
|
return;
|
|
}
|
|
for (int i = 0; i < 40000; ++i) {
|
|
if (env->NewStringUTF("thread local") == nullptr) {
|
|
all_allocated = false;
|
|
break;
|
|
}
|
|
}
|
|
java_vm->DetachCurrentThread();
|
|
});
|
|
worker.join();
|
|
ASSERT_TRUE(all_allocated) << "round " << round;
|
|
}
|
|
}
|
|
|
|
TEST_F(JniVmTest, DetachCurrentThreadKeepsVmOwnedObjectsAlive) {
|
|
JavaVM *java_vm = vm_->GetJavaVM();
|
|
jobject implementation = nullptr;
|
|
jobject params = nullptr;
|
|
std::thread worker([java_vm, &implementation, ¶ms]() {
|
|
JNIEnv *env = nullptr;
|
|
if (java_vm->AttachCurrentThread(reinterpret_cast<void **>(&env), nullptr) !=
|
|
JNI_OK) {
|
|
return;
|
|
}
|
|
jclass gl_class =
|
|
env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
|
|
implementation = env->CallStaticObjectMethod(
|
|
gl_class,
|
|
env->GetStaticMethodID(gl_class, "getImplementation",
|
|
"()Lcom/roblox/engine/jni/EngineJavaCallback2;"));
|
|
params = env->CallStaticObjectMethod(
|
|
gl_class,
|
|
env->GetStaticMethodID(
|
|
gl_class, "getDeviceStaticParams",
|
|
"()Lcom/roblox/engine/jni/model/DeviceStaticParams;"));
|
|
java_vm->DetachCurrentThread();
|
|
});
|
|
worker.join();
|
|
ASSERT_NE(implementation, nullptr);
|
|
ASSERT_NE(params, nullptr);
|
|
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
for (int i = 0; i < 64; ++i) {
|
|
EXPECT_NE(reinterpret_cast<jobject>(env->NewStringUTF("reused slot")),
|
|
implementation);
|
|
}
|
|
jclass params_class =
|
|
env->FindClass("com/roblox/engine/jni/model/DeviceStaticParams");
|
|
jfieldID os_version =
|
|
env->GetFieldID(params_class, "osVersion", "Ljava/lang/String;");
|
|
EXPECT_EQ(ReadJavaString(env, static_cast<jstring>(
|
|
env->GetObjectField(params, os_version))),
|
|
"Android 13");
|
|
}
|
|
|
|
TEST_F(JniVmTest, DeletedHighIndexStringHandleReadsAsEmpty) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
std::vector<jstring> strings;
|
|
for (int i = 0; i < 8000; ++i) {
|
|
strings.push_back(env->NewStringUTF("filler"));
|
|
}
|
|
jstring high = strings.back();
|
|
ASSERT_GT(reinterpret_cast<uintptr_t>(high), uintptr_t{0x10000000});
|
|
for (jstring text : strings) {
|
|
env->DeleteLocalRef(text);
|
|
}
|
|
EXPECT_EQ(env->GetStringUTFLength(high), 0);
|
|
EXPECT_STREQ(env->GetStringUTFChars(high, nullptr), "");
|
|
EXPECT_EQ(env->GetStringLength(high), 0);
|
|
}
|
|
|
|
std::size_t CountOccurrences(const std::string &text,
|
|
const std::string &needle) {
|
|
std::size_t count = 0;
|
|
for (std::size_t at = text.find(needle); at != std::string::npos;
|
|
at = text.find(needle, at + needle.size())) {
|
|
++count;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
TEST_F(JniVmTest, UnhandledCallIsReportedOncePerClassAndMethod) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass first = env->FindClass("example/UnhandledFirst");
|
|
jclass second = env->FindClass("example/UnhandledSecond");
|
|
const jmethodID constructor = env->GetMethodID(first, "<init>", "()V");
|
|
jobject first_object = env->NewObject(first, constructor);
|
|
jobject second_object = env->NewObject(second, constructor);
|
|
const jmethodID method = env->GetMethodID(first, "zzReportedVoid", "(I)V");
|
|
|
|
::testing::internal::CaptureStderr();
|
|
env->CallVoidMethod(first_object, method, 1);
|
|
env->CallVoidMethod(first_object, method, 2);
|
|
CallVoidMethodVForTest(env, first_object, method, 3);
|
|
env->CallVoidMethod(second_object, method, 4);
|
|
const std::string output = ::testing::internal::GetCapturedStderr();
|
|
|
|
EXPECT_EQ(CountOccurrences(output, " [JNI] no handler for "
|
|
"example/UnhandledFirst.zzReportedVoid(I)V\n"),
|
|
1u);
|
|
EXPECT_EQ(CountOccurrences(output, " [JNI] no handler for "
|
|
"example/UnhandledSecond.zzReportedVoid(I)V\n"),
|
|
1u);
|
|
}
|
|
|
|
TEST_F(JniVmTest, UnhandledCallsAreReportedForEveryResultType) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass cls = env->FindClass("example/UnhandledResults");
|
|
jobject object =
|
|
env->NewObject(cls, env->GetMethodID(cls, "<init>", "()V"));
|
|
const auto id = [&](const char *name, const char *sig) {
|
|
return env->GetMethodID(cls, name, sig);
|
|
};
|
|
const auto static_id = [&](const char *name, const char *sig) {
|
|
return env->GetStaticMethodID(cls, name, sig);
|
|
};
|
|
|
|
::testing::internal::CaptureStderr();
|
|
EXPECT_EQ(env->CallObjectMethod(object, id("zzObject", "()Ljava/lang/Object;")),
|
|
nullptr);
|
|
EXPECT_EQ(env->CallBooleanMethod(object, id("zzBoolean", "()Z")), JNI_FALSE);
|
|
EXPECT_EQ(env->CallIntMethod(object, id("zzInt", "()I")), 0);
|
|
EXPECT_EQ(env->CallLongMethod(object, id("zzLong", "()J")), 0);
|
|
EXPECT_EQ(env->CallFloatMethod(object, id("zzFloat", "()F")), 0.0f);
|
|
EXPECT_EQ(env->CallDoubleMethod(object, id("zzDouble", "()D")), 0.0);
|
|
EXPECT_EQ(env->CallByteMethod(object, id("zzByte", "()B")), 0);
|
|
env->CallStaticVoidMethod(cls, static_id("zzStaticVoid", "()V"));
|
|
EXPECT_EQ(env->CallStaticObjectMethod(
|
|
cls, static_id("zzStaticObject", "()Ljava/lang/Object;")),
|
|
nullptr);
|
|
EXPECT_EQ(
|
|
env->CallStaticBooleanMethod(cls, static_id("zzStaticBoolean", "()Z")),
|
|
JNI_FALSE);
|
|
EXPECT_EQ(env->CallStaticIntMethod(cls, static_id("zzStaticInt", "()I")), 0);
|
|
EXPECT_EQ(env->CallStaticLongMethod(cls, static_id("zzStaticLong", "()J")),
|
|
0);
|
|
EXPECT_EQ(env->CallStaticFloatMethod(cls, static_id("zzStaticFloat", "()F")),
|
|
0.0f);
|
|
const std::string output = ::testing::internal::GetCapturedStderr();
|
|
|
|
const std::string prefix = " [JNI] no handler for example/UnhandledResults.";
|
|
const std::string static_prefix =
|
|
" [JNI] no handler for static example/UnhandledResults.";
|
|
for (const char *method :
|
|
{"zzObject()Ljava/lang/Object;", "zzBoolean()Z", "zzInt()I", "zzLong()J",
|
|
"zzFloat()F", "zzDouble()D", "zzByte()B"}) {
|
|
EXPECT_EQ(CountOccurrences(output, prefix + method + "\n"), 1u) << method;
|
|
}
|
|
for (const char *method :
|
|
{"zzStaticVoid()V", "zzStaticObject()Ljava/lang/Object;",
|
|
"zzStaticBoolean()Z", "zzStaticInt()I", "zzStaticLong()J",
|
|
"zzStaticFloat()F"}) {
|
|
EXPECT_EQ(CountOccurrences(output, static_prefix + method + "\n"), 1u)
|
|
<< method;
|
|
}
|
|
}
|
|
|
|
TEST_F(JniVmTest, HandledAndDeliberatelyIgnoredCallsAreNotReported) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass cls = env->FindClass("example/HandledProbe");
|
|
jobject object =
|
|
env->NewObject(cls, env->GetMethodID(cls, "<init>", "()V"));
|
|
env->SetIntField(object, env->GetFieldID(cls, "zzCount", "I"), 7);
|
|
jclass callback_class =
|
|
env->FindClass("com/roblox/engine/jni/EngineJavaCallback2");
|
|
jobject callback = env->NewObject(
|
|
callback_class, env->GetMethodID(callback_class, "<init>", "()V"));
|
|
jclass helper_class = env->FindClass("com/roblox/client/startup/NativeHelper");
|
|
jobject helper = env->NewObject(
|
|
helper_class, env->GetMethodID(helper_class, "<init>", "()V"));
|
|
jvalue no_args[1] = {};
|
|
|
|
::testing::internal::CaptureStderr();
|
|
(void)env->CallIntMethod(object, env->GetMethodID(cls, "getScreenWidth", "()I"));
|
|
EXPECT_EQ(env->CallIntMethod(object, env->GetMethodID(cls, "getZzCount", "()I")),
|
|
7);
|
|
env->CallVoidMethod(callback,
|
|
env->GetMethodID(callback_class, "c", "()V"));
|
|
env->CallVoidMethodA(
|
|
helper, env->GetMethodID(helper_class, "gameActivity_zzLater", "()V"),
|
|
no_args);
|
|
const std::string output = ::testing::internal::GetCapturedStderr();
|
|
|
|
EXPECT_EQ(output.find("no handler for"), std::string::npos) << output;
|
|
}
|
|
|
|
TEST_F(JniVmTest, UnmatchedRunAAndFVoidCallsAreReported) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass cls = env->FindClass("example/UnmatchedShortNames");
|
|
jobject object =
|
|
env->NewObject(cls, env->GetMethodID(cls, "<init>", "()V"));
|
|
jvalue no_args[1] = {};
|
|
|
|
::testing::internal::CaptureStderr();
|
|
env->CallVoidMethod(object, env->GetMethodID(cls, "run", "()V"));
|
|
env->CallVoidMethod(object, env->GetMethodID(cls, "f", "()V"));
|
|
env->CallVoidMethodA(object, env->GetMethodID(cls, "a", "()V"), no_args);
|
|
const std::string output = ::testing::internal::GetCapturedStderr();
|
|
|
|
const std::string prefix =
|
|
" [JNI] no handler for example/UnmatchedShortNames.";
|
|
for (const char *method : {"run()V", "f()V", "a()V"}) {
|
|
EXPECT_EQ(CountOccurrences(output, prefix + method + "\n"), 1u) << method;
|
|
}
|
|
}
|
|
|
|
TEST_F(JniVmTest, UnknownGameActivityCallbackIsRecordedOnEveryVoidPath) {
|
|
JNIEnv *env = vm_->GetJNIEnv();
|
|
jclass helper_class = env->FindClass("com/roblox/client/startup/NativeHelper");
|
|
jobject helper = env->NewObject(
|
|
helper_class, env->GetMethodID(helper_class, "<init>", "()V"));
|
|
jvalue no_args[1] = {};
|
|
|
|
::testing::internal::CaptureStderr();
|
|
env->CallVoidMethod(
|
|
helper, env->GetMethodID(helper_class, "gameActivity_zzViaV", "()V"));
|
|
env->CallVoidMethodA(
|
|
helper, env->GetMethodID(helper_class, "gameActivity_zzViaA", "()V"),
|
|
no_args);
|
|
const std::string output = ::testing::internal::GetCapturedStderr();
|
|
|
|
EXPECT_EQ(output.find("no handler for"), std::string::npos) << output;
|
|
for (const char *name : {"gameActivity_zzViaV", "gameActivity_zzViaA"}) {
|
|
EXPECT_EQ(env->GetBooleanField(helper,
|
|
env->GetFieldID(helper_class, name, "Z")),
|
|
JNI_TRUE)
|
|
<< name;
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
} // namespace jnivm
|