Files
InstanceBrick--nightcap/tests/roblox_experience_composition_test.cc
T
vii b60c43e8f0 chore(merge): merge upstream komaruworld/mocktail main
Keep both the CallProtocol and AccountProtocol bridges. Keep our pointer
hold and catalog validation. Re-apply our JNI local-frame counting,
singleton handles, class handle cache, handle-table report and thread
detach release on upstream's split jnivm files.
2026-10-01 21:41:43 -05:00

1615 lines
64 KiB
C++

#include "runtime/roblox_experience_composition.h"
#include <gtest/gtest.h>
#include <sys/stat.h>
#include <atomic>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <map>
#include <memory>
#include <nlohmann/json.hpp>
#include <string>
#include <thread>
#include <vector>
#include "jnivm/jnivm.h"
#include "runtime/external_launch_broker.h"
namespace mocktail {
namespace runtime {
namespace {
struct Probe {
explicit Probe(jnivm::VM* source_vm) : vm(source_vm) {}
jnivm::VM* vm = nullptr;
jobject callback = nullptr;
FreshLaunchPresentObserver observer = nullptr;
void* observer_context = nullptr;
int64_t started_place = 0;
std::atomic<int> starts{0};
int leaves = 0;
int destroys = 0;
int updates = 0;
int app_updates = 0;
int app_starts = 0;
int app_pauses = 0;
int app_surface_width = 0;
float app_dpi_scale = 0.0f;
int app_start_surface_width = 0;
int64_t app_user_id = 0;
std::vector<std::string> app_surface_lifecycle;
int disconnects = 0;
int surface_width = 0;
int join_request_type = -1;
int web_view_initializations = 0;
int permission_subscriptions = 0;
int permission_disconnects = 0;
int permission_handlers_cleared = 0;
std::map<std::string, jobject> permission_handlers;
std::map<std::string, jobject> account_handlers;
int account_handlers_cleared = 0;
std::string permission_response;
int browser_bindings = 0;
int browser_disconnects = 0;
int browser_releases = 0;
int browser_callbacks_created = 0;
int browser_callbacks_cleared = 0;
std::string web_view_javascript_callback;
bool publish_during_subscribe = false;
std::atomic<bool> block_start{false};
std::atomic<bool> start_entered{false};
std::atomic<bool> release_start{false};
GameSurface observed_surface;
std::vector<RobloxExperiencePresencePhase> presence_phases;
std::vector<int64_t> presence_places;
std::atomic<int> playing_presence_notifications{0};
};
Probe* g_probe = nullptr;
void Prepare(void* context) {
static_cast<jnivm::VM*>(context)->RestoreFunctions();
}
GameSurface SnapshotSurface(void* context) {
return static_cast<Probe*>(context)->observed_surface;
}
jnivm::RobloxCredentialView ProvideCredential(const void* context) {
const auto* credential = static_cast<const std::string*>(context);
return credential != nullptr ? jnivm::RobloxCredentialView{credential->data(),
credential->size()}
: jnivm::RobloxCredentialView{};
}
jobject CreateRawCallback(void* context, std::shared_ptr<void> callback_context,
void (*run)(void*, JNIEnv*, jstring)) {
auto* probe = static_cast<Probe*>(context);
return probe->vm->CreateMessageBusRawCallback(
std::move(callback_context), jnivm::MessageBusRawCallbacks{run});
}
void ClearRawCallback(void* context, jobject callback) {
static_cast<Probe*>(context)->vm->ClearMessageBusRawCallback(callback);
}
jobject CreateRequestHandler(void* context,
std::shared_ptr<void> callback_context,
std::string (*run)(void*, JNIEnv*, jstring)) {
return static_cast<Probe*>(context)->vm->CreateMessageBusRequestHandler(
std::move(callback_context),
jnivm::MessageBusRequestHandlerCallbacks{run});
}
void ClearRequestHandler(void* context, jobject handler) {
static_cast<Probe*>(context)->vm->ClearMessageBusRequestHandler(handler);
}
jobject CreateAsyncRequestHandler(void* context,
std::shared_ptr<void> callback_context,
void (*run)(void*, JNIEnv*, jstring,
jstring)) {
return static_cast<Probe*>(context)->vm->CreateMessageBusAsyncRequestHandler(
std::move(callback_context),
jnivm::MessageBusAsyncRequestHandlerCallbacks{run});
}
void ClearAsyncRequestHandler(void* context, jobject handler) {
static_cast<Probe*>(context)->vm->ClearMessageBusAsyncRequestHandler(handler);
}
jobject CreateMemStorageCallback(void* context,
std::shared_ptr<void> callback_context,
void (*on_item_set)(void*, JNIEnv*, jstring)) {
auto* probe = static_cast<Probe*>(context);
++probe->browser_callbacks_created;
return probe->vm->CreateMemStorageCallback(
std::move(callback_context),
jnivm::MemStorageCallbackCallbacks{on_item_set});
}
void ClearMemStorageCallback(void* context, jobject callback) {
auto* probe = static_cast<Probe*>(context);
++probe->browser_callbacks_cleared;
probe->vm->ClearMemStorageCallback(callback);
}
bool SetPlatformWebCallbacks(
void* context, std::shared_ptr<void> callback_context,
void (*on_data_model_notification)(void*, JNIEnv*, jstring, jstring),
void (*on_app_bridge_notification)(void*, JNIEnv*, jstring, jstring),
void (*on_native_overlay)(void*, JNIEnv*, jstring, jstring),
void (*on_open_web_activity)(void*, JNIEnv*, jstring, jstring),
void (*on_sync_cookies)(void*, JNIEnv*, jstring),
void (*on_set_cookie)(void*, JNIEnv*, jstring, jstring)) {
auto* probe = static_cast<Probe*>(context);
if (probe == nullptr || callback_context == nullptr ||
on_data_model_notification == nullptr ||
on_app_bridge_notification == nullptr || on_native_overlay == nullptr ||
on_open_web_activity == nullptr || on_sync_cookies == nullptr ||
on_set_cookie == nullptr) {
return false;
}
probe->vm->SetRobloxDataModelNotificationCallbacks(
std::move(callback_context),
jnivm::RobloxDataModelNotificationCallbacks{
on_data_model_notification, on_app_bridge_notification,
on_native_overlay, on_open_web_activity, on_sync_cookies,
on_set_cookie});
return true;
}
void ClearPlatformWebCallbacks(void* context) {
static_cast<Probe*>(context)->vm->ClearRobloxDataModelNotificationCallbacks();
}
jobject BindMemStorage(JNIEnv* env, jclass, jstring, jobject) {
const jlong connection_ref = ++g_probe->browser_bindings;
jclass connection_class =
env->FindClass("com/roblox/engine/jni/memstorage/Connection");
jmethodID constructor =
connection_class != nullptr
? env->GetMethodID(connection_class, "<init>", "(J)V")
: nullptr;
jobject connection =
constructor != nullptr
? env->NewObject(connection_class, constructor, connection_ref)
: nullptr;
if (connection_class != nullptr) {
env->DeleteLocalRef(connection_class);
}
return connection;
}
void DisconnectMemStorage(JNIEnv*, jclass, jobject) {
++g_probe->browser_disconnects;
}
void ReleaseMemStorageConnection(JNIEnv*, jclass, jobject) {
++g_probe->browser_releases;
}
void FireMemStorage(JNIEnv*, jclass, jstring, jstring) {}
RobloxBrowserServiceSymbols BrowserServiceSymbols() {
return {BindMemStorage, DisconnectMemStorage, ReleaseMemStorageConnection,
FireMemStorage};
}
RobloxExperienceJniFactory JniFactory(Probe* probe) {
return {probe,
CreateRawCallback,
ClearRawCallback,
CreateRequestHandler,
ClearRequestHandler,
CreateMemStorageCallback,
ClearMemStorageCallback,
SetPlatformWebCallbacks,
ClearPlatformWebCallbacks,
CreateAsyncRequestHandler,
ClearAsyncRequestHandler};
}
jobject SubscribePermission(JNIEnv* env, jobject, jstring, jstring, jobject,
jboolean) {
jclass cls = env->FindClass("com/roblox/universalapp/messagebus/Connection");
jmethodID ctor = env->GetMethodID(cls, "<init>", "(J)V");
return env->NewObject(
cls, ctor, static_cast<jlong>(++g_probe->permission_subscriptions));
}
void DisconnectPermission(JNIEnv*, jobject, jlong) {
++g_probe->permission_disconnects;
}
void PublishPermission(JNIEnv*, jobject, jstring, jstring, jstring, jint,
jstring) {}
void SetAsyncPermission(JNIEnv* env, jobject, jstring, jstring method,
jobject handler) {
const char* chars = env->GetStringUTFChars(method, nullptr);
g_probe->permission_handlers[chars] = handler;
env->ReleaseStringUTFChars(method, chars);
}
void ClearPermission(JNIEnv*, jobject, jstring, jstring) {
++g_probe->permission_handlers_cleared;
}
void ResolvePermission(JNIEnv* env, jobject, jstring, jstring response) {
const char* chars = env->GetStringUTFChars(response, nullptr);
g_probe->permission_response = chars;
env->ReleaseStringUTFChars(response, chars);
}
RobloxPermissionsMessageBusSymbols PermissionsSymbols() {
return {SubscribePermission, DisconnectPermission, PublishPermission,
SetAsyncPermission, ClearPermission, ResolvePermission};
}
nlohmann::json QueryMicrophone(Probe* probe) {
JNIEnv* env = probe->vm->GetJNIEnv();
jobject handler = probe->permission_handlers.at("HasPermissions");
jclass cls = env->GetObjectClass(handler);
jmethodID run =
env->GetMethodID(cls, "run", "(Ljava/lang/String;Ljava/lang/String;)V");
jstring message =
env->NewStringUTF(R"({"permissions":["MICROPHONE_ACCESS"]})");
jstring id = env->NewStringUTF("before-luaapp");
env->CallVoidMethod(handler, run, message, id);
env->DeleteLocalRef(message);
env->DeleteLocalRef(id);
env->DeleteLocalRef(cls);
return nlohmann::json::parse(probe->permission_response);
}
void SetRequestHandler(JNIEnv*, jobject, jstring, jstring, jobject) {}
std::string CopyJavaString(JNIEnv* env, jstring value) {
const char* chars = value ? env->GetStringUTFChars(value, nullptr) : nullptr;
const std::string copy = chars ? chars : "";
if (chars) env->ReleaseStringUTFChars(value, chars);
return copy;
}
jstring AccountProtocolName(JNIEnv* env, jclass) {
return env->NewStringUTF("AccountProtocol");
}
jstring AccountAvailableMethod(JNIEnv* env, jclass) {
return env->NewStringUTF("DeviceIntegrityAvailable");
}
jstring AccountTokenMethod(JNIEnv* env, jclass) {
return env->NewStringUTF("GetIntegrityToken");
}
jstring AccountSupportKey(JNIEnv* env, jclass) {
return env->NewStringUTF("support");
}
jstring AccountTokenKey(JNIEnv* env, jclass) {
return env->NewStringUTF("token");
}
jstring AccountResultKey(JNIEnv* env, jclass) {
return env->NewStringUTF("result");
}
void SetAccountHandler(JNIEnv* env, jobject, jstring, jstring method,
jobject handler) {
g_probe->account_handlers[CopyJavaString(env, method)] = handler;
}
void ClearAccountHandler(JNIEnv*, jobject, jstring, jstring) {
++g_probe->account_handlers_cleared;
}
RobloxAccountProtocolSymbols AccountSymbols() {
return {AccountProtocolName, AccountAvailableMethod, AccountTokenMethod,
AccountSupportKey, AccountTokenKey, AccountResultKey,
SetAccountHandler, ClearAccountHandler};
}
void ClearNativeRequestHandler(JNIEnv*, jobject, jstring, jstring) {}
void PublishRaw(JNIEnv*, jobject, jstring, jstring) {}
void BroadcastDataModelFocus(JNIEnv*, jclass, jstring, jstring, jstring) {}
jstring GetLaunchId(JNIEnv* env, jclass) {
return env->NewStringUTF("experienceLaunch");
}
jstring GetWebViewOpenId(JNIEnv* env, jclass) {
return env->NewStringUTF("openWindow");
}
jstring GetWebViewMutateId(JNIEnv* env, jclass) {
return env->NewStringUTF("mutateWindow");
}
jstring GetWebViewCloseId(JNIEnv* env, jclass) {
return env->NewStringUTF("closeWindow");
}
jstring GetWebViewHandleWindowCloseId(JNIEnv* env, jclass) {
return env->NewStringUTF("handleWindowClose");
}
jstring GetWebViewProtocolName(JNIEnv* env, jclass) {
return env->NewStringUTF("WebView");
}
jstring GetWebViewAvailabilityId(JNIEnv* env, jclass) {
return env->NewStringUTF("isAvailable");
}
jstring GetWebViewMessageId(JNIEnv* env, jclass, jstring protocol_name,
jstring method_name) {
const char* protocol = env->GetStringUTFChars(protocol_name, nullptr);
const char* method = env->GetStringUTFChars(method_name, nullptr);
const std::string message_id =
std::string(protocol != nullptr ? protocol : "") + "." +
(method != nullptr ? method : "");
if (protocol != nullptr) {
env->ReleaseStringUTFChars(protocol_name, protocol);
}
if (method != nullptr) {
env->ReleaseStringUTFChars(method_name, method);
}
return env->NewStringUTF(message_id.c_str());
}
void InitializeWebViewProtocol(JNIEnv*, jclass) {
++g_probe->web_view_initializations;
}
void SignalWebViewJavascriptCallback(JNIEnv* env, jclass, jstring payload) {
const char* payload_chars = env->GetStringUTFChars(payload, nullptr);
g_probe->web_view_javascript_callback =
payload_chars != nullptr ? payload_chars : "";
if (payload_chars != nullptr) {
env->ReleaseStringUTFChars(payload, payload_chars);
}
}
void UpdateCookieSetHandler(JNIEnv*, jobject, jobject) {}
jobject Subscribe(JNIEnv* env, jobject, jstring id, jobject callback,
jboolean) {
const char* id_chars = env->GetStringUTFChars(id, nullptr);
const bool experience =
id_chars != nullptr && std::string(id_chars) == "experienceLaunch";
if (id_chars != nullptr) {
env->ReleaseStringUTFChars(id, id_chars);
}
if (experience) {
g_probe->callback = callback;
}
if (experience && g_probe->publish_during_subscribe) {
jclass callback_class = env->GetObjectClass(callback);
jmethodID run =
env->GetMethodID(callback_class, "run", "(Ljava/lang/String;)V");
jstring request = env->NewStringUTF(
R"({"placeId":17580461965,"gameJoinContext":"PlayButton"})");
env->CallVoidMethod(callback, run, request);
}
jclass clazz =
env->FindClass("com/roblox/universalapp/messagebus/Connection");
jmethodID constructor = env->GetMethodID(clazz, "<init>", "(J)V");
jobject connection =
env->NewObject(clazz, constructor, static_cast<jlong>(91));
env->DeleteLocalRef(clazz);
return connection;
}
void Disconnect(JNIEnv*, jobject, jlong handle) {
if (handle == 91)
++g_probe->disconnects;
}
void Foreground(JNIEnv* env, jclass, jboolean enabled, jstring reason) {
if (reason == nullptr)
return;
const char* chars = env->GetStringUTFChars(reason, nullptr);
const std::string value = chars != nullptr ? chars : "";
if (chars != nullptr)
env->ReleaseStringUTFChars(reason, chars);
if (value == "ASMA.stop" || value == "ASMA.start") {
g_probe->app_surface_lifecycle.push_back(
std::string(enabled == JNI_TRUE ? "background:" : "foreground:") +
value);
}
}
jint Start(JNIEnv* env, jclass, jobject params) {
++g_probe->starts;
g_probe->start_entered.store(true);
jclass clazz = env->GetObjectClass(params);
jfieldID place = env->GetFieldID(clazz, "placeId", "J");
g_probe->started_place = env->GetLongField(params, place);
g_probe->join_request_type =
env->GetIntField(params, env->GetFieldID(clazz, "joinRequestType", "I"));
jobject surface = env->GetObjectField(
params, env->GetFieldID(clazz, "surface", "Landroid/view/Surface;"));
jclass surface_class = env->GetObjectClass(surface);
g_probe->surface_width =
env->GetIntField(surface, env->GetFieldID(surface_class, "width", "I"));
env->DeleteLocalRef(clazz);
if (g_probe->observer != nullptr) {
g_probe->observer(g_probe->observer_context, 17);
}
while (g_probe->block_start.load() && !g_probe->release_start.load()) {
std::this_thread::yield();
}
return 1;
}
void Update(JNIEnv*, jclass, jobject, jobject, jobject) {
++g_probe->updates;
}
void UpdateApp(JNIEnv* env, jclass, jobject surface, jobject platform_params) {
++g_probe->app_updates;
jclass surface_class = env->GetObjectClass(surface);
g_probe->app_surface_width =
env->GetIntField(surface, env->GetFieldID(surface_class, "width", "I"));
jclass params_class = env->GetObjectClass(platform_params);
g_probe->app_dpi_scale = env->GetFloatField(
platform_params, env->GetFieldID(params_class, "dpiScale", "F"));
env->DeleteLocalRef(surface_class);
env->DeleteLocalRef(params_class);
}
void PauseGame(JNIEnv*, jclass) {}
void CallMessagesFromMainThread(JNIEnv*, jclass) {}
void ResumeGame(JNIEnv*, jclass, jobject, jobject, jobject) {}
void Leave(JNIEnv*, jclass) {
++g_probe->leaves;
}
void PauseApp(JNIEnv*, jclass) {
++g_probe->app_pauses;
g_probe->app_surface_lifecycle.emplace_back("pause-app");
}
void DestroyApp(JNIEnv*, jclass) {
++g_probe->destroys;
}
void StartApp(JNIEnv* env, jclass, jobject params) {
++g_probe->app_starts;
g_probe->app_surface_lifecycle.emplace_back("start-app");
jclass clazz = env->GetObjectClass(params);
g_probe->app_user_id =
env->GetLongField(params, env->GetFieldID(clazz, "appUserId", "J"));
jobject surface = env->GetObjectField(
params, env->GetFieldID(clazz, "surface", "Landroid/view/Surface;"));
jclass surface_class = env->GetObjectClass(surface);
g_probe->app_start_surface_width =
env->GetIntField(surface, env->GetFieldID(surface_class, "width", "I"));
env->DeleteLocalRef(surface_class);
env->DeleteLocalRef(surface);
env->DeleteLocalRef(clazz);
}
bool RegisterObserver(void* context, FreshLaunchPresentObserver observer,
void* observer_context) {
auto* probe = static_cast<Probe*>(context);
probe->observer = observer;
probe->observer_context = observer_context;
return true;
}
void ClearObserver(void* context) {
auto* probe = static_cast<Probe*>(context);
probe->observer = nullptr;
probe->observer_context = nullptr;
}
void NotifyCompositionReturn(void* context) {
static_cast<RobloxExperienceComposition*>(context)->NotifyLuaAppDidReturn();
}
void ObservePresence(void* context, RobloxExperiencePresencePhase phase,
const RobloxExperienceLaunchRequest* request) {
auto* probe = static_cast<Probe*>(context);
probe->presence_phases.push_back(phase);
probe->presence_places.push_back(request != nullptr ? request->place_id : 0);
if (phase == RobloxExperiencePresencePhase::kPlaying) {
++probe->playing_presence_notifications;
}
}
void DrainUntilPresented(RobloxExperienceComposition* composition) {
ASSERT_NE(composition, nullptr);
for (int attempt = 0; attempt < 1000; ++attempt) {
ASSERT_TRUE(composition->DrainLaunchRequests().ok());
if (composition->Snapshot().game_presented) {
ASSERT_TRUE(composition->DrainLaunchRequests().ok());
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
FAIL() << "fresh launch worker did not present a frame";
}
TEST(RobloxExperienceCompositionTest,
SubscribesAtLuaAppReadinessAndStartsFreshTypedGame) {
jnivm::VM vm;
Probe probe{&vm};
g_probe = &probe;
for (const char* class_name : {
"com/roblox/engine/jni/NativeGLInterface",
"android/view/Surface",
"com/roblox/engine/jni/model/PlatformParams",
"android/app/Activity",
"com/roblox/engine/jni/autovalue/AutoValue_StartGameParams",
"com/roblox/engine/jni/autovalue/StartAppParams",
"com/roblox/universalapp/messagebus/MessageBus",
"com/roblox/universalapp/messagebus/Connection",
"com/roblox/engine/jni/memstorage/MemStorage",
"com/roblox/engine/jni/memstorage/Connection",
"com/roblox/engine/jni/memstorage/Callback",
}) {
vm.RegisterClass(class_name);
}
RobloxExperienceComposition composition(
{vm.GetJavaVM(), &vm, Prepare}, {GetLaunchId, Subscribe, Disconnect},
{GetWebViewOpenId, GetWebViewHandleWindowCloseId, GetWebViewProtocolName,
GetWebViewAvailabilityId, GetWebViewMessageId, InitializeWebViewProtocol,
Subscribe, Disconnect, SetRequestHandler, ClearNativeRequestHandler,
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
GetWebViewCloseId, SignalWebViewJavascriptCallback,
UpdateCookieSetHandler},
BrowserServiceSymbols(), PermissionsSymbols(),
{Foreground, Start, Update, PauseGame, ResumeGame, Leave, PauseApp,
DestroyApp, UpdateApp, StartApp, CallMessagesFromMainThread},
JniFactory(&probe), {&probe, RegisterObserver, ClearObserver}, {},
nullptr, {}, {&probe, ObservePresence}, false, true);
RobloxLuaAppExperienceReadiness readiness;
readiness.principal = {GameSessionPrincipalKind::kAuthenticated, 4, "42",
"https://www.roblox.com"};
readiness.surface = {7, 0x1234, 1280, 720};
readiness.username = "typed-user";
ASSERT_TRUE(composition.InitializePlatformProtocols().ok());
EXPECT_EQ(QueryMicrophone(&probe).at("status"), "AUTHORIZED");
ASSERT_TRUE(composition.OnLuaAppReady(readiness).ok());
ASSERT_TRUE(composition.subscribed());
EXPECT_EQ(probe.web_view_initializations, 1);
EXPECT_EQ(probe.browser_bindings, 4);
EXPECT_EQ(probe.browser_callbacks_created, 4);
EXPECT_TRUE(composition.SurfaceCreated(8).ok());
EXPECT_TRUE(composition.SurfaceChanged({8, 0x5678, 640, 480}).ok());
EXPECT_TRUE(composition.SurfaceDestroyed(8).ok());
EXPECT_TRUE(composition.SurfaceCreated(9).ok());
EXPECT_TRUE(composition.SurfaceChanged({9, 0x6789, 640, 480}).ok());
EXPECT_EQ(probe.app_updates, 0);
EXPECT_EQ(probe.app_starts, 2);
EXPECT_EQ(probe.app_pauses, 2);
EXPECT_EQ(probe.app_start_surface_width, 640);
EXPECT_EQ(probe.app_user_id, 42);
EXPECT_EQ(probe.app_surface_lifecycle,
(std::vector<std::string>{"pause-app", "background:ASMA.stop",
"foreground:ASMA.start", "start-app",
"pause-app", "background:ASMA.stop",
"foreground:ASMA.start", "start-app"}));
ASSERT_NE(probe.callback, nullptr);
JNIEnv* env = vm.GetJNIEnv();
jclass callback_class = env->GetObjectClass(probe.callback);
jmethodID run =
env->GetMethodID(callback_class, "run", "(Ljava/lang/String;)V");
jstring request = env->NewStringUTF(
R"({"placeId":17580461965,"gameJoinContext":"PlayButton"})");
env->CallVoidMethod(probe.callback, run, request);
EXPECT_EQ(probe.starts.load(), 0);
DrainUntilPresented(&composition);
EXPECT_EQ(probe.starts.load(), 1);
EXPECT_EQ(probe.started_place, 17580461965);
EXPECT_EQ(probe.join_request_type, 0);
EXPECT_EQ(probe.surface_width, 640);
EXPECT_TRUE(composition.Snapshot().game_presented);
ASSERT_EQ(probe.presence_phases.size(), 2u);
EXPECT_EQ(probe.presence_phases[0], RobloxExperiencePresencePhase::kJoining);
EXPECT_EQ(probe.presence_phases[1], RobloxExperiencePresencePhase::kPlaying);
EXPECT_EQ(probe.presence_places,
(std::vector<int64_t>{17580461965, 17580461965}));
// ASMA/V2 reports its already-completed native return through the exact
// NativeGLJavaInterface.gameDidLeave() callback. The host consumes it
// without replaying LeaveGame, retains subsequent LuaApp surface updates,
// and accepts another launch in the same process.
std::shared_ptr<void> lifecycle_context(&composition, [](void*) {});
vm.SetRobloxExperienceLifecycleCallbacks(
lifecycle_context,
jnivm::RobloxExperienceLifecycleCallbacks{&NotifyCompositionReturn});
jclass native_gl_class =
env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
jmethodID game_did_leave =
env->GetStaticMethodID(native_gl_class, "gameDidLeave", "()V");
ASSERT_NE(game_did_leave, nullptr);
env->CallStaticVoidMethod(native_gl_class, game_did_leave);
ASSERT_TRUE(composition.DrainLaunchRequests().ok());
EXPECT_EQ(probe.starts.load(), 1);
EXPECT_EQ(composition.Snapshot().state, GameSessionState::kReturnedToLuaApp);
EXPECT_TRUE(composition.Snapshot().returned_to_lua_app);
ASSERT_EQ(probe.presence_phases.size(), 3u);
EXPECT_EQ(probe.presence_phases.back(),
RobloxExperiencePresencePhase::kBrowsing);
EXPECT_EQ(probe.presence_places.back(), 0);
const int updates_before_lua_app_resize = probe.updates;
const int app_updates_before_lua_app_resize = probe.app_updates;
const GameSessionUpdateResult lua_app_resize =
composition.SurfaceChanged({10, 0x7777, 800, 600});
EXPECT_TRUE(lua_app_resize.ok());
EXPECT_EQ(lua_app_resize.state, GameSessionState::kCreated);
EXPECT_EQ(probe.updates, updates_before_lua_app_resize);
EXPECT_EQ(probe.app_updates, app_updates_before_lua_app_resize + 1);
EXPECT_EQ(probe.app_surface_width, 800);
jstring server_request = env->NewStringUTF(
R"({"placeId":1962086868,"gameInstanceId":"97ea-49e4",)"
R"("joinAttemptId":"second-launch","joinAttemptOrigin":"WebView"})");
env->CallVoidMethod(probe.callback, run, server_request);
for (int attempt = 0; attempt < 1000 && probe.starts.load() < 2; ++attempt) {
ASSERT_TRUE(composition.DrainLaunchRequests().ok());
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
ASSERT_EQ(probe.starts.load(), 2);
DrainUntilPresented(&composition);
EXPECT_EQ(probe.started_place, 1962086868);
EXPECT_EQ(probe.join_request_type, 3);
EXPECT_EQ(probe.leaves, 0);
ASSERT_EQ(probe.presence_phases.size(), 5u);
EXPECT_EQ(probe.presence_phases[3], RobloxExperiencePresencePhase::kJoining);
EXPECT_EQ(probe.presence_phases[4], RobloxExperiencePresencePhase::kPlaying);
EXPECT_EQ(probe.presence_places[3], 1962086868);
EXPECT_EQ(probe.presence_places[4], 1962086868);
EXPECT_TRUE(composition.LeaveGame().ok());
EXPECT_EQ(probe.leaves, 1);
EXPECT_EQ(probe.destroys, 0);
vm.ClearRobloxExperienceLifecycleCallbacks();
EXPECT_TRUE(composition.Shutdown().ok());
EXPECT_EQ(probe.disconnects, 4);
EXPECT_EQ(probe.browser_disconnects, 4);
EXPECT_EQ(probe.browser_releases, 4);
EXPECT_EQ(probe.browser_callbacks_cleared, 4);
g_probe = nullptr;
}
TEST(RobloxExperienceCompositionTest,
AcceptsMessagePublishedSynchronouslyBySubscribe) {
jnivm::VM vm;
Probe probe{&vm};
probe.publish_during_subscribe = true;
g_probe = &probe;
for (const char* class_name : {
"com/roblox/engine/jni/NativeGLInterface",
"android/view/Surface",
"com/roblox/engine/jni/model/PlatformParams",
"android/app/Activity",
"com/roblox/engine/jni/autovalue/AutoValue_StartGameParams",
"com/roblox/engine/jni/autovalue/StartAppParams",
"com/roblox/universalapp/messagebus/MessageBus",
"com/roblox/universalapp/messagebus/Connection",
"com/roblox/engine/jni/memstorage/MemStorage",
"com/roblox/engine/jni/memstorage/Connection",
"com/roblox/engine/jni/memstorage/Callback",
}) {
vm.RegisterClass(class_name);
}
RobloxExperienceComposition composition(
{vm.GetJavaVM(), &vm, Prepare}, {GetLaunchId, Subscribe, Disconnect},
{GetWebViewOpenId, GetWebViewHandleWindowCloseId, GetWebViewProtocolName,
GetWebViewAvailabilityId, GetWebViewMessageId, InitializeWebViewProtocol,
Subscribe, Disconnect, SetRequestHandler, ClearNativeRequestHandler,
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
GetWebViewCloseId, SignalWebViewJavascriptCallback,
UpdateCookieSetHandler},
BrowserServiceSymbols(), PermissionsSymbols(),
{Foreground, Start, Update, PauseGame, ResumeGame, Leave, PauseApp,
DestroyApp, UpdateApp, StartApp, CallMessagesFromMainThread},
JniFactory(&probe), {&probe, RegisterObserver, ClearObserver});
RobloxLuaAppExperienceReadiness readiness;
readiness.principal = {GameSessionPrincipalKind::kAuthenticated, 4, "42",
"https://www.roblox.com"};
readiness.surface = {7, 0x1234, 1280, 720};
readiness.username = "typed-user";
ASSERT_TRUE(composition.InitializePlatformProtocols().ok());
ASSERT_TRUE(composition.OnLuaAppReady(readiness).ok());
EXPECT_EQ(probe.starts.load(), 0);
DrainUntilPresented(&composition);
EXPECT_EQ(probe.starts.load(), 1);
EXPECT_EQ(probe.started_place, 17580461965);
EXPECT_TRUE(composition.Shutdown().ok());
g_probe = nullptr;
}
TEST(RobloxExperienceCompositionTest,
ConsumesControlledStartAndSwitchesActiveGame) {
char directory_pattern[] = "/tmp/mocktail-composition-launch-test-XXXXXX";
char* launch_directory = mkdtemp(directory_pattern);
ASSERT_NE(launch_directory, nullptr);
ASSERT_EQ(chmod(launch_directory, 0700), 0);
ExternalLaunchBrokerOptions broker_options;
broker_options.socket_path =
std::filesystem::path(launch_directory) / "broker.sock";
std::shared_ptr<ExternalLaunchBroker> broker;
Status status = ExternalLaunchBroker::StartOwnerAfterLockAcquired(
broker_options, &broker);
ASSERT_TRUE(status.ok()) << status.message();
ASSERT_TRUE(InstallActiveExternalLaunchBroker(broker).ok());
RobloxExperienceLaunchRequest controlled_launch;
ASSERT_TRUE(ParseRobloxExperienceLaunchJson(
R"({"placeId":1962086868,"gameInstanceId":"97ea-49e4",)"
R"("joinAttemptOrigin":"Web"})",
&controlled_launch)
.ok());
ASSERT_TRUE(broker->QueueInitialRequest(std::move(controlled_launch)).ok());
jnivm::VM vm;
Probe probe{&vm};
g_probe = &probe;
for (const char* class_name : {
"com/roblox/engine/jni/NativeGLInterface",
"android/view/Surface",
"com/roblox/engine/jni/model/PlatformParams",
"android/app/Activity",
"com/roblox/engine/jni/autovalue/AutoValue_StartGameParams",
"com/roblox/engine/jni/autovalue/StartAppParams",
"com/roblox/universalapp/messagebus/MessageBus",
"com/roblox/universalapp/messagebus/Connection",
"com/roblox/engine/jni/memstorage/MemStorage",
"com/roblox/engine/jni/memstorage/Connection",
"com/roblox/engine/jni/memstorage/Callback",
}) {
vm.RegisterClass(class_name);
}
RobloxExperienceComposition composition(
{vm.GetJavaVM(), &vm, Prepare}, {GetLaunchId, Subscribe, Disconnect},
{GetWebViewOpenId, GetWebViewHandleWindowCloseId, GetWebViewProtocolName,
GetWebViewAvailabilityId, GetWebViewMessageId, InitializeWebViewProtocol,
Subscribe, Disconnect, SetRequestHandler, ClearNativeRequestHandler,
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
GetWebViewCloseId, SignalWebViewJavascriptCallback,
UpdateCookieSetHandler},
BrowserServiceSymbols(), PermissionsSymbols(),
{Foreground, Start, Update, PauseGame, ResumeGame, Leave, PauseApp,
DestroyApp, UpdateApp, StartApp, CallMessagesFromMainThread},
JniFactory(&probe), {&probe, RegisterObserver, ClearObserver});
RobloxLuaAppExperienceReadiness readiness;
readiness.principal = {GameSessionPrincipalKind::kAuthenticated, 4, "42",
"https://www.roblox.com"};
readiness.surface = {7, 0x1234, 1280, 720};
readiness.username = "typed-user";
ASSERT_TRUE(composition.InitializePlatformProtocols().ok());
ASSERT_TRUE(composition.OnLuaAppReady(readiness).ok());
EXPECT_EQ(broker->pending_launch_count(), 0u);
EXPECT_EQ(probe.starts.load(), 0);
DrainUntilPresented(&composition);
EXPECT_EQ(probe.starts.load(), 1);
EXPECT_EQ(probe.started_place, 1962086868);
EXPECT_EQ(probe.join_request_type, 3);
RobloxExperienceLaunchRequest duplicate_server;
ASSERT_TRUE(ParseRobloxExperienceLaunchJson(
R"({"gameInstanceId":"97ea-49e4","placeId":1962086868,)"
R"("joinAttemptOrigin":"Web"})",
&duplicate_server)
.ok());
ASSERT_TRUE(broker->QueueInitialRequest(std::move(duplicate_server)).ok());
ASSERT_TRUE(composition.DrainLaunchRequests().ok());
EXPECT_EQ(broker->pending_launch_count(), 0u);
EXPECT_EQ(probe.leaves, 0);
EXPECT_EQ(probe.starts.load(), 1);
RobloxExperienceLaunchRequest next_server;
ASSERT_TRUE(ParseRobloxExperienceLaunchJson(
R"({"placeId":17580461965,"gameInstanceId":"next-server"})",
&next_server)
.ok());
ASSERT_TRUE(broker->QueueInitialRequest(std::move(next_server)).ok());
ASSERT_TRUE(composition.DrainLaunchRequests().ok());
EXPECT_EQ(probe.leaves, 1);
EXPECT_EQ(probe.starts.load(), 1);
ASSERT_TRUE(composition.DrainLaunchRequests().ok());
EXPECT_EQ(probe.leaves, 1);
EXPECT_EQ(probe.starts.load(), 1);
// The exact gameDidLeave callback is the safe boundary between the old
// controller and the replacement server launch.
composition.NotifyLuaAppDidReturn();
for (int attempt = 0; attempt < 1000 && probe.starts.load() < 2; ++attempt) {
ASSERT_TRUE(composition.DrainLaunchRequests().ok());
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
ASSERT_EQ(probe.starts.load(), 2);
EXPECT_EQ(probe.started_place, 17580461965);
EXPECT_EQ(probe.join_request_type, 3);
EXPECT_TRUE(composition.LeaveGame().ok());
EXPECT_EQ(probe.leaves, 2);
EXPECT_TRUE(composition.Shutdown().ok());
ClearActiveExternalLaunchBroker(broker.get());
EXPECT_TRUE(broker->Shutdown().ok());
broker.reset();
std::error_code ignored;
std::filesystem::remove_all(launch_directory, ignored);
g_probe = nullptr;
}
TEST(RobloxExperienceCompositionTest,
DrainStartsOwnedLaunchWorkerWithoutBlockingOwnerThread) {
jnivm::VM vm;
Probe probe{&vm};
probe.publish_during_subscribe = true;
probe.block_start.store(true);
g_probe = &probe;
for (const char* class_name : {
"com/roblox/engine/jni/NativeGLInterface",
"android/view/Surface",
"com/roblox/engine/jni/model/PlatformParams",
"android/app/Activity",
"com/roblox/engine/jni/autovalue/AutoValue_StartGameParams",
"com/roblox/engine/jni/autovalue/StartAppParams",
"com/roblox/universalapp/messagebus/MessageBus",
"com/roblox/universalapp/messagebus/Connection",
"com/roblox/engine/jni/memstorage/MemStorage",
"com/roblox/engine/jni/memstorage/Connection",
"com/roblox/engine/jni/memstorage/Callback",
}) {
vm.RegisterClass(class_name);
}
RobloxExperienceComposition composition(
{vm.GetJavaVM(), &vm, Prepare}, {GetLaunchId, Subscribe, Disconnect},
{GetWebViewOpenId, GetWebViewHandleWindowCloseId, GetWebViewProtocolName,
GetWebViewAvailabilityId, GetWebViewMessageId, InitializeWebViewProtocol,
Subscribe, Disconnect, SetRequestHandler, ClearNativeRequestHandler,
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
GetWebViewCloseId, SignalWebViewJavascriptCallback,
UpdateCookieSetHandler},
BrowserServiceSymbols(), PermissionsSymbols(),
{Foreground, Start, Update, PauseGame, ResumeGame, Leave, PauseApp,
DestroyApp, UpdateApp, StartApp, CallMessagesFromMainThread},
JniFactory(&probe), {&probe, RegisterObserver, ClearObserver}, {},
nullptr, {}, {&probe, ObservePresence});
RobloxLuaAppExperienceReadiness readiness;
readiness.principal = {GameSessionPrincipalKind::kAuthenticated, 4, "42",
"https://www.roblox.com"};
readiness.surface = {7, 0x1234, 1280, 720};
readiness.username = "typed-user";
ASSERT_TRUE(composition.InitializePlatformProtocols().ok());
ASSERT_TRUE(composition.OnLuaAppReady(readiness).ok());
const auto started_at = std::chrono::steady_clock::now();
ASSERT_TRUE(composition.DrainLaunchRequests().ok());
const auto elapsed = std::chrono::steady_clock::now() - started_at;
EXPECT_LT(elapsed, std::chrono::milliseconds(100));
for (int attempt = 0; attempt < 1000 && !probe.start_entered.load();
++attempt) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
ASSERT_TRUE(probe.start_entered.load());
EXPECT_EQ(probe.starts.load(), 1);
for (int attempt = 0;
attempt < 1000 && probe.playing_presence_notifications.load() == 0;
++attempt) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
const int playing_before_start_return =
probe.playing_presence_notifications.load();
probe.release_start.store(true);
DrainUntilPresented(&composition);
ASSERT_EQ(playing_before_start_return, 1);
ASSERT_GE(probe.presence_phases.size(), 2u);
EXPECT_EQ(probe.presence_phases.front(),
RobloxExperiencePresencePhase::kJoining);
EXPECT_EQ(probe.presence_phases.back(),
RobloxExperiencePresencePhase::kPlaying);
EXPECT_TRUE(composition.Shutdown().ok());
g_probe = nullptr;
}
TEST(RobloxExperienceCompositionTest,
RejectsIncompleteReadinessBeforeSubscribe) {
jnivm::VM vm;
Probe probe{&vm};
RobloxExperienceComposition composition({vm.GetJavaVM(), &vm, Prepare}, {},
{}, {}, {}, {}, JniFactory(&probe),
{});
EXPECT_FALSE(
composition.OnLuaAppReady(RobloxLuaAppExperienceReadiness{}).ok());
EXPECT_FALSE(composition.subscribed());
}
TEST(RobloxExperienceCompositionTest,
InitializesDurablePlatformProtocolsBeforeNativeBootstrap) {
jnivm::VM vm;
Probe probe{&vm};
g_probe = &probe;
for (const char* class_name : {
"com/roblox/protocols/webview/WebViewProtocol",
"com/roblox/universalapp/messagebus/MessageBus",
"com/roblox/universalapp/messagebus/Connection",
"com/roblox/engine/jni/memstorage/MemStorage",
"com/roblox/engine/jni/memstorage/Connection",
"com/roblox/engine/jni/memstorage/Callback",
}) {
vm.RegisterClass(class_name);
}
RobloxExperienceComposition composition(
{vm.GetJavaVM(), &vm, Prepare}, {},
{GetWebViewOpenId, GetWebViewHandleWindowCloseId, GetWebViewProtocolName,
GetWebViewAvailabilityId, GetWebViewMessageId, InitializeWebViewProtocol,
Subscribe, Disconnect, SetRequestHandler, ClearNativeRequestHandler,
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
GetWebViewCloseId, SignalWebViewJavascriptCallback,
UpdateCookieSetHandler},
BrowserServiceSymbols(), PermissionsSymbols(), {}, JniFactory(&probe),
{});
ASSERT_TRUE(composition.InitializePlatformProtocols().ok());
EXPECT_FALSE(composition.subscribed());
EXPECT_EQ(probe.permission_subscriptions, 6);
EXPECT_EQ(probe.permission_handlers.size(), 6u);
EXPECT_EQ(probe.permission_handlers.count("getCallState"), 1u);
EXPECT_EQ(QueryMicrophone(&probe).at("status"), "DENIED");
EXPECT_EQ(probe.web_view_initializations, 1);
EXPECT_EQ(probe.browser_bindings, 4);
EXPECT_EQ(probe.browser_callbacks_created, 4);
EXPECT_EQ(probe.browser_disconnects, 0);
EXPECT_EQ(probe.browser_releases, 0);
EXPECT_EQ(probe.browser_callbacks_cleared, 0);
EXPECT_TRUE(composition.Shutdown().ok());
EXPECT_EQ(probe.permission_disconnects, 6);
EXPECT_EQ(probe.permission_handlers_cleared, 6);
EXPECT_EQ(probe.disconnects, 3);
EXPECT_EQ(probe.browser_disconnects, 4);
EXPECT_EQ(probe.browser_releases, 4);
EXPECT_EQ(probe.browser_callbacks_cleared, 4);
EXPECT_TRUE(probe.account_handlers.empty());
g_probe = nullptr;
}
TEST(RobloxExperienceCompositionTest,
AnswersDeviceIntegrityAsUnsupportedWithPlatformProtocols) {
jnivm::VM vm;
Probe probe{&vm};
g_probe = &probe;
for (const char* class_name : {
"com/roblox/protocols/webview/WebViewProtocol",
"com/roblox/universalapp/messagebus/MessageBus",
"com/roblox/universalapp/messagebus/Connection",
"com/roblox/engine/jni/memstorage/MemStorage",
"com/roblox/engine/jni/memstorage/Connection",
"com/roblox/engine/jni/memstorage/Callback",
}) {
vm.RegisterClass(class_name);
}
RobloxExperienceComposition composition(
{vm.GetJavaVM(), &vm, Prepare}, {},
{GetWebViewOpenId, GetWebViewHandleWindowCloseId, GetWebViewProtocolName,
GetWebViewAvailabilityId, GetWebViewMessageId, InitializeWebViewProtocol,
Subscribe, Disconnect, SetRequestHandler, ClearNativeRequestHandler,
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
GetWebViewCloseId, SignalWebViewJavascriptCallback,
UpdateCookieSetHandler},
BrowserServiceSymbols(), PermissionsSymbols(), {}, JniFactory(&probe),
{}, {}, nullptr, {}, {}, false, false, AccountSymbols());
ASSERT_TRUE(composition.InitializePlatformProtocols().ok());
ASSERT_EQ(probe.account_handlers.size(), 2u);
JNIEnv* env = vm.GetJNIEnv();
jstring request = env->NewStringUTF("{}");
jstring response = vm.DispatchMessageBusRequestHandler(
probe.account_handlers.at("DeviceIntegrityAvailable"), env, request);
EXPECT_EQ(nlohmann::json::parse(CopyJavaString(env, response)),
(nlohmann::json{{"support", false}}));
env->DeleteLocalRef(request);
if (response) env->DeleteLocalRef(response);
EXPECT_TRUE(composition.Shutdown().ok());
EXPECT_EQ(probe.account_handlers_cleared, 2);
g_probe = nullptr;
}
TEST(RobloxExperienceCompositionTest,
NativeSignInSubscribesAndLaunchesWithoutRestart) {
jnivm::VM vm;
Probe probe{&vm};
probe.observed_surface = {11, 0x2468, 1280, 720};
g_probe = &probe;
for (const char* class_name : {
"com/roblox/engine/jni/NativeGLInterface",
"android/view/Surface",
"com/roblox/engine/jni/model/PlatformParams",
"android/app/Activity",
"com/roblox/engine/jni/autovalue/AutoValue_StartGameParams",
"com/roblox/engine/jni/autovalue/StartAppParams",
"com/roblox/universalapp/messagebus/MessageBus",
"com/roblox/universalapp/messagebus/Connection",
"com/roblox/engine/jni/memstorage/MemStorage",
"com/roblox/engine/jni/memstorage/Connection",
"com/roblox/engine/jni/memstorage/Callback",
}) {
vm.RegisterClass(class_name);
}
RobloxExperienceComposition composition(
{vm.GetJavaVM(), &vm, Prepare}, {GetLaunchId, Subscribe, Disconnect},
{GetWebViewOpenId, GetWebViewHandleWindowCloseId, GetWebViewProtocolName,
GetWebViewAvailabilityId, GetWebViewMessageId, InitializeWebViewProtocol,
Subscribe, Disconnect, SetRequestHandler, ClearNativeRequestHandler,
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
GetWebViewCloseId, SignalWebViewJavascriptCallback,
UpdateCookieSetHandler},
BrowserServiceSymbols(), PermissionsSymbols(),
{Foreground, Start, Update, PauseGame, ResumeGame, Leave, PauseApp,
DestroyApp, UpdateApp, StartApp, CallMessagesFromMainThread},
JniFactory(&probe), {&probe, RegisterObserver, ClearObserver}, {},
nullptr, {&probe, SnapshotSurface});
ASSERT_TRUE(composition.InitializePlatformProtocols().ok());
EXPECT_FALSE(composition.subscribed());
jnivm::RobloxAuthIdentity identity;
identity.user_id = 4242;
identity.username = "live-user";
identity.display_name = "Live User";
const std::string credential = ".ROBLOSECURITY=_|live-test-credential";
vm.SetRobloxCredentialProvider(&credential, &ProvideCredential);
vm.SetRobloxAuthIdentity(identity);
ASSERT_TRUE(composition.DrainPlatformEvents().ok());
ASSERT_TRUE(composition.subscribed());
ASSERT_NE(probe.callback, nullptr);
probe.observed_surface.width = 960;
probe.observed_surface.dpi_scale = 2.0f;
ASSERT_TRUE(composition.DrainPlatformEvents().ok());
EXPECT_EQ(probe.app_updates, 1);
EXPECT_EQ(probe.app_surface_width, 960);
EXPECT_FLOAT_EQ(probe.app_dpi_scale, 2.0f);
JNIEnv* env = vm.GetJNIEnv();
jclass callback_class = env->GetObjectClass(probe.callback);
jmethodID run =
env->GetMethodID(callback_class, "run", "(Ljava/lang/String;)V");
env->CallVoidMethod(
probe.callback, run,
env->NewStringUTF(
R"({"placeId":17580461965,"gameJoinContext":"PlayButton"})"));
DrainUntilPresented(&composition);
EXPECT_EQ(probe.starts.load(), 1);
EXPECT_EQ(probe.started_place, 17580461965);
EXPECT_EQ(probe.surface_width, 960);
EXPECT_TRUE(vm.DispatchRobloxExperienceLuaAppDidReturn());
EXPECT_TRUE(composition.DrainLaunchRequests().ok());
EXPECT_TRUE(composition.Shutdown().ok());
EXPECT_FALSE(vm.DispatchRobloxExperienceLuaAppDidReturn());
g_probe = nullptr;
}
TEST(RobloxExperienceCompositionTest,
RequiresPlatformProtocolsBeforeLuaAppReadiness) {
jnivm::VM vm;
Probe probe{&vm};
RobloxExperienceComposition composition(
{vm.GetJavaVM(), &vm, Prepare}, {GetLaunchId, Subscribe, Disconnect},
{GetWebViewOpenId, GetWebViewHandleWindowCloseId, GetWebViewProtocolName,
GetWebViewAvailabilityId, GetWebViewMessageId, InitializeWebViewProtocol,
Subscribe, Disconnect, SetRequestHandler, ClearNativeRequestHandler,
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
GetWebViewCloseId, SignalWebViewJavascriptCallback,
UpdateCookieSetHandler},
BrowserServiceSymbols(), PermissionsSymbols(),
{Foreground, Start, Update, PauseGame, ResumeGame, Leave, PauseApp,
DestroyApp, UpdateApp, StartApp, CallMessagesFromMainThread},
JniFactory(&probe), {&probe, RegisterObserver, ClearObserver});
RobloxLuaAppExperienceReadiness readiness;
readiness.principal = {GameSessionPrincipalKind::kAuthenticated, 4, "42",
"https://www.roblox.com"};
readiness.surface = {7, 0x1234, 1280, 720};
readiness.username = "typed-user";
const Status status = composition.OnLuaAppReady(readiness);
EXPECT_FALSE(status.ok());
EXPECT_EQ(status.code(), StatusCode::kFailedPrecondition);
EXPECT_FALSE(composition.subscribed());
}
} // namespace
class RobloxExperienceCompositionWebSurfaceTest : public ::testing::Test {
protected:
struct Helper {
explicit Helper(const char* before_ready = "") {
char pattern[] = "/tmp/mocktail_surface_close_XXXXXX";
const char* created = mkdtemp(pattern);
if (created == nullptr)
return;
directory = created;
path = directory / "helper";
std::ofstream output(path);
output << R"PY(#!/usr/bin/env python3
import socket, sys
sys.stdin.buffer.read()
s = socket.socket(fileno=198)
s.settimeout(5)
)PY" << before_ready << R"PY(
s.send(b'MWVE' + bytes([1, 3, 0, 0]) + bytes(4))
while True:
packet = s.recv(1024 * 1024)
if not packet or packet[5] == 5:
break
)PY";
output.close();
if (!output.good() || chmod(path.c_str(), 0700) != 0)
path.clear();
}
~Helper() {
if (process != nullptr)
(void)process->RequestClose();
std::error_code error;
if (!directory.empty())
std::filesystem::remove_all(directory, error);
}
std::filesystem::path directory;
std::filesystem::path path;
std::shared_ptr<WebViewHelperProcess> process;
};
static void AttachProcess(RobloxExperienceComposition* composition,
std::shared_ptr<WebViewHelperProcess> process) {
composition->web_surface_process_ = std::move(process);
}
static bool HasProcess(const RobloxExperienceComposition* composition) {
return composition->web_surface_process_ != nullptr;
}
struct ExitProbe {
int calls = 0;
};
static void ObserveExit(void* context) {
++static_cast<ExitProbe*>(context)->calls;
}
static Status Open(RobloxExperienceComposition* composition,
const std::shared_ptr<ExitProbe>& probe) {
return composition->OpenWebSurface(
"https://www.roblox.com/challenge/cdn/hybrid", "webview",
RobloxExperienceComposition::WebSurfaceRoute::kWebView,
{probe, &RobloxExperienceCompositionWebSurfaceTest::ObserveExit}, {});
}
static std::unique_ptr<RobloxExperienceComposition> MakeComposition(
const SecureRobloxCredential* credential = nullptr) {
return std::make_unique<RobloxExperienceComposition>(
JniEnvironmentProvider{}, RobloxExperienceMessageBusSymbols{},
RobloxWebViewMessageBusSymbols{}, RobloxBrowserServiceSymbols{},
RobloxPermissionsMessageBusSymbols{}, RobloxGameSessionSymbols{},
RobloxExperienceJniFactory{}, RobloxFreshLaunchPresentBoundary{},
RobloxGameSurfaceJniConfig{}, credential);
}
static bool CookieSynchronized(
const RobloxExperienceComposition* composition) {
return composition->web_view_cookie_synchronized_;
}
static std::string CookieValue(
const RobloxExperienceComposition* composition) {
return std::string(composition->web_view_cookie_.value());
}
static void Activate(RobloxExperienceComposition* composition,
bool browser_service, uint64_t process_generation,
uint64_t logical_generation,
const std::shared_ptr<ExitProbe>& probe) {
std::lock_guard<std::mutex> lock(composition->mutex_);
composition->web_surface_route_ =
browser_service
? RobloxExperienceComposition::WebSurfaceRoute::kBrowserService
: RobloxExperienceComposition::WebSurfaceRoute::kWebView;
composition->web_surface_process_generation_ = process_generation;
composition->web_surface_logical_generation_ = logical_generation;
composition->web_surface_logical_exit_observer_ = {
probe, &RobloxExperienceCompositionWebSurfaceTest::ObserveExit};
}
static Status Close(RobloxExperienceComposition* composition) {
return composition->CloseWebSurface();
}
static Status RouteEvent(RobloxExperienceComposition* composition,
bool browser_service,
const WebViewHelperEvent& event) {
return RobloxExperienceComposition::RouteWebSurfaceEvent(
browser_service
? RobloxExperienceComposition::WebSurfaceRoute::kBrowserService
: RobloxExperienceComposition::WebSurfaceRoute::kWebView,
event, composition->web_view_bridge_.get());
}
static Status RouteCurrentEvent(RobloxExperienceComposition* composition,
uint64_t process_generation,
uint64_t logical_generation,
const WebViewHelperEvent& event) {
return composition->RouteCurrentWebSurfaceEvent(nullptr, process_generation,
logical_generation, event);
}
static void PhysicalExit(RobloxExperienceComposition* composition,
uint64_t process_generation) {
composition->HandleWebSurfaceExit(process_generation);
}
static void MarkClosesWhenChallengeSolved(
RobloxExperienceComposition* composition) {
std::lock_guard<std::mutex> lock(composition->mutex_);
composition->web_surface_close_when_challenge_solved_ = true;
}
static bool SolvedChallengeClosePending(
RobloxExperienceComposition* composition) {
std::lock_guard<std::mutex> lock(composition->mutex_);
return composition->solved_challenge_close_deadline_.has_value();
}
static void ExpireSolvedChallengeGrace(
RobloxExperienceComposition* composition) {
std::lock_guard<std::mutex> lock(composition->mutex_);
if (composition->solved_challenge_close_deadline_.has_value()) {
composition->solved_challenge_close_deadline_ =
std::chrono::steady_clock::now();
}
}
static std::string ChallengeSuccess() {
return R"({"moduleID":"Navigation","functionName":"navigateToFeature",)"
R"("params":{"params":{"data":{"captchaData":{}},)"
R"("feature":"CaptchaSuccess"}},"callbackID":"challenge-1"})";
}
static std::unique_ptr<RobloxExperienceComposition> MakeWebViewComposition(
jnivm::VM* vm, Probe* probe) {
for (const char* class_name : {
"com/roblox/protocols/webview/WebViewProtocol",
"com/roblox/universalapp/messagebus/MessageBus",
"com/roblox/universalapp/messagebus/Connection",
"com/roblox/engine/jni/memstorage/MemStorage",
"com/roblox/engine/jni/memstorage/Connection",
"com/roblox/engine/jni/memstorage/Callback",
}) {
vm->RegisterClass(class_name);
}
return std::make_unique<RobloxExperienceComposition>(
JniEnvironmentProvider{vm->GetJavaVM(), vm, Prepare},
RobloxExperienceMessageBusSymbols{},
RobloxWebViewMessageBusSymbols{
GetWebViewOpenId, GetWebViewHandleWindowCloseId,
GetWebViewProtocolName, GetWebViewAvailabilityId,
GetWebViewMessageId, InitializeWebViewProtocol, Subscribe,
Disconnect, SetRequestHandler, ClearNativeRequestHandler,
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
GetWebViewCloseId, SignalWebViewJavascriptCallback,
UpdateCookieSetHandler},
BrowserServiceSymbols(), PermissionsSymbols(),
RobloxGameSessionSymbols{}, JniFactory(probe),
RobloxFreshLaunchPresentBoundary{});
}
};
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
SeedsValidatedWebViewCredentialBeforePlatformCallbacks) {
SecureRobloxCredential credential{".ROBLOSECURITY=typed-secret"};
auto composition = MakeComposition(&credential);
EXPECT_TRUE(CookieSynchronized(composition.get()));
EXPECT_EQ(CookieValue(composition.get()), "typed-secret");
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
RoutesServerJoinToExactApkCallbackOwnerWithoutRewritingPayload) {
jnivm::VM vm;
Probe probe{&vm};
g_probe = &probe;
for (const char* class_name : {
"com/roblox/protocols/webview/WebViewProtocol",
"com/roblox/universalapp/messagebus/MessageBus",
"com/roblox/universalapp/messagebus/Connection",
"com/roblox/engine/jni/memstorage/MemStorage",
"com/roblox/engine/jni/memstorage/Connection",
"com/roblox/engine/jni/memstorage/Callback",
}) {
vm.RegisterClass(class_name);
}
RobloxExperienceComposition composition(
{vm.GetJavaVM(), &vm, Prepare}, {},
{GetWebViewOpenId, GetWebViewHandleWindowCloseId, GetWebViewProtocolName,
GetWebViewAvailabilityId, GetWebViewMessageId, InitializeWebViewProtocol,
Subscribe, Disconnect, SetRequestHandler, ClearNativeRequestHandler,
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
GetWebViewCloseId, SignalWebViewJavascriptCallback,
UpdateCookieSetHandler},
BrowserServiceSymbols(), PermissionsSymbols(), {}, JniFactory(&probe),
{});
ASSERT_TRUE(composition.InitializePlatformProtocols().ok());
const std::string join =
R"({"moduleID":"Game","functionName":"launchGame",)"
R"("params":{"request":{"requestType":"RequestGameJob",)"
R"("placeId":"1962086868","instanceId":"97ea-49e4",)"
R"("isPlayTogetherGame":false,"browserTrackerId":"tracker"}},)"
R"("callbackID":"join-callback"})";
ASSERT_TRUE(RouteEvent(&composition, false,
{WebViewHelperEventType::kExecuteRoblox, join})
.ok());
EXPECT_EQ(probe.web_view_javascript_callback, join);
probe.web_view_javascript_callback.clear();
ASSERT_TRUE(RouteEvent(&composition, true,
{WebViewHelperEventType::kExecuteRoblox, join})
.ok());
EXPECT_EQ(probe.web_view_javascript_callback, join);
auto exit_probe = std::make_shared<ExitProbe>();
Activate(&composition, false, 17, 23, exit_probe);
probe.web_view_javascript_callback.clear();
ASSERT_TRUE(RouteCurrentEvent(&composition, 17, 23,
{WebViewHelperEventType::kExecuteRoblox, join})
.ok());
EXPECT_EQ(probe.web_view_javascript_callback, join);
ASSERT_TRUE(Close(&composition).ok());
probe.web_view_javascript_callback.clear();
EXPECT_TRUE(RouteCurrentEvent(&composition, 17, 23,
{WebViewHelperEventType::kExecuteRoblox, join})
.ok());
EXPECT_TRUE(probe.web_view_javascript_callback.empty());
Activate(&composition, false, 17, 24, exit_probe);
EXPECT_TRUE(RouteCurrentEvent(&composition, 17, 23,
{WebViewHelperEventType::kExecuteRoblox, join})
.ok());
EXPECT_TRUE(probe.web_view_javascript_callback.empty());
ASSERT_TRUE(RouteCurrentEvent(&composition, 17, 24,
{WebViewHelperEventType::kExecuteRoblox, join})
.ok());
EXPECT_EQ(probe.web_view_javascript_callback, join);
EXPECT_TRUE(Close(&composition).ok());
EXPECT_TRUE(composition.Shutdown().ok());
g_probe = nullptr;
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
LogicalCloseNotifiesActiveRouteExactlyOnce) {
auto composition = MakeComposition();
auto probe = std::make_shared<ExitProbe>();
Activate(composition.get(), false, 7, 11, probe);
EXPECT_TRUE(Close(composition.get()).ok());
EXPECT_TRUE(Close(composition.get()).ok());
EXPECT_EQ(probe->calls, 1);
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
CloseStopsPageProcessBeforeAnotherVerification) {
Helper helper;
ASSERT_FALSE(helper.path.empty());
auto composition = MakeComposition();
auto probe = std::make_shared<ExitProbe>();
for (uint64_t generation : {7, 8}) {
SCOPED_TRACE(generation);
const auto launched = LaunchWebViewHelper(
helper.path, "https://www.roblox.com/challenge/cdn/hybrid");
ASSERT_TRUE(launched) << launched.error;
helper.process = launched.process;
ASSERT_TRUE(helper.process->WaitUntilReady(std::chrono::seconds(2)));
AttachProcess(composition.get(), helper.process);
Activate(composition.get(), false, generation, generation, probe);
PhysicalExit(composition.get(), generation - 1);
EXPECT_TRUE(HasProcess(composition.get()));
EXPECT_TRUE(Close(composition.get()).ok());
EXPECT_FALSE(HasProcess(composition.get()));
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(2);
while (helper.process->running() &&
std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
EXPECT_FALSE(helper.process->running());
PhysicalExit(composition.get(), generation);
EXPECT_EQ(probe->calls, generation - 6);
(void)helper.process->RequestClose();
}
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
CrossProtocolCloseOwnsSharedSurfaceAndIgnoresStaleGeneration) {
auto composition = MakeComposition();
auto probe = std::make_shared<ExitProbe>();
Activate(composition.get(), true, 9, 13, probe);
EXPECT_TRUE(Close(composition.get()).ok());
EXPECT_EQ(probe->calls, 1);
PhysicalExit(composition.get(), 8);
EXPECT_EQ(probe->calls, 1);
PhysicalExit(composition.get(), 9);
PhysicalExit(composition.get(), 9);
EXPECT_EQ(probe->calls, 1);
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
WaitsForSlowHelperStartupBeforeRejectingSurface) {
Helper helper("import time\ntime.sleep(3.5)\n");
ASSERT_FALSE(helper.path.empty());
ASSERT_EQ(setenv("MOCKTAIL_WEBVIEW_HELPER", helper.path.c_str(), 1), 0);
SecureRobloxCredential credential{".ROBLOSECURITY=typed-secret"};
auto composition = MakeComposition(&credential);
auto probe = std::make_shared<ExitProbe>();
::testing::internal::CaptureStderr();
const Status status = Open(composition.get(), probe);
const std::string log = ::testing::internal::GetCapturedStderr();
unsetenv("MOCKTAIL_WEBVIEW_HELPER");
EXPECT_TRUE(status.ok()) << status.message();
const std::string marker = "Roblox web surface launched in ";
const size_t at = log.find(marker);
ASSERT_NE(at, std::string::npos) << log;
EXPECT_GE(std::stol(log.substr(at + marker.size())), 3500) << log;
EXPECT_TRUE(Close(composition.get()).ok());
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
ReportsElapsedStartupTimeWhenHelperNeverBecomesReady) {
Helper helper("sys.exit(0)\n");
ASSERT_FALSE(helper.path.empty());
ASSERT_EQ(setenv("MOCKTAIL_WEBVIEW_HELPER", helper.path.c_str(), 1), 0);
SecureRobloxCredential credential{".ROBLOSECURITY=typed-secret"};
auto composition = MakeComposition(&credential);
auto probe = std::make_shared<ExitProbe>();
const Status status = Open(composition.get(), probe);
unsetenv("MOCKTAIL_WEBVIEW_HELPER");
ASSERT_FALSE(status.ok());
EXPECT_NE(status.message().find("did not become ready"), std::string::npos)
<< status.message();
EXPECT_NE(status.message().find(" ms of 5000 ms"), std::string::npos)
<< status.message();
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
ForwardsSolvedChallengeAndLeavesClosingToRoblox) {
jnivm::VM vm;
Probe probe{&vm};
g_probe = &probe;
auto composition = MakeWebViewComposition(&vm, &probe);
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
auto exit_probe = std::make_shared<ExitProbe>();
Activate(composition.get(), false, 17, 23, exit_probe);
const std::string success = ChallengeSuccess();
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
{WebViewHelperEventType::kRobloxWkHybrid,
success})
.ok());
EXPECT_EQ(probe.web_view_javascript_callback, success);
EXPECT_TRUE(SolvedChallengeClosePending(composition.get()));
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
EXPECT_EQ(exit_probe->calls, 0);
ASSERT_TRUE(Close(composition.get()).ok());
EXPECT_EQ(exit_probe->calls, 1);
ExpireSolvedChallengeGrace(composition.get());
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
EXPECT_EQ(exit_probe->calls, 1);
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
EXPECT_TRUE(composition->Shutdown().ok());
g_probe = nullptr;
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
ClosesSolvedChallengeWhenRobloxLeavesItOpen) {
jnivm::VM vm;
Probe probe{&vm};
g_probe = &probe;
auto composition = MakeWebViewComposition(&vm, &probe);
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
auto exit_probe = std::make_shared<ExitProbe>();
Activate(composition.get(), false, 17, 23, exit_probe);
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
{WebViewHelperEventType::kExecuteRoblox,
ChallengeSuccess()})
.ok());
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
EXPECT_EQ(exit_probe->calls, 0);
ExpireSolvedChallengeGrace(composition.get());
::testing::internal::CaptureStderr();
const Status status = composition->DrainPlatformEvents();
const std::string log = ::testing::internal::GetCapturedStderr();
ASSERT_TRUE(status.ok()) << status.message();
EXPECT_EQ(exit_probe->calls, 1);
EXPECT_NE(log.find("closing solved challenge window"), std::string::npos)
<< log;
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
EXPECT_TRUE(composition->Shutdown().ok());
g_probe = nullptr;
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
ClosesSolvedCaptchaWindowAfterForwardingSuccess) {
jnivm::VM vm;
Probe probe{&vm};
g_probe = &probe;
auto composition = MakeWebViewComposition(&vm, &probe);
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
auto exit_probe = std::make_shared<ExitProbe>();
Activate(composition.get(), false, 17, 23, exit_probe);
MarkClosesWhenChallengeSolved(composition.get());
const std::string success = ChallengeSuccess();
::testing::internal::CaptureStderr();
const Status status = RouteCurrentEvent(
composition.get(), 17, 23,
{WebViewHelperEventType::kRobloxWkHybrid, success});
const std::string log = ::testing::internal::GetCapturedStderr();
ASSERT_TRUE(status.ok()) << status.message();
EXPECT_EQ(probe.web_view_javascript_callback, success);
EXPECT_EQ(exit_probe->calls, 1);
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
EXPECT_NE(log.find("closing solved captcha window"), std::string::npos)
<< log;
EXPECT_TRUE(composition->Shutdown().ok());
g_probe = nullptr;
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
UnsolvedChallengeEventsKeepWindowOpen) {
jnivm::VM vm;
Probe probe{&vm};
g_probe = &probe;
auto composition = MakeWebViewComposition(&vm, &probe);
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
auto exit_probe = std::make_shared<ExitProbe>();
Activate(composition.get(), false, 17, 23, exit_probe);
const std::string shown =
R"({"moduleID":"Navigation","functionName":"navigateToFeature",)"
R"("params":{"params":{"feature":"CaptchaShown"}},"callbackID":"c"})";
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
{WebViewHelperEventType::kRobloxWkHybrid,
shown})
.ok());
EXPECT_EQ(probe.web_view_javascript_callback, shown);
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
EXPECT_EQ(exit_probe->calls, 0);
EXPECT_TRUE(Close(composition.get()).ok());
EXPECT_TRUE(composition->Shutdown().ok());
g_probe = nullptr;
}
namespace {} // namespace
} // namespace runtime
} // namespace mocktail