diff --git a/testlab/src/android/testlab.cc b/testlab/src/android/testlab.cc new file mode 100644 index 0000000000..3d967d4fff --- /dev/null +++ b/testlab/src/android/testlab.cc @@ -0,0 +1,103 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "testlab/src/include/firebase/testlab.h" + +#include + +#include "app/src/include/firebase/app.h" +#include "app/src/include/firebase/log.h" +#include "app/src/log.h" +#include "app/src/reference_count.h" +#include "app/src/util.h" +#include "app/src/util_android.h" +#include "testlab/src/android/util.h" +#include "testlab/src/common/common.h" + +using firebase::internal::ReferenceCount; +using firebase::internal::ReferenceCountLock; + +namespace firebase { +namespace test_lab { +namespace game_loop { + +static ReferenceCount g_initializer; // NOLINT + +namespace internal { + +// Determine whether the test lab module is initialized. +bool IsInitialized() { return g_initializer.references() > 0; } + +} // namespace internal + +// Initialize the API +void Initialize(const firebase::App& app) { + ReferenceCountLock ref_count(&g_initializer); + if (ref_count.references() != 0) { + LogWarning("Test Lab API already initialized"); + return; + } + ref_count.AddReference(); + LogDebug("Firebase Test Lab API initializing"); + internal::Initialize(&app); +} + +// Clean up the API +void Terminate() { + ReferenceCountLock ref_count(&g_initializer); + if (ref_count.references() == 0) { + LogWarning("Test Lab API was never initialized"); + return; + } + if (ref_count.references() == 1) { + internal::Terminate(); + } + ref_count.RemoveReference(); +} + +// Return the game loop scenario's integer ID, or 0 if no game loop is running +int GetScenario() { + if (!internal::IsInitialized()) return 0; + return internal::GetScenario(); +} + +// Log progress text to the game loop's custom results and device logs +void LogText(const char* format, ...) { + if (GetScenario() == 0) return; + va_list args; + va_start(args, format); + internal::LogText(format, args); + va_end(args); +} + +// Complete the game loop scenario with the specified outcome +void FinishScenario(ScenarioOutcome outcome) { + if (GetScenario() == 0) return; + FILE* result_file = internal::RetrieveCustomResultsFile(); + if (result_file == nullptr) { + LogError("Could not obtain the custom results file"); + } else { + internal::OutputResult(outcome, result_file); + } + internal::CallFinish(); + Terminate(); + // TODO(brandonmorris): This works, but isn't the proper way to exit the app. + // Look into either using ANativeActivity_finish or calling finish() on the + // main thread. + exit(0); +} + +} // namespace game_loop +} // namespace test_lab +} // namespace firebase diff --git a/testlab/src/android/util.cc b/testlab/src/android/util.cc new file mode 100644 index 0000000000..87ef68d75d --- /dev/null +++ b/testlab/src/android/util.cc @@ -0,0 +1,328 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include +#include + +#include "app/src/include/firebase/app.h" +#include "app/src/util_android.h" +#include "testlab/src/common/common.h" + +namespace firebase { +namespace test_lab { +namespace game_loop { +namespace internal { + +static const ::firebase::App* g_app = nullptr; +static const char* kFirebaseTestLabAuthority = + "content://com.google.firebase.testlab"; +static const char* kScenarioCol = "scenario"; +static const char* kCustomResultsCol = "customResultUri"; + +std::string* g_custom_result_uri; + +int GetScenario(); +static void InitFromIntent(); +static bool InitFromContentProvider(); +static void GetIntentUri(); + +FILE* GetTempFile(); + +void CreateOrOpenLogFile() { + if (!g_log_file) { + g_log_file = GetTempFile(); + } + if (!g_log_file) { + LogError( + "Could not create a cache directory file for custom results. No custom " + "results will " + "be logged for the duration of the game loop scenario. %s", + strerror(errno)); + } +} + +void Initialize(const ::firebase::App* app) { + g_app = app; + CreateOrOpenLogFile(); + if (!InitFromContentProvider()) { + LogDebug( + "Could not find scenario data from content provider, falling back to " + "intent"); + InitFromIntent(); + } +} + +void Terminate() { + g_app = nullptr; + if (g_custom_result_uri) { + delete g_custom_result_uri; + g_custom_result_uri = nullptr; + } + SetScenario(0); + CloseLogFile(); + TerminateCommon(); +} + +// Creates a global reference to the cursor retrieved by querying the FTL +// content provider. Caller is repsonsible for deleting the reference. +static jobject QueryContentProvider() { + JNIEnv* env = g_app->GetJNIEnv(); + // contentResolver = activity.getContentResolver() + jobject content_resolver = env->CallObjectMethod( + g_app->activity(), + util::activity::GetMethodId(util::activity::kGetContentResolver)); + if (util::CheckAndClearJniExceptions(env) || content_resolver == nullptr) + return nullptr; + jstring authority = env->NewStringUTF(kFirebaseTestLabAuthority); + // authority = Uri.parse(authorityStr) + jobject authority_uri = env->CallStaticObjectMethod( + util::uri::GetClass(), util::uri::GetMethodId(util::uri::kParse), + authority); + util::CheckAndClearJniExceptions(env); + // cursor = contentResolver.query(authority, null, null, null, null) + jobject cursor_local = env->CallObjectMethod( + content_resolver, + util::content_resolver::GetMethodId(util::content_resolver::kQuery), + authority_uri, nullptr, nullptr, nullptr, nullptr); + jobject cursor = env->NewGlobalRef(cursor_local); + env->DeleteLocalRef(cursor_local); + env->DeleteLocalRef(content_resolver); + env->DeleteLocalRef(authority); + env->DeleteLocalRef(authority_uri); + if (util::CheckAndClearJniExceptions(env) || cursor == nullptr) + return nullptr; + return cursor; +} + +static int GetScenarioFromCursor(jobject cursor) { + if (cursor == nullptr) return 0; + JNIEnv* env = g_app->GetJNIEnv(); + // int scenarioCol = cursor.getColumnIndex(SCENARIO_COL); + jstring scenario_col_name = env->NewStringUTF(kScenarioCol); + int scenario_col = env->CallIntMethod( + cursor, util::cursor::GetMethodId(util::cursor::kGetColumnIndex), + scenario_col_name); + env->DeleteLocalRef(scenario_col_name); + if (util::CheckAndClearJniExceptions(env) || scenario_col == -1) return 0; + + // cursor.moveToFirst() + env->CallBooleanMethod(cursor, + util::cursor::GetMethodId(util::cursor::kMoveToFirst)); + util::CheckAndClearJniExceptions(env); + + // cursor.getInt(scenarioCol) + int scenario = env->CallIntMethod( + cursor, util::cursor::GetMethodId(util::cursor::kGetInt), scenario_col); + util::CheckAndClearJniExceptions(env); + LogDebug("Retrieved scenario from the content provider: %d", scenario); + return scenario; +} + +static const char* GetResultsUriFromCursor(jobject cursor) { + if (cursor == nullptr) return nullptr; + JNIEnv* env = g_app->GetJNIEnv(); + // int customResultCol = cursor.getColumnIndex(CUSTOM_RESULT_COL); + jstring custom_result_col_name = env->NewStringUTF(kCustomResultsCol); + int custom_result_col = env->CallIntMethod( + cursor, util::cursor::GetMethodId(util::cursor::kGetColumnIndex), + custom_result_col_name); + env->DeleteLocalRef(custom_result_col_name); + if (util::CheckAndClearJniExceptions(env) || custom_result_col == -1) + return nullptr; + + // cursor.moveToFirst() + env->CallBooleanMethod(cursor, + util::cursor::GetMethodId(util::cursor::kMoveToFirst)); + util::CheckAndClearJniExceptions(env); + + // cursor.getString(customResultCol) + jobject custom_result_str = env->CallObjectMethod( + cursor, util::cursor::GetMethodId(util::cursor::kGetString), + custom_result_col); + util::CheckAndClearJniExceptions(env); + const char* custom_result = + util::JniStringToString(env, custom_result_str).c_str(); + LogDebug("Found the custom result uri string from the content provider: %s", + custom_result); + return custom_result; +} + +// Attempt to initialize game loop scenario data from the Test Lab content +// provider and return whether successful +static bool InitFromContentProvider() { + JNIEnv* env = g_app->GetJNIEnv(); + jobject cursor = QueryContentProvider(); + if (cursor == nullptr) { + LogWarning( + "Firebase Test Lab content provider does not exist or could not be " + "queried."); + return false; + } + + int scenario = GetScenarioFromCursor(cursor); + if (scenario == 0) return false; + SetScenario(scenario); + const char* custom_result = GetResultsUriFromCursor(cursor); + env->DeleteGlobalRef(cursor); + if (custom_result == nullptr) return false; + g_custom_result_uri = new std::string(custom_result); + + return true; +} + +// Helper method to initialize the game loop scenario number +static void InitFromIntent() { + JNIEnv* env = g_app->GetJNIEnv(); + jobject activity = env->NewLocalRef(g_app->activity()); + + // Intent intent = app.getIntent(); + jobject intent = env->CallObjectMethod( + activity, util::activity::GetMethodId(util::activity::kGetIntent)); + if (util::CheckAndClearJniExceptions(env) || !intent) return; + + // scenario = intent.getIntExtra("scenario"); + jstring scenario_key = env->NewStringUTF("scenario"); + int scenario = env->CallIntMethod( + intent, util::intent::GetMethodId(util::intent::kGetIntExtra), + scenario_key, 0); + util::CheckAndClearJniExceptions(env); + env->DeleteLocalRef(intent); + + LogInfo("Received the scenario number %d", scenario); + SetScenario(scenario); + GetIntentUri(); +} + +// Helper method to retrieve the URI data from the intent +static void GetIntentUri() { + JNIEnv* env = g_app->GetJNIEnv(); + jobject intent = env->CallObjectMethod( + g_app->activity(), + util::activity::GetMethodId(util::activity::kGetIntent)); + util::CheckAndClearJniExceptions(env); + jobject uri = env->CallObjectMethod( + intent, util::intent::GetMethodId(util::intent::kGetData)); + env->DeleteLocalRef(intent); + util::CheckAndClearJniExceptions(env); + if (uri == nullptr) { + LogError( + "Intent did not contain a valid file descriptor for the game " + "loop custom results. If you manually set the scenario number, " + "you must also provide a custom results directory or no results " + "will be logged"); + return; + } + jobject uri_str = + env->CallObjectMethod(uri, util::uri::GetMethodId(util::uri::kToString)); + env->DeleteLocalRef(uri); + std::string custom_result_str = util::JniStringToString(env, uri_str); + g_custom_result_uri = new std::string(custom_result_str); +} + +// Helper method to initialize the custom results logging +FILE* RetrieveCustomResultsFile() { + if (g_results_dir != nullptr) { + return OpenCustomResultsFile(GetScenario()); + } + if (g_custom_result_uri == nullptr) { + LogError( + "No URI of a custom results asset were found, no custom results will " + "be logged."); + return nullptr; + } + JNIEnv* env = g_app->GetJNIEnv(); + // activity.getContentResolver() + jobject content_resolver = env->CallObjectMethod( + g_app->activity(), + util::activity::GetMethodId(util::activity::kGetContentResolver)); + + jstring uri_str = env->NewStringUTF(g_custom_result_uri->c_str()); + jobject uri = env->CallStaticObjectMethod( + util::uri::GetClass(), util::uri::GetMethodId(util::uri::kParse), + uri_str); + util::CheckAndClearJniExceptions(env); + env->DeleteLocalRef(uri_str); + + // contentResolver.openAssetFileDescriptor(Uri, String) + jobject asset_file_descriptor = env->CallObjectMethod( + content_resolver, + util::content_resolver::GetMethodId( + util::content_resolver::kOpenAssetFileDescriptor), + uri, env->NewStringUTF("w")); + util::CheckAndClearJniExceptions(env); + env->DeleteLocalRef(uri); + env->DeleteLocalRef(content_resolver); + + // assetFileDescriptor.getParcelFileDescriptor() + jobject parcel_file_descriptor = env->CallObjectMethod( + asset_file_descriptor, + util::asset_file_descriptor::GetMethodId( + util::asset_file_descriptor::kGetParcelFileDescriptor)); + util::CheckAndClearJniExceptions(env); + env->DeleteLocalRef(asset_file_descriptor); + + // parcelFileDescriptor.detachFd() + jint fd = env->CallIntMethod(parcel_file_descriptor, + util::parcel_file_descriptor::GetMethodId( + util::parcel_file_descriptor::kDetachFd)); + util::CheckAndClearJniExceptions(env); + env->DeleteLocalRef(parcel_file_descriptor); + + FILE* log_file = fdopen(fd, "w"); + if (env->ExceptionCheck() || log_file == nullptr) { + LogError( + "Firebase game loop custom results file could not be opened. Any " + "logged results will not appear in the test's custom results."); + } + return log_file; +} + +FILE* GetTempFile() { + JNIEnv* env = g_app->GetJNIEnv(); + // File cache_dir_file = getCacheDir(); + jobject cache_dir_file = env->CallObjectMethod( + g_app->activity(), + util::activity::GetMethodId(util::activity::kGetCacheDir)); + if (util::CheckAndClearJniExceptions(env) || !cache_dir_file) { + LogError("Could not obtain a temporary file"); + return nullptr; + } + + // String cache_dir_path = cache_dir_file.getPath(); + jstring cache_dir_path = static_cast(env->CallObjectMethod( + cache_dir_file, util::file::GetMethodId(util::file::kGetPath))); + std::string cache_dir_string = util::JniStringToString(env, cache_dir_path); + env->DeleteLocalRef(cache_dir_file); + if (util::CheckAndClearJniExceptions(env) || !cache_dir_path) { + LogError("Could not obtain a temporary file"); + return nullptr; + } + + std::string cache_file = cache_dir_string + "gameloopresultstemp.txt"; + return fopen(cache_file.c_str(), "w+"); +} + +void CallFinish() { + JNIEnv* env = g_app->GetJNIEnv(); + jobject activity = env->NewLocalRef(g_app->activity()); + env->CallVoidMethod(activity, + util::activity::GetMethodId(util::activity::kFinish)); + env->DeleteLocalRef(activity); + util::CheckAndClearJniExceptions(env); +} + +} // namespace internal +} // namespace game_loop +} // namespace test_lab +} // namespace firebase diff --git a/testlab/src/android/util.h b/testlab/src/android/util.h new file mode 100644 index 0000000000..5f632fb022 --- /dev/null +++ b/testlab/src/android/util.h @@ -0,0 +1,48 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef FIREBASE_TESTLAB_CLIENT_CPP_SRC_ANDROID_UTIL_H_ +#define FIREBASE_TESTLAB_CLIENT_CPP_SRC_ANDROID_UTIL_H_ + +#include + +#include "app/src/include/firebase/app.h" + +namespace firebase { +namespace test_lab { +namespace game_loop { +namespace internal { + +/// Returns true if the Test Lab API has been initialized and a game loop is +/// running +bool IsInitialized(); + +/// Prepares any platform-specific resources associated with the SDK. +void Initialize(const ::firebase::App* app); + +/// Cleans up any platform-specific resources associated with the SDK. +void Terminate(); + +/// Obtains a file handle to the custom results file sent by the intent. +FILE* RetrieveCustomResultsFile(); + +/// Calls finish() on the activity, ending the game loop scenario. +void CallFinish(); + +} // namespace internal +} // namespace game_loop +} // namespace test_lab +} // namespace firebase + +#endif // FIREBASE_TESTLAB_CLIENT_CPP_SRC_ANDROID_UTIL_H_ diff --git a/testlab/src/common/common.cc b/testlab/src/common/common.cc new file mode 100644 index 0000000000..494e9f9db8 --- /dev/null +++ b/testlab/src/common/common.cc @@ -0,0 +1,212 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "testlab/src/common/common.h" + +#include +#include +#include + +#include +#include +#include +#include + +#include "app/src/log.h" +#include "app/src/util.h" +#include "testlab/scenario_result_generated.h" +#include "testlab/scenario_result_resource.h" +#include "testlab/src/include/firebase/testlab.h" +#include "flatbuffers/idl.h" +#include "flatbuffers/util.h" + +// Register the module initializer. +FIREBASE_APP_REGISTER_CALLBACKS( + test_lab, + { + if (app == ::firebase::App::GetInstance()) { + firebase::test_lab::game_loop::Initialize(*app); + } + return kInitResultSuccess; + }, + { + if (app == ::firebase::App::GetInstance()) { + firebase::test_lab::game_loop::Terminate(); + } + }); + +namespace firebase { +namespace test_lab { +namespace game_loop { + +void SetScenario(int scenario_number) { + if (!internal::IsInitialized() || GetScenario() == scenario_number) return; + LogDebug("Resetting scenario number to %d", scenario_number); + internal::ResetLog(); + internal::SetScenario(scenario_number); +} + +void SetResultsDirectory(const char* path) { + internal::SetResultsDirectory(path); +} + +std::string GetResultsDirectory() { return internal::GetResultsDirectory(); } + +namespace internal { + +FILE* g_log_file; +std::string* g_results_dir; + +static const char* kRootType = "ScenarioResult"; + +static int g_scenario = 0; + +void SetScenario(int scenario) { g_scenario = scenario; } +int GetScenario() { return g_scenario; } +std::string GetResultsDirectory() { + if (g_results_dir == nullptr) { + return ""; + } + return *g_results_dir; +} + +std::vector ReadLines(FILE* file); +std::string ScenarioToString(int scenario); + +const char* OutcomeToString( + ::firebase::test_lab::game_loop::ScenarioOutcome outcome) { + switch (outcome) { + case kScenarioOutcomeSuccess: + return "success"; + case kScenarioOutcomeFailure: + return "failure"; + } +} + +void OutputResult(::firebase::test_lab::game_loop::ScenarioOutcome outcome, + FILE* result_file) { + flatbuffers::FlatBufferBuilder builder(1024); + std::string outcome_string = OutcomeToString(outcome); + auto outcome_string_offset = builder.CreateString(outcome_string); + std::rewind(g_log_file); + std::vector logs = ReadLines(g_log_file); + auto text_log_offset = builder.CreateVectorOfStrings(logs); + ScenarioResultBuilder result_builder(builder); + result_builder.add_scenario_number(GetScenario()); + result_builder.add_outcome(outcome_string_offset); + result_builder.add_text_log(text_log_offset); + auto result = result_builder.Finish(); + builder.Finish(result); + flatbuffers::Parser parser; + const char* schema = + reinterpret_cast(scenario_result_resource_data); + parser.Parse(schema); + parser.SetRootType(kRootType); + std::string jsongen; + flatbuffers::GenerateText(parser, builder.GetBufferPointer(), &jsongen); + fprintf(result_file, "%s", jsongen.c_str()); + fflush(result_file); +} + +void LogText(const char* format, va_list args) { + vfprintf(g_log_file, format, args); + fprintf(g_log_file, "\n"); + fflush(g_log_file); + LogMessageV(firebase::kLogLevelDebug, format, args); +} + +void CloseLogFile() { + if (g_log_file) { + fclose(g_log_file); + g_log_file = nullptr; + } +} + +std::vector ReadLines(FILE* file) { + fseek(file, 0, SEEK_END); + auto file_size = ftell(file); + rewind(file); + std::vector buffer(file_size); + if (file_size != fread(&buffer[0], 1, file_size, file)) { + LogError( + "Could not read the custom results log file. Any results logged during " + "the game loop scenario will not be included in the custom results."); + return std::vector(); + } + return TokenizeByCharacter(buffer, '\n'); +} + +std::vector TokenizeByCharacter(std::vector buffer, + char token) { + std::vector tokens; + auto line_begin = buffer.begin(); + auto buffer_end = buffer.end(); + while (line_begin < buffer_end) { + auto line_end = std::find(line_begin, buffer_end, token); + tokens.push_back(std::string(line_begin, line_end)); + line_begin = line_end + 1; + } + return tokens; +} + +void TerminateCommon() { SetResultsDirectory(nullptr); } + +void ResetLog() { + if (g_log_file) { + fclose(g_log_file); + g_log_file = nullptr; + } + CreateOrOpenLogFile(); +} + +void SetResultsDirectory(const char* path) { + if (g_results_dir) { + delete g_results_dir; + g_results_dir = nullptr; + } + if (path && strlen(path)) { + g_results_dir = new std::string(path); + } +} + +FILE* OpenCustomResultsFile(int scenario) { + std::string file_name = + "results_scenario_" + ScenarioToString(scenario) + ".json"; + std::string file_path; + if (g_results_dir != nullptr) { + file_path = *g_results_dir + "/" + file_name; + } else { + file_path = file_name; + } + FILE* file = fopen(file_path.c_str(), "w"); + if (file == nullptr) { + LogError( + "Could not open custom results file at %s. Results for this scenario " + "will not be included: %s", + file_path.c_str(), strerror(errno)); + } + return file; +} + +// std::to_string() isn't supported on Android NDK +std::string ScenarioToString(int scenario) { + std::ostringstream stream; + stream << scenario; + return stream.str(); +} + +} // namespace internal +} // namespace game_loop +} // namespace test_lab +} // namespace firebase diff --git a/testlab/src/common/common.h b/testlab/src/common/common.h new file mode 100644 index 0000000000..b6efeb062e --- /dev/null +++ b/testlab/src/common/common.h @@ -0,0 +1,66 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef FIREBASE_TESTLAB_CLIENT_CPP_SRC_COMMON_COMMON_H_ +#define FIREBASE_TESTLAB_CLIENT_CPP_SRC_COMMON_COMMON_H_ + +#include + +#include +#include + +#include "testlab/src/include/firebase/testlab.h" + +namespace firebase { +namespace test_lab { +namespace game_loop { +namespace internal { + +extern FILE* g_log_file; +extern std::string* g_results_dir; + +void SetScenario(int scenario); +int GetScenario(); + +// Formats and produces the custom result file +void OutputResult(::firebase::test_lab::game_loop::ScenarioOutcome outcome, + FILE* result_file); + +// Format and write the scenario's custom results. Does not close the file after +// writing. +void LogText(const char* format, va_list args); + +void TerminateCommon(); + +void CreateOrOpenLogFile(); // Implemented in platform specific module +void CloseLogFile(); +bool IsInitialized(); + +std::vector TokenizeByCharacter(std::vector buffer, + char token); + +void ResetLog(); + +void SetResultsDirectory(const char* path); + +std::string GetResultsDirectory(); + +FILE* OpenCustomResultsFile(int scenario); + +} // namespace internal +} // namespace game_loop +} // namespace test_lab +} // namespace firebase + +#endif // FIREBASE_TESTLAB_CLIENT_CPP_SRC_COMMON_COMMON_H_ diff --git a/testlab/src/common/scenario_result.fbs b/testlab/src/common/scenario_result.fbs new file mode 100644 index 0000000000..0020b3d09c --- /dev/null +++ b/testlab/src/common/scenario_result.fbs @@ -0,0 +1,30 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The FlatBuffers schema for game loop result data + +// The namespace is defined to match the internal package. +namespace firebase.test_lab.game_loop.internal; + +// Data table that describes the result of a game loop scenario. +table ScenarioResult { + // The scenario's numeric ID + scenario_number:int; + + // The outcome of the scenario, e.g. success. + outcome:string; + + // Messages logged by the user during the scenario's execution. + text_log:[string]; +} diff --git a/testlab/src/desktop/testlab_desktop.cc b/testlab/src/desktop/testlab_desktop.cc new file mode 100644 index 0000000000..4a6d73ff63 --- /dev/null +++ b/testlab/src/desktop/testlab_desktop.cc @@ -0,0 +1,240 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include + +#include "app/src/include/firebase/app.h" +#include "app/src/log.h" +#include "app/src/path.h" +#include "app/src/reference_count.h" +#include "testlab/src/common/common.h" +#include "testlab/src/include/firebase/testlab.h" +#include "flatbuffers/util.h" + +#if defined(_WIN32) +// windows.h must be first to define basic Windows types. +// clang-format off +#include // NOLINT +// clang-format on +#include +#include +#else +#include +#include +#endif // defined(_WIN32) + +#if FIREBASE_PLATFORM_OSX +#include "testlab/src/desktop/testlab_macos.h" +#endif + +#if FIREBASE_PLATFORM_WINDOWS +#define mkdir(x, y) _mkdir(x) +#endif // FIREBASE_PLATFORM_WINDOWS + +using firebase::internal::ReferenceCount; +using firebase::internal::ReferenceCountLock; + +namespace firebase { +namespace test_lab { +namespace game_loop { + +static ReferenceCount g_initializer; // NOLINT + +static const char* kScenarioFlagPrefix = "--game_loop_scenario="; +static const char* kResultsDirFlagPrefix = "--game_loop_results_dir="; +static const char* kLogFileName = "firebase-game-loop.log"; + +namespace internal { + +// Determine whether the test lab module is initialized. +bool IsInitialized() { return g_initializer.references() > 0; } +static void ParseCommandLineArgs(); +static FILE* GetCustomResultsFile(); + +} // namespace internal + +void Initialize(const ::firebase::App& app) { + ReferenceCountLock ref_count(&g_initializer); + if (ref_count.references() != 0) { + LogWarning("Test Lab API already initialized"); + return; + } + ref_count.AddReference(); + internal::CreateOrOpenLogFile(); + internal::ParseCommandLineArgs(); +} + +void Terminate() { + ReferenceCountLock ref_count(&g_initializer); + if (ref_count.references() == 0) { + LogWarning("Test Lab API was terminated or never initialized"); + return; + } + if (ref_count.references() == 1) { + internal::SetScenario(0); + internal::CloseLogFile(); + internal::TerminateCommon(); + } + ref_count.RemoveReference(); +} + +int GetScenario() { + if (!internal::IsInitialized()) { + return 0; + } + return internal::GetScenario(); +} + +void LogText(const char* format, ...) { + ReferenceCountLock ref_count(&g_initializer); + if (GetScenario() == 0) return; + va_list args; + va_start(args, format); + internal::LogText(format, args); + va_end(args); +} + +void FinishScenario(::firebase::test_lab::game_loop::ScenarioOutcome outcome) { + if (GetScenario() == 0) return; + FILE* result_file = internal::OpenCustomResultsFile(GetScenario()); + if (result_file != nullptr) { + internal::OutputResult(outcome, result_file); + } + Terminate(); + // TODO(brandonmorris): Find a way to signal a test is complete (e.g. write to + // a file in the results dir or set an env var). +} + +namespace internal { + +static bool NotEmpty(const char* str) { + return str != nullptr && str[0] != '\0'; +} + +static const char* GetTempDir() { + char* temp; +#if defined(_WIN32) + temp = std::getenv("TMP"); + if (NotEmpty(temp)) return temp; + temp = std::getenv("TEMP"); + if (NotEmpty(temp)) return temp; + temp = std::getenv("USERPROFILE"); + if (NotEmpty(temp)) return temp; + // If all else fails, return the current directory + return ""; +#else + temp = std::getenv("TMPDIR"); + if (NotEmpty(temp)) return temp; + temp = std::getenv("TMP"); + if (NotEmpty(temp)) return temp; + temp = std::getenv("TEMP"); + if (NotEmpty(temp)) return temp; + temp = std::getenv("TEMPDIR"); + if (NotEmpty(temp)) return temp; + return "/tmp"; +#endif // defined(_WIN32) +} + +void CreateOrOpenLogFile() { + std::string temp = GetTempDir(); + std::string log_filename = temp + "/" + kLogFileName; + g_log_file = fopen(log_filename.c_str(), "w+"); + if (g_log_file == nullptr) { + LogError( + "Could not open the temporary log file at %s. Any logs from this game " + "loop scenario will not be included in the custom results: %s", + log_filename.c_str(), strerror(errno)); + } +} + +static std::string GetResultFilename() { + if (GetScenario() <= 0) { + return ""; + } + return "results_scenario_" + std::to_string(GetScenario()) + ".json"; +} + +static std::string GetArgumentForPrefix(std::string prefix, + std::vector arguments) { + for (const std::string& argument : arguments) { + if (argument.compare(0, prefix.size(), prefix) == 0) { + return argument.substr(prefix.size()); + } + } + return std::string(); +} + +static void SetScenarioIfNotEmpty(std::string scenario_str) { + if (!scenario_str.empty()) { + int scenario; + flatbuffers::StringToNumber(scenario_str.c_str(), &scenario); + SetScenario(scenario); + } +} + +static std::vector GetCommandLineArgs() { +#if FIREBASE_PLATFORM_WINDOWS + wchar_t** arg_list; + int n_args; + arg_list = CommandLineToArgvW(GetCommandLineW(), &n_args); + std::vector arguments(n_args); + for (int i = 0; i < n_args; i++) { + const wchar_t* arg = arg_list[i]; + int arg_characters = static_cast(wcslen(arg_list[i])); + int size_needed = WideCharToMultiByte(CP_UTF8, 0, arg, arg_characters, NULL, + 0, NULL, NULL); + std::string argument(size_needed, 0); + std::vector buffer(size_needed, 0); + WideCharToMultiByte(CP_UTF8, 0, arg, arg_characters, &buffer[0], + size_needed, NULL, NULL); + arguments[i].append(&buffer[0]); + } + LocalFree(arg_list); + return arguments; +#elif FIREBASE_PLATFORM_OSX + return GetArguments(); +#elif FIREBASE_PLATFORM_LINUX + FILE* proc = fopen("/proc/self/cmdline", "r"); + std::vector buffer; + // /proc "files" aren't real files, so we can't know the total length ahead of + // time and need to read one character at a time. + for (;;) { + int c = getc(proc); + if (c == EOF) break; + buffer.push_back(c); + } + return TokenizeByCharacter(buffer, '\0'); +#else +#warning Game loop command line flags will not be parsed + return std::vector(); +#endif +} + +static void ParseCommandLineArgs() { + std::vector arguments = GetCommandLineArgs(); + std::string scenario_str = + GetArgumentForPrefix(kScenarioFlagPrefix, arguments); + SetScenarioIfNotEmpty(scenario_str); + std::string directory = + GetArgumentForPrefix(kResultsDirFlagPrefix, arguments); + if (!directory.empty()) { + SetResultsDirectory(directory.c_str()); + } +} + +} // namespace internal + +} // namespace game_loop +} // namespace test_lab +} // namespace firebase diff --git a/testlab/src/desktop/testlab_macos.h b/testlab/src/desktop/testlab_macos.h new file mode 100644 index 0000000000..c2f199acc4 --- /dev/null +++ b/testlab/src/desktop/testlab_macos.h @@ -0,0 +1,33 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef FIREBASE_TESTLAB_CLIENT_CPP_SRC_DESKTOP_TESTLAB_MACOS_H_ +#define FIREBASE_TESTLAB_CLIENT_CPP_SRC_DESKTOP_TESTLAB_MACOS_H_ + +#include + +namespace firebase { +namespace test_lab { +namespace game_loop { +namespace internal { + +// Get the command line arguments used to launch the running process. +std::vector GetArguments(); + +} // namespace internal +} // namespace game_loop +} // namespace test_lab +} // namespace firebase + +#endif // FIREBASE_TESTLAB_CLIENT_CPP_SRC_DESKTOP_TESTLAB_MACOS_H_ diff --git a/testlab/src/desktop/testlab_macos.mm b/testlab/src/desktop/testlab_macos.mm new file mode 100644 index 0000000000..93d854f599 --- /dev/null +++ b/testlab/src/desktop/testlab_macos.mm @@ -0,0 +1,23 @@ +#include +#include + +#include + +namespace firebase { +namespace test_lab { +namespace game_loop { +namespace internal { + +std::vector GetArguments() { + NSArray* args = [[NSProcessInfo processInfo] arguments]; + std::vector arguments(args.count); + for (int i = 0; i < args.count; i++) { + arguments[i] = args[i].UTF8String; + } + return arguments; +} + +} // firebase +} // test_lab +} // game_loop +} // internal diff --git a/testlab/src/include/firebase/testlab.h b/testlab/src/include/firebase/testlab.h new file mode 100644 index 0000000000..91cbdac7e4 --- /dev/null +++ b/testlab/src/include/firebase/testlab.h @@ -0,0 +1,109 @@ +/* + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef FIREBASE_TESTLAB_CLIENT_CPP_SRC_INCLUDE_FIREBASE_TESTLAB_H_ +#define FIREBASE_TESTLAB_CLIENT_CPP_SRC_INCLUDE_FIREBASE_TESTLAB_H_ + +#include + +#include "firebase/app.h" +#include "firebase/internal/common.h" + +#if !defined(DOXYGEN) && !defined(SWIG) +FIREBASE_APP_REGISTER_CALLBACKS_REFERENCE(test_lab) +#endif // !defined(DOXYGEN) && !defined(SWIG) + +/// @brief Namespace that encompasses all Firebase APIs. +namespace firebase { + +/// @brief Firebase Test Lab API. +/// +/// See the developer guides for general +/// information on using Firebase Test Lab. +/// +/// This library is experimental and is not currently officially supported. +namespace test_lab { +namespace game_loop { + +/// @brief Indicate the outcome of a game loop scenario (e.g. success). +enum ScenarioOutcome { kScenarioOutcomeSuccess, kScenarioOutcomeFailure }; + +/// @brief Initialize the Test Lab Game Loop API. +/// +/// This must be called prior to calling any other methods in the +/// firebase::test_lab::game_loop namespace. +/// +/// @param[in] app Default firebase::App instance. +/// +/// @see firebase::App::GetInstance(). +void Initialize(const firebase::App& app); + +/// @brief Terminate the Test Lab Game Loop API. +/// +/// @note The application will continue to run after calling this method, but +/// any future calls to methods in the firebase::test_lab::game_loop namespace +/// will have no effect unless it is initialized again. +/// +/// @note If this function is called during a game loop, any results logged as +/// part of that game loop scenario will not appear in the scenario's custom +/// results. +/// +/// Cleans up resources associated with the Test Lab Game Loop API. +void Terminate(); + +/// @brief Retrieve the current scenario number of a game loop test. +/// +/// @return A positive integer representing the current game loop scenario, or +/// 0 if not game loop is running. +int GetScenario(); + +/// @brief Record progress of a game loop to the test's custom results. +/// +/// @note These messages also forwarded to the system log at the DEBUG level. +/// +/// @param[in] format Format string of the message to include in the scenario's +/// custom results file. +void LogText(const char* format, ...); + +/// @brief Complete the current game loop scenario and exit the application. +/// +/// @note This method implicitly calls `game_loop::Terminate()` prior to +/// exiting. If no game loop is running, this method has no effect. +/// +/// Finish the current game loop scenario by cleaning up its resources and +/// exiting the application. +void FinishScenario(ScenarioOutcome outcome); + +/// @brief Set the scenario of the currently running test. +/// +/// @note Calling this method and changing the scenario will clear any results +/// for the previous scenario. +void SetScenario(int scenario_number); + +/// @brief Set the directory where custom results will be written to when +/// FinishScenario() is called. +void SetResultsDirectory(const char* path); + +/// @brief The currently set directory where custom results will be written to +/// when FinishScenario() is called. If no directory has been set, this function +/// returns an empty string. +std::string GetResultsDirectory(); + +} // namespace game_loop +} // namespace test_lab +} // namespace firebase + +#endif // FIREBASE_TESTLAB_CLIENT_CPP_SRC_INCLUDE_FIREBASE_TESTLAB_H_ diff --git a/testlab/src/ios/custom_results.h b/testlab/src/ios/custom_results.h new file mode 100644 index 0000000000..e1b8e4e6ea --- /dev/null +++ b/testlab/src/ios/custom_results.h @@ -0,0 +1,38 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef FIREBASE_TESTLAB_CLIENT_CPP_SRC_IOS_CUSTOM_RESULTS_H_ +#define FIREBASE_TESTLAB_CLIENT_CPP_SRC_IOS_CUSTOM_RESULTS_H_ + +#include + +namespace firebase { +namespace test_lab { +namespace game_loop { +namespace internal { + +static const char* kResultsDir = "GameLoopResults"; + +// Creates and returns the custom results file. +FILE* CreateCustomResultsFile(int scenario); + +// Creates and returns the log file for storing intermediate results. +FILE* CreateLogFile(); + +} // namespace internal +} // namespace game_loop +} // namespace test_lab +} // namespace firebase + +#endif // FIREBASE_TESTLAB_CLIENT_CPP_SRC_IOS_CUSTOM_RESULTS_H_ diff --git a/testlab/src/ios/custom_results.mm b/testlab/src/ios/custom_results.mm new file mode 100644 index 0000000000..6e80da54f2 --- /dev/null +++ b/testlab/src/ios/custom_results.mm @@ -0,0 +1,124 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "testlab/src/ios/custom_results.h" + +#import + +#include +#include +#include "app/src/assert.h" +#include "app/src/util_ios.h" +#include "testlab/src/common/common.h" +#include "testlab/src/include/firebase/testlab.h" + +namespace firebase { +namespace test_lab { +namespace game_loop { +namespace internal { + +static const char* kCustomResultDirErrorMsgFmt = + "Could not create Test Lab's custom results directory. It will not " + "be possible to log test results for Test Lab game loop scenarios. " + "NSFileManager:URLForDirectory returned error %s"; +static const char* kCustomResultFileErrorMsgFmt = + "Test Lab could not create the custom result file. It will not be " + "possible to log test results for Test Lab test cases. " + "NSFileManager:createFileAtPath returned error code %d: %s"; + +static NSURL* GetDocumentsUrl(NSFileManager* manager); +static void LogCustomResultsDirError(NSError* error); +static FILE* OpenFileOrLogError(NSFileManager* manager, NSURL* file_url, const char* mode); + +void CreateOrOpenLogFile() { + if (!g_log_file) { + g_log_file = CreateLogFile(); + } +} + +FILE* CreateCustomResultsFile(int scenario) { + if (g_results_dir != nullptr) { + return OpenCustomResultsFile(scenario); + } + // iOS sandboxes apps' filesystems, so we need to find the documents directory + // through the NSFileManager + NSFileManager* manager = [NSFileManager defaultManager]; + NSURL* documents_url = GetDocumentsUrl(manager); + NSURL* results_dir = [documents_url URLByAppendingPathComponent:@(kResultsDir) isDirectory:YES]; + BOOL results_dir_exists = [manager fileExistsAtPath:results_dir.path]; + if (!results_dir_exists) { + NSError* error; + BOOL create_dir_result = [manager createDirectoryAtURL:results_dir + withIntermediateDirectories:NO + attributes:nil + error:&error]; + if (!create_dir_result) { + LogCustomResultsDirError(error); + return nullptr; + } + } + + // Use the filename that matches the Android results file created by FTL + NSString* result_filename = [NSString stringWithFormat:@"results_scenario_%d.json", scenario]; + NSURL* result_url = [results_dir URLByAppendingPathComponent:result_filename isDirectory:NO]; + return OpenFileOrLogError(manager, result_url, "w"); +} + +FILE* CreateLogFile() { + NSFileManager* manager = [NSFileManager defaultManager]; + NSURL* documents_url = GetDocumentsUrl(manager); + NSURL* log_url = [documents_url URLByAppendingPathComponent:@"firebase-game-loop.log" + isDirectory:NO]; + return OpenFileOrLogError(manager, log_url, "w+"); +} + +static NSURL* GetDocumentsUrl(NSFileManager* manager) { + NSError* error = nil; + NSURL* documents_url = [manager URLForDirectory:NSDocumentDirectory + inDomain:NSUserDomainMask + appropriateForURL:nil + create:NO + error:&error]; + if (documents_url == nil) { + LogCustomResultsDirError(error); + return nullptr; + } + return documents_url; +} + +static void LogCustomResultsDirError(NSError* error) { + NSString* errorMessage = [NSString stringWithFormat:@"%@", error]; + LogError(kCustomResultDirErrorMsgFmt, errorMessage.UTF8String); +} + +static FILE* OpenFileOrLogError(NSFileManager* manager, NSURL* file_url, const char* mode) { + BOOL create_file_result = [manager createFileAtPath:file_url.path contents:nil attributes:nil]; + if (!create_file_result) { + LogError(kCustomResultFileErrorMsgFmt, errno, strerror(errno)); + return nullptr; + } + std::string file_path = file_url.path.UTF8String; + FILE* file = std::fopen(file_path.c_str(), mode); + if (file == nullptr) { + LogError( + "Could not open file %s. Custom results for this scenario may be missing or incomplete", + file_path.c_str()); + } + return file; +} + +} // namespace internal +} // namespace game_loop +} // namespace test_lab +} // namespace firebase diff --git a/testlab/src/ios/testlab.mm b/testlab/src/ios/testlab.mm new file mode 100644 index 0000000000..59493dcef3 --- /dev/null +++ b/testlab/src/ios/testlab.mm @@ -0,0 +1,248 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "testlab/src/include/firebase/testlab.h" + +#import +#import + +#include "app/src/include/firebase/app.h" +#include "app/src/log.h" +#include "app/src/mutex.h" +#include "app/src/reference_count.h" +#include "app/src/util_ios.h" +#include "testlab/src/common/common.h" +#include "testlab/src/ios/custom_results.h" + +using firebase::internal::ReferenceCount; +using firebase::internal::ReferenceCountLock; + +namespace firebase { +namespace test_lab { +namespace game_loop { + +static ReferenceCount g_initializer; // NOLINT + +// String constants for iOS game loops +static const char* kFtlScheme = "firebase-game-loop"; +static const char* kFtlCompleteUrl = "firebase-game-loop-complete://"; +static const char* kScenario = "scenario"; + +namespace internal { + +// Determine whether the test lab module is initialized. +bool IsInitialized() { return g_initializer.references() > 0; } + +} // namespace internal + +static ::firebase::util::ClassMethodImplementationCache& SwizzledMethodCache() { + static ::firebase::util::ClassMethodImplementationCache* g_swizzled_method_cache; + return *::firebase::util::ClassMethodImplementationCache::GetCreateCache( + &g_swizzled_method_cache); +} + +// Initialize the game loop API +void Initialize(const ::firebase::App& app) { + ReferenceCountLock ref_count(&g_initializer); + if (ref_count.references() != 0) { + LogWarning("Test Lab API already initialized"); + return; + } + LogDebug("Firebase Test Lab API initializing"); + internal::CreateOrOpenLogFile(); + ref_count.AddReference(); +} + +// Release resources associated with the game loop API +void Terminate() { + ReferenceCountLock ref_count(&g_initializer); + if (ref_count.references() == 0) { + LogWarning("Test Lab API was never initialized"); + return; + } + LogDebug("Terminating the Firebase Test Lab API"); + if (ref_count.references() == 1) { + internal::CloseLogFile(); + internal::TerminateCommon(); + } + ref_count.RemoveReference(); +} + +// Return the current scenario number of the game loop test +int GetScenario() { + if (!internal::IsInitialized()) return 0; + return internal::GetScenario(); +} + +// Log formatted text to the game loop's custom results +void LogText(const char* format, ...) { + ReferenceCountLock ref_count(&g_initializer); + if (GetScenario() == 0) return; + va_list args; + va_start(args, format); + internal::LogText(format, args); + va_end(args); +} + +// End a game loop scenario with an outcome +void FinishScenario(::firebase::test_lab::game_loop::ScenarioOutcome outcome) { + if (GetScenario() == 0) return; + FILE* custom_results_file = internal::CreateCustomResultsFile(GetScenario()); + if (custom_results_file == nullptr) { + LogError("Could not obtain the custom results file"); + } else { + internal::OutputResult(outcome, custom_results_file); + } + Terminate(); + UIApplication* app = [UIApplication sharedApplication]; + [app openURL:[NSURL URLWithString:@(kFtlCompleteUrl)] + options:@{} + completionHandler:^(BOOL success){ + // TODO(brandonmorris): Investigate a graceful way to exit the application, or make a + // special exception within FTL to signify finishing a scenario is not a crash. + }]; +} + +// Retrieve the scenario from the URL if present +static int ParseScenarioFromUrl(NSURL* url) { + if ([url.scheme isEqualToString:(@(kFtlScheme))]) { + NSURLComponents* components = [NSURLComponents componentsWithURL:url + resolvingAgainstBaseURL:YES]; + for (NSURLQueryItem* item in [components queryItems]) { + if ([item.name isEqualToString:@(kScenario)]) { + int scenario = (int)[item.value integerValue]; + NSLog(@"%@", [NSString stringWithFormat:@"Found scenario %d", scenario]); + return scenario > 0 ? scenario : 0; + } + } + } + LogWarning("No game loop scenario could be parsed from the URL scheme"); + return 0; +} + +// Implementation of application:openURL:options that replaces the original +// app delegate's implementation. +static BOOL AppDelegateApplicationOpenURLOptions(id self, SEL selectorValue, + UIApplication* application, NSURL* url, + NSDictionary* options) { + LogDebug("Parsing URL for application:openURL:options:"); + internal::SetScenario(ParseScenarioFromUrl(url)); + + // Some applications / frameworks (like Unity) do not handle nil arguments for url and options + // so create empty objects to prevent them from failing. + if (!url) url = [[NSURL alloc] init]; + if (!options) options = @{}; + + IMP app_delegate_application_open_url_options = + SwizzledMethodCache().GetMethodForObject(self, @selector(application:openURL:options:)); + if (app_delegate_application_open_url_options) { + return ( + (util::AppDelegateApplicationOpenUrlOptionsFunc)app_delegate_application_open_url_options)( + self, selectorValue, application, url, options); + } else if ([self methodForSelector:@selector(forwardInvocation:)] != + [NSObject instanceMethodForSelector:@selector(forwardInvocation:)]) { + NSMethodSignature* signature = [[self class] instanceMethodSignatureForSelector:selectorValue]; + NSInvocation* invocation = [NSInvocation invocationWithMethodSignature:signature]; + [invocation setSelector:selectorValue]; + [invocation setTarget:self]; + [invocation setArgument:&application atIndex:2]; + [invocation setArgument:&url atIndex:3]; + [invocation setArgument:&options atIndex:4]; + [self forwardInvocation:invocation]; + // Read the return value from the invocation. + BOOL ret; + [invocation getReturnValue:&ret]; + return ret; + } + return YES; +} + +static BOOL AppDelegateApplicationOpenUrlSourceApplicationAnnotation(id self, SEL selectorValue, + UIApplication* application, + NSURL* url, + NSString* sourceApplication, + id annotation) { + internal::SetScenario(ParseScenarioFromUrl(url)); + + // Some applications / frameworks (like Unity) do not handle nil arguments for url, + // sourceApplication and annotation, so create empty objects to prevent them from failing. + if (!url) url = [[NSURL alloc] init]; + if (!sourceApplication) sourceApplication = @""; + if (!annotation) annotation = [[NSString alloc] init]; + IMP app_delegate_application_open_url_source_application_annotation = + SwizzledMethodCache().GetMethodForObject( + self, @selector(application:openURL:sourceApplication:annotation:)); + if (app_delegate_application_open_url_source_application_annotation) { + return ((util::AppDelegateApplicationOpenUrlSourceApplicationAnnotationFunc) + app_delegate_application_open_url_source_application_annotation)( + self, selectorValue, application, url, sourceApplication, annotation); + } else if ([self methodForSelector:@selector(forwardInvocation:)] != + [NSObject instanceMethodForSelector:@selector(forwardInvocation:)]) { + NSMethodSignature* signature = [[self class] instanceMethodSignatureForSelector:selectorValue]; + NSInvocation* invocation = [NSInvocation invocationWithMethodSignature:signature]; + [invocation setSelector:selectorValue]; + [invocation setTarget:self]; + [invocation setArgument:&application atIndex:2]; + [invocation setArgument:&url atIndex:3]; + [invocation setArgument:&sourceApplication atIndex:4]; + [invocation setArgument:&annotation atIndex:5]; + [self forwardInvocation:invocation]; + // Read the return value from the invocation. + BOOL ret; + [invocation getReturnValue:&ret]; + return ret; + } + return YES; +} + +// Hook all AppDelegate methods that FTL requires to intercept the game loop +// launch. +// +// The user of the library provides the class which implements AppDelegate protocol so this +// method hooks methods of user's UIApplication class in order to intercept events required for +// FTL. The alternative to this procedure would require the user to implement boilerplate code +// in their UIApplication in order to plumb in FTL. +// +// The following methods are replaced in order to intercept AppDelegate events: +// - (BOOL)application:openURL:sourceApplication:annotation: +// - (BOOL)application:openURL:options: +static void HookAppDelegateMethods(Class clazz) { + Class method_encoding_class = [FIRSAMAppDelegate class]; + auto& method_cache = SwizzledMethodCache(); + // application:openURL:options: is called in preference to + // application:openURL:sourceApplication:annotation: so if the UIApplicationDelegate does not + // implement application:openURL:options:, do not hook it. + method_cache.ReplaceOrAddMethod(clazz, @selector(application:openURL:options:), + (IMP)AppDelegateApplicationOpenURLOptions, method_encoding_class); + method_cache.ReplaceOrAddMethod( + clazz, @selector(application:openURL:sourceApplication:annotation:), + (IMP)AppDelegateApplicationOpenUrlSourceApplicationAnnotation, method_encoding_class); +} + +} // namespace game_loop +} // namespace test_lab +} // namespace firebase + +// Category for UIApplication that is used to hook methods in all classes. +// Category +load() methods are called after all class load methods in each Mach-O +// (see call_load_methods() in +// http://www.opensource.apple.com/source/objc4/objc4-274/runtime/objc-runtime.m) +@implementation UIApplication (FIRFTL) ++ (void)load { + ::firebase::LogDebug("Loading UIApplication FIRFTL category"); + ::firebase::util::ForEachAppDelegateClass(^(Class clazz) { + ::firebase::test_lab::game_loop::HookAppDelegateMethods(clazz); + }); +} +@end