diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index b2e542c7..fb313dd4 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -263,6 +263,8 @@ add_library(skyline SHARED ${source_DIR}/skyline/applet/applet_creator.cpp ${source_DIR}/skyline/applet/controller_applet.cpp ${source_DIR}/skyline/applet/player_select_applet.cpp + ${source_DIR}/skyline/applet/swkbd/software_keyboard_applet.cpp + ${source_DIR}/skyline/applet/swkbd/software_keyboard_config.cpp ${source_DIR}/skyline/services/codec/IHardwareOpusDecoder.cpp ${source_DIR}/skyline/services/codec/IHardwareOpusDecoderManager.cpp ${source_DIR}/skyline/services/hid/IHidServer.cpp diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index df363863..ba0b74e3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -65,7 +65,7 @@ CreateApplet( @@ -17,6 +18,8 @@ namespace skyline::applet { return std::make_shared(state, manager, std::move(onAppletStateChanged), std::move(onNormalDataPushFromApplet), std::move(onInteractiveDataPushFromApplet), appletMode); case AppletId::LibraryAppletPlayerSelect: return std::make_shared(state, manager, std::move(onAppletStateChanged), std::move(onNormalDataPushFromApplet), std::move(onInteractiveDataPushFromApplet), appletMode); + case AppletId::LibraryAppletSwkbd: + return std::make_shared(state, manager, std::move(onAppletStateChanged), std::move(onNormalDataPushFromApplet), std::move(onInteractiveDataPushFromApplet), appletMode); default: throw exception("Unimplemented Applet: 0x{:X} ({})", static_cast(appletId), ToString(appletId)); } diff --git a/app/src/main/cpp/skyline/applet/swkbd/software_keyboard_applet.cpp b/app/src/main/cpp/skyline/applet/swkbd/software_keyboard_applet.cpp new file mode 100644 index 00000000..1713157a --- /dev/null +++ b/app/src/main/cpp/skyline/applet/swkbd/software_keyboard_applet.cpp @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright © 2022 Skyline Team and Contributors (https://github.com/skyline-emu/) +// Copyright © 2019-2022 Ryujinx Team and Contributors + +#include +#include +#include +#include "software_keyboard_applet.h" +#include + +class Utf8Utf16Converter : public std::codecvt { + public: + ~Utf8Utf16Converter() override = default; +}; + +namespace skyline::applet::swkbd { + static void WriteStringToSpan(span chars, std::u16string_view text, bool useUtf8Storage) { + if (useUtf8Storage) { + auto u8chars{chars.cast()}; + Utf8Utf16Converter::state_type convert_state; + const char16_t *from_next; + char8_t *to_next; + Utf8Utf16Converter().out(convert_state, text.data(), text.end(), from_next, u8chars.data(), u8chars.end().base(), to_next); + // Null terminate the string, if it isn't out of bounds + if (to_next < reinterpret_cast(text.end())) + *to_next = u8'\0'; + } else { + std::memcpy(chars.data(), text.data(), std::min(text.size() * sizeof(char16_t), chars.size())); + // Null terminate the string, if it isn't out of bounds + if (text.size() * sizeof(char16_t) < chars.size()) + *(reinterpret_cast(chars.data()) + text.size()) = u'\0'; + } + } + + SoftwareKeyboardApplet::ValidationRequest::ValidationRequest(std::u16string_view text, bool useUtf8Storage) : size{sizeof(ValidationRequest)} { + WriteStringToSpan(chars, text, useUtf8Storage); + } + + SoftwareKeyboardApplet::OutputResult::OutputResult(CloseResult closeResult, std::u16string_view text, bool useUtf8Storage) : closeResult{closeResult} { + WriteStringToSpan(chars, text, useUtf8Storage); + } + + static std::u16string FillDefaultText(u32 minLength, u32 maxLength) { + std::u16string text{u"Skyline"}; + while (text.size() < minLength) + text += u"Emulator" + text; + if (text.size() > maxLength) + text.resize(maxLength); + return text; + } + + void SoftwareKeyboardApplet::SendResult() { + if (dialog) + state.jvm->CloseKeyboard(dialog); + PushNormalDataAndSignal(std::make_shared>(state, manager, OutputResult{currentResult, currentText, config.commonConfig.isUseUtf8})); + onAppletStateChanged->Signal(); + } + + SoftwareKeyboardApplet::SoftwareKeyboardApplet( + const DeviceState &state, + service::ServiceManager &manager, + std::shared_ptr onAppletStateChanged, + std::shared_ptr onNormalDataPushFromApplet, + std::shared_ptr onInteractiveDataPushFromApplet, + service::applet::LibraryAppletMode appletMode) + : IApplet{state, + manager, + std::move(onAppletStateChanged), + std::move(onNormalDataPushFromApplet), + std::move(onInteractiveDataPushFromApplet), + appletMode} { + if (appletMode != service::applet::LibraryAppletMode::AllForeground) + throw exception("Inline Software Keyboard not implemeted"); + } + + Result SoftwareKeyboardApplet::Start() { + std::scoped_lock lock{inputDataMutex}; + auto commonArgs{normalInputData.front()->GetSpan().as()}; + normalInputData.pop(); + + auto configSpan{normalInputData.front()->GetSpan()}; + normalInputData.pop(); + config = [&] { + if (commonArgs.apiVersion < 0x30007) + return KeyboardConfigVB{configSpan.as()}; + else if (commonArgs.apiVersion < 0x6000B) + return KeyboardConfigVB{configSpan.as()}; + else + return configSpan.as(); + }(); + Logger::Debug("Swkbd Config:\n* KeyboardMode: {}\n* InvalidCharFlags: {:#09b}\n* TextMaxLength: {}\n* TextMinLength: {}\n* PasswordMode: {}\n* InputFormMode: {}\n* IsUseNewLine: {}\n* IsUseTextCheck: {}", + static_cast(config.commonConfig.keyboardMode), + config.commonConfig.invalidCharFlags.raw, + config.commonConfig.textMaxLength, + config.commonConfig.textMinLength, + static_cast(config.commonConfig.passwordMode), + static_cast(config.commonConfig.inputFormMode), + config.commonConfig.isUseNewLine, + config.commonConfig.isUseTextCheck + ); + + auto maxChars{static_cast(SwkbdTextBytes / (config.commonConfig.isUseUtf8 ? sizeof(char8_t) : sizeof(char16_t)))}; + config.commonConfig.textMaxLength = std::min(config.commonConfig.textMaxLength, maxChars); + if (config.commonConfig.textMaxLength == 0) + config.commonConfig.textMaxLength = maxChars; + config.commonConfig.textMinLength = std::min(config.commonConfig.textMinLength, config.commonConfig.textMaxLength); + + if (config.commonConfig.textMaxLength > MaxOneLineChars) + config.commonConfig.inputFormMode = InputFormMode::MultiLine; + + if (!normalInputData.empty() && config.commonConfig.initialStringLength > 0) + currentText = std::u16string(normalInputData.front()->GetSpan().subspan(config.commonConfig.initialStringOffset).cast().data(), config.commonConfig.initialStringLength); + + dialog = state.jvm->ShowKeyboard(*reinterpret_cast(&config), currentText); + if (!dialog) { + Logger::Warn("Couldn't show keyboard dialog, using default text"); + currentResult = CloseResult::Enter; + currentText = FillDefaultText(config.commonConfig.textMinLength, config.commonConfig.textMaxLength); + } else { + auto result{state.jvm->WaitForSubmitOrCancel(dialog)}; + currentResult = static_cast(result.first); + currentText = result.second; + } + if (config.commonConfig.isUseTextCheck && currentResult == CloseResult::Enter) { + PushInteractiveDataAndSignal(std::make_shared>(state, manager, ValidationRequest{currentText, config.commonConfig.isUseUtf8})); + validationPending = true; + } else { + SendResult(); + } + return {}; + } + + Result SoftwareKeyboardApplet::GetResult() { + return {}; + } + + void SoftwareKeyboardApplet::PushNormalDataToApplet(std::shared_ptr data) { + std::scoped_lock lock{inputDataMutex}; + normalInputData.emplace(data); + } + + void SoftwareKeyboardApplet::PushInteractiveDataToApplet(std::shared_ptr data) { + if (validationPending) { + auto dataSpan{data->GetSpan()}; + auto validationResult{dataSpan.as()}; + if (validationResult.result == TextCheckResult::Success) { + validationPending = false; + SendResult(); + } else { + if (dialog) { + if (static_cast(state.jvm->ShowValidationResult(dialog, static_cast(validationResult.result), std::u16string(validationResult.chars.data()))) == CloseResult::Enter) { + // Accepted on confirmation dialog + validationPending = false; + SendResult(); + } else { + // Cancelled or failed validation, go back to waiting for text + auto result{state.jvm->WaitForSubmitOrCancel(dialog)}; + currentResult = static_cast(result.first); + currentText = result.second; + if (currentResult == CloseResult::Enter) { + PushInteractiveDataAndSignal(std::make_shared>(state, manager, ValidationRequest{currentText, config.commonConfig.isUseUtf8})); + } else { + SendResult(); + } + } + } else { + std::array chars{}; + WriteStringToSpan(chars, std::u16string(validationResult.chars.data()), true); + std::string message{reinterpret_cast(chars.data())}; + if (validationResult.result == TextCheckResult::ShowFailureDialog) + Logger::Warn("Sending default text despite being rejected by the guest with message: \"{}\"", message); + else + Logger::Debug("Guest asked to confirm default text with message: \"{}\"", message); + PushNormalDataAndSignal(std::make_shared>(state, manager, OutputResult{CloseResult::Enter, currentText, config.commonConfig.isUseUtf8})); + } + } + } + } +} diff --git a/app/src/main/cpp/skyline/applet/swkbd/software_keyboard_applet.h b/app/src/main/cpp/skyline/applet/swkbd/software_keyboard_applet.h new file mode 100644 index 00000000..4982200f --- /dev/null +++ b/app/src/main/cpp/skyline/applet/swkbd/software_keyboard_applet.h @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright © 2022 Skyline Team and Contributors (https://github.com/skyline-emu/) + +#pragma once + +#include +#include +#include +#include "software_keyboard_config.h" + +namespace skyline::applet::swkbd { + static_assert(sizeof(KeyboardConfigVB) == sizeof(JvmManager::KeyboardConfig)); + + /** + * @url https://switchbrew.org/wiki/Software_Keyboard + * @brief An implementation for the Software Keyboard (swkbd) Applet which handles translating guest applet transactions to the appropriate host behavior + */ + class SoftwareKeyboardApplet : public service::am::IApplet { + private: + /** + * @url https://switchbrew.org/wiki/Software_Keyboard#CloseResult + */ + enum class CloseResult : u32 { + Enter = 0x0, + Cancel = 0x1, + }; + + /** + * @url https://switchbrew.org/wiki/Software_Keyboard#TextCheckResult + */ + enum class TextCheckResult : u32 { + Success = 0x0, + ShowFailureDialog = 0x1, + ShowConfirmDialog = 0x2, + }; + + static constexpr u32 SwkbdTextBytes{0x7D4}; //!< Size of the returned IStorage buffer that's used to return the input text + + static constexpr u32 MaxOneLineChars{32}; //!< The maximum number of characters for which anything other than InputFormMode::MultiLine is used + + #pragma pack(push, 1) + + /** + * @brief The final result after the swkbd has closed + */ + struct OutputResult { + CloseResult closeResult; + std::array chars{}; + + OutputResult(CloseResult closeResult, std::u16string_view text, bool useUtf8Storage); + }; + static_assert(sizeof(OutputResult) == 0x7D8); + + /** + * @brief A request for validating a string inside guest code, this is pushed via the interactive queue + */ + struct ValidationRequest { + u64 size; + std::array chars{}; + + ValidationRequest(std::u16string_view text, bool useUtf8Storage); + }; + static_assert(sizeof(ValidationRequest) == 0x7DC); + + /** + * @brief The result of validating text submitted to the guest + */ + struct ValidationResult { + TextCheckResult result; + std::array chars; + }; + static_assert(sizeof(ValidationResult) == 0x7D8); + + #pragma pack(pop) + + std::mutex inputDataMutex; + std::queue> normalInputData; + KeyboardConfigVB config{}; + bool validationPending{}; + std::u16string currentText{}; + CloseResult currentResult{}; + + jobject dialog{}; + + void SendResult(); + + public: + SoftwareKeyboardApplet(const DeviceState &state, service::ServiceManager &manager, std::shared_ptr onAppletStateChanged, std::shared_ptr onNormalDataPushFromApplet, std::shared_ptr onInteractiveDataPushFromApplet, service::applet::LibraryAppletMode appletMode); + + Result Start() override; + + Result GetResult() override; + + void PushNormalDataToApplet(std::shared_ptr data) override; + + void PushInteractiveDataToApplet(std::shared_ptr data) override; + }; +} diff --git a/app/src/main/cpp/skyline/applet/swkbd/software_keyboard_config.cpp b/app/src/main/cpp/skyline/applet/swkbd/software_keyboard_config.cpp new file mode 100644 index 00000000..6b035866 --- /dev/null +++ b/app/src/main/cpp/skyline/applet/swkbd/software_keyboard_config.cpp @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright © 2022 Skyline Team and Contributors (https://github.com/skyline-emu/) +// Copyright © 2019-2022 Ryujinx Team and Contributors + +#include "software_keyboard_config.h" + +namespace skyline::applet::swkbd { + KeyboardConfigVB::KeyboardConfigVB() = default; + + KeyboardConfigVB::KeyboardConfigVB(const KeyboardConfigV7 &v7config) : commonConfig(v7config.commonConfig), separateTextPos(v7config.separateTextPos) {} + + KeyboardConfigVB::KeyboardConfigVB(const KeyboardConfigV0 &v0config) : commonConfig(v0config.commonConfig) {} +} diff --git a/app/src/main/cpp/skyline/applet/swkbd/software_keyboard_config.h b/app/src/main/cpp/skyline/applet/swkbd/software_keyboard_config.h new file mode 100644 index 00000000..b53987a6 --- /dev/null +++ b/app/src/main/cpp/skyline/applet/swkbd/software_keyboard_config.h @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright © 2022 Skyline Team and Contributors (https://github.com/skyline-emu/) +// Copyright © 2019-2022 Ryujinx Team and Contributors + +#pragma once + +#include + +namespace skyline::applet::swkbd { + /** + * @brief Specifies the characters the keyboard should allow you to input + * @url https://switchbrew.org/wiki/Software_Keyboard#KeyboardMode + */ + enum class KeyboardMode : u32 { + Full = 0x0, + Numeric = 0x1, + ASCII = 0x2, + FullLatin = 0x3, + Alphabet = 0x4, + SimplifiedChinese = 0x5, + TraditionalChinese = 0x6, + Korean = 0x7, + LanguageSet2 = 0x8, + LanguageSet2Latin = 0x9, + }; + + /** + * @brief Specifies the characters that you shouldn't be allowed to input + * @url https://switchbrew.org/wiki/Software_Keyboard#InvalidCharFlags + */ + union InvalidCharFlags { + u32 raw; + struct { + u32 _pad_ : 1; + u32 space : 1; + u32 atMark : 1; + u32 percent : 1; + u32 slash : 1; + u32 backslash : 1; + u32 numeric : 1; + u32 outsideOfDownloadCode : 1; + u32 outsideOfMiiNickName : 1; + } flags; + }; + + /** + * @brief Specifies where the cursor should initially be on the initial string + * @url https://switchbrew.org/wiki/Software_Keyboard#InitialCursorPos + */ + enum class InitialCursorPos : u32 { + First = 0x0, + Last = 0x1, + }; + + /** + * @url https://switchbrew.org/wiki/Software_Keyboard#PasswordMode + */ + enum class PasswordMode : u32 { + Show = 0x0, + Hide = 0x1, //!< Hides any inputted text to prevent a password from being leaked + }; + + /** + * @url https://switchbrew.org/wiki/Software_Keyboard#InputFormMode + * @note Only applies when 1 <= textMaxLength <= 32, otherwise Multiline is used + */ + enum class InputFormMode : u32 { + OneLine = 0x0, + MultiLine = 0x1, + Separate = 0x2, //!< Used with separateTextPos + }; + + /** + * @brief Specifies the language of custom dictionary entries + * @url https://switchbrew.org/wiki/Software_Keyboard#DictionaryLanguage + */ + enum class DictionaryLanguage : u16 { + Japanese = 0x00, + AmericanEnglish = 0x01, + CanadianFrench = 0x02, + LatinAmericanSpanish = 0x03, + Reserved1 = 0x04, + BritishEnglish = 0x05, + French = 0x06, + German = 0x07, + Spanish = 0x08, + Italian = 0x09, + Dutch = 0x0A, + Portuguese = 0x0B, + Russian = 0x0C, + Reserved2 = 0x0D, + SimplifiedChinesePinyin = 0x0E, + TraditionalChineseCangjie = 0x0F, + TraditionalChineseSimplifiedCangjie = 0x10, + TraditionalChineseZhuyin = 0x11, + Korean = 0x12, + }; + + /** + * @brief A descriptor of a custom dictionary entry + * @url https://switchbrew.org/wiki/Software_Keyboard#DictionaryInfo + */ + struct DictionaryInfo { + u32 offset; + u16 size; + DictionaryLanguage language; + }; + + /** + * @brief The keyboard config that's common across all versions + * @url https://switchbrew.org/wiki/Software_Keyboard#KeyboardConfig + */ + struct CommonKeyboardConfig { + KeyboardMode keyboardMode; + std::array okText; + char16_t leftOptionalSymbolKey; + char16_t rightOptionalSymbolKey; + bool isPredictionEnabled; + u8 _pad0_; + InvalidCharFlags invalidCharFlags; + InitialCursorPos initialCursorPos; + std::array headerText; + std::array subText; + std::array guideText; + u8 _pad2_[0x2]; + u32 textMaxLength; + u32 textMinLength; + PasswordMode passwordMode; + InputFormMode inputFormMode; + bool isUseNewLine; + bool isUseUtf8; + bool isUseBlurBackground; + u8 _pad3_; + u32 initialStringOffset; + u32 initialStringLength; + u32 userDictionaryOffset; + u32 userDictionaryNum; + bool isUseTextCheck; + u8 reserved[0x3]; + }; + static_assert(sizeof(CommonKeyboardConfig) == 0x3D4); + + /** + * @brief The keyboard config for the first api version + * @url https://switchbrew.org/wiki/Software_Keyboard#KeyboardConfig + */ + struct KeyboardConfigV0 { + CommonKeyboardConfig commonConfig; + u8 _pad0_[0x4]; + u64 textCheckCallback{}; + }; + static_assert(sizeof(KeyboardConfigV0) == 0x3E0); + + /** + * @brief The keyboard config as of api version 0x30007 + * @url https://switchbrew.org/wiki/Software_Keyboard#KeyboardConfig + */ + struct KeyboardConfigV7 { + CommonKeyboardConfig commonConfig; + u8 _pad0_[0x4]; + u64 textCheckCallback; + std::array separateTextPos; + }; + static_assert(sizeof(KeyboardConfigV7) == 0x400); + + /** + * @brief The keyboard config as of api version 0x6000B + * @url https://switchbrew.org/wiki/Software_Keyboard#KeyboardConfig + */ + struct KeyboardConfigVB { + CommonKeyboardConfig commonConfig{}; + std::array separateTextPos{0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF}; + std::array customisedDictionaryInfoList{}; + u8 customisedDictionaryCount{}; + bool isCancelButtonDisabled{}; + u8 reserved0[0xD]; + u8 trigger{}; + u8 reserved1[0x4]; + + KeyboardConfigVB(); + + KeyboardConfigVB(const KeyboardConfigV7 &v7config); + + KeyboardConfigVB(const KeyboardConfigV0 &v0config); + }; + static_assert(sizeof(KeyboardConfigVB) == 0x4C8); +} diff --git a/app/src/main/cpp/skyline/jvm.cpp b/app/src/main/cpp/skyline/jvm.cpp index c6d31ece..5af9c305 100644 --- a/app/src/main/cpp/skyline/jvm.cpp +++ b/app/src/main/cpp/skyline/jvm.cpp @@ -54,12 +54,17 @@ namespace skyline { thread_local inline JniEnvironment env; JvmManager::JvmManager(JNIEnv *environ, jobject instance) - : instance(environ->NewGlobalRef(instance)), - instanceClass(reinterpret_cast(environ->NewGlobalRef(environ->GetObjectClass(instance)))), - initializeControllersId(environ->GetMethodID(instanceClass, "initializeControllers", "()V")), - vibrateDeviceId(environ->GetMethodID(instanceClass, "vibrateDevice", "(I[J[I)V")), - clearVibrationDeviceId(environ->GetMethodID(instanceClass, "clearVibrationDevice", "(I)V")), - getVersionCodeId(environ->GetMethodID(instanceClass, "getVersionCode", "()I")) { + : instance{environ->NewGlobalRef(instance)}, + instanceClass{reinterpret_cast(environ->NewGlobalRef(environ->GetObjectClass(instance)))}, + initializeControllersId{environ->GetMethodID(instanceClass, "initializeControllers", "()V")}, + vibrateDeviceId{environ->GetMethodID(instanceClass, "vibrateDevice", "(I[J[I)V")}, + clearVibrationDeviceId{environ->GetMethodID(instanceClass, "clearVibrationDevice", "(I)V")}, + showKeyboardId{environ->GetMethodID(instanceClass, "showKeyboard", "(Ljava/nio/ByteBuffer;Ljava/lang/String;)Lemu/skyline/applet/swkbd/SoftwareKeyboardDialog;")}, + waitForSubmitOrCancelId{environ->GetMethodID(instanceClass, "waitForSubmitOrCancel", "(Lemu/skyline/applet/swkbd/SoftwareKeyboardDialog;)[Ljava/lang/Object;")}, + closeKeyboardId{environ->GetMethodID(instanceClass, "closeKeyboard", "(Lemu/skyline/applet/swkbd/SoftwareKeyboardDialog;)V")}, + showValidationResultId{environ->GetMethodID(instanceClass, "showValidationResult", "(Lemu/skyline/applet/swkbd/SoftwareKeyboardDialog;ILjava/lang/String;)I")}, + getVersionCodeId{environ->GetMethodID(instanceClass, "getVersionCode", "()I")}, + getIntegerValueId{environ->GetMethodID(environ->FindClass("java/lang/Integer"), "intValue", "()I")} { env.Initialize(environ); } @@ -104,7 +109,42 @@ namespace skyline { env->CallVoidMethod(instance, clearVibrationDeviceId, index); } + jobject JvmManager::ShowKeyboard(KeyboardConfig &config, std::u16string initialText) { + auto buffer{env->NewDirectByteBuffer(&config, sizeof(KeyboardConfig))}; + auto str{env->NewString(reinterpret_cast(initialText.data()), static_cast(initialText.length()))}; + jobject localKeyboardDialog{env->CallObjectMethod(instance, showKeyboardId, buffer, str)}; + env->DeleteLocalRef(buffer); + env->DeleteLocalRef(str); + auto keyboardDialog{env->NewGlobalRef(localKeyboardDialog)}; + + env->DeleteLocalRef(localKeyboardDialog); + return keyboardDialog; + } + + std::pair JvmManager::WaitForSubmitOrCancel(jobject keyboardDialog) { + auto returnArray{reinterpret_cast(env->CallObjectMethod(instance, waitForSubmitOrCancelId, keyboardDialog))}; + auto buttonInteger{env->GetObjectArrayElement(returnArray, 0)}; + auto inputJString{reinterpret_cast(env->GetObjectArrayElement(returnArray, 1))}; + auto stringChars{env->GetStringChars(inputJString, nullptr)}; + std::u16string input{stringChars, stringChars + env->GetStringLength(inputJString)}; + env->ReleaseStringChars(inputJString, stringChars); + + return {static_cast(env->CallIntMethod(buttonInteger, getIntegerValueId)), input}; + } + + void JvmManager::CloseKeyboard(jobject dialog) { + env->CallVoidMethod(instance, closeKeyboardId, dialog); + env->DeleteGlobalRef(dialog); + } + i32 JvmManager::GetVersionCode() { return env->CallIntMethod(instance, getVersionCodeId); } + + JvmManager::KeyboardCloseResult JvmManager::ShowValidationResult(jobject dialog, KeyboardTextCheckResult checkResult, std::u16string message) { + auto str{env->NewString(reinterpret_cast(message.data()), static_cast(message.length()))}; + auto result{static_cast(env->CallIntMethod(instance, showValidationResultId, dialog, checkResult, str))}; + env->DeleteLocalRef(str); + return result; + } } diff --git a/app/src/main/cpp/skyline/jvm.h b/app/src/main/cpp/skyline/jvm.h index d9d8ff94..0f370882 100644 --- a/app/src/main/cpp/skyline/jvm.h +++ b/app/src/main/cpp/skyline/jvm.h @@ -23,6 +23,12 @@ namespace skyline { */ class JvmManager { public: + using KeyboardHandle = jobject; + using KeyboardConfig = std::array; + using KeyboardCloseResult = u32; + using KeyboardTextCheckResult = u32; + + jobject instance; //!< A reference to the activity jclass instanceClass; //!< The class of the activity @@ -106,6 +112,26 @@ namespace skyline { */ void ClearVibrationDevice(jint index); + /** + * @brief A call to EmulationActivity.showKeyboard in Kotlin + */ + KeyboardHandle ShowKeyboard(KeyboardConfig &config, std::u16string initialText); + + /** + * @brief A call to EmulationActivity.waitForSubmitOrCancel in Kotlin + */ + std::pair WaitForSubmitOrCancel(KeyboardHandle dialog); + + /** + * @brief A call to EmulationActivity.closeKeyboard in Kotlin + */ + void CloseKeyboard(KeyboardHandle dialog); + + /** + * @brief A call to EmulationActivity.showValidationResult in Kotlin + */ + KeyboardCloseResult ShowValidationResult(KeyboardHandle dialog, KeyboardTextCheckResult checkResult, std::u16string message); + /** * @brief A call to EmulationActivity.getVersionCode in Kotlin * @return A version code in Vulkan's format with 14-bit patch + 10-bit major and minor components @@ -116,6 +142,12 @@ namespace skyline { jmethodID initializeControllersId; jmethodID vibrateDeviceId; jmethodID clearVibrationDeviceId; + jmethodID showKeyboardId; + jmethodID waitForSubmitOrCancelId; + jmethodID closeKeyboardId; + jmethodID showValidationResultId; jmethodID getVersionCodeId; + + jmethodID getIntegerValueId; }; } diff --git a/app/src/main/java/emu/skyline/AppDialog.kt b/app/src/main/java/emu/skyline/AppDialog.kt index 46cda3dd..c054f057 100644 --- a/app/src/main/java/emu/skyline/AppDialog.kt +++ b/app/src/main/java/emu/skyline/AppDialog.kt @@ -43,7 +43,7 @@ class AppDialog : BottomSheetDialogFragment() { private lateinit var binding : AppDialogBinding - private val item by lazy { requireArguments().getSerializable("item") as AppItem } + private val item by lazy { requireArguments().getSerializable("item")!! as AppItem } /** * This inflates the layout of the dialog after initial view creation diff --git a/app/src/main/java/emu/skyline/EmulationActivity.kt b/app/src/main/java/emu/skyline/EmulationActivity.kt index 6bdd87c9..6ef84c49 100644 --- a/app/src/main/java/emu/skyline/EmulationActivity.kt +++ b/app/src/main/java/emu/skyline/EmulationActivity.kt @@ -20,12 +20,20 @@ import androidx.core.content.getSystemService import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.updateMargins +import androidx.fragment.app.FragmentTransaction +import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint +import emu.skyline.applet.swkbd.SoftwareKeyboardConfig +import emu.skyline.applet.swkbd.SoftwareKeyboardDialog import emu.skyline.databinding.EmuActivityBinding import emu.skyline.input.* import emu.skyline.loader.getRomFormat +import emu.skyline.utils.ByteBufferSerializable import emu.skyline.utils.Settings import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.FutureTask import javax.inject.Inject import kotlin.math.abs @@ -365,7 +373,7 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback, View.OnTo // Stop forcing 60Hz on exit to allow the skyline UI to run at high refresh rates getSystemService()?.unregisterDisplayListener(this) - force60HzRefreshRate(false); + force60HzRefreshRate(false) } override fun surfaceCreated(holder : SurfaceHolder) { @@ -574,6 +582,50 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback, View.OnTo vibrators[index]?.cancel() } + @Suppress("unused") + fun showKeyboard(buffer : ByteBuffer, initialText : String) : SoftwareKeyboardDialog? { + buffer.order(ByteOrder.LITTLE_ENDIAN) + val config = ByteBufferSerializable.createFromByteBuffer(SoftwareKeyboardConfig::class, buffer) as SoftwareKeyboardConfig + + val keyboardDialog = SoftwareKeyboardDialog.newInstance(config, initialText) + runOnUiThread { + val transaction = supportFragmentManager.beginTransaction() + transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + transaction + .add(android.R.id.content, keyboardDialog) + .addToBackStack(null) + .commit() + } + return keyboardDialog + } + + @Suppress("unused") + fun waitForSubmitOrCancel(dialog : SoftwareKeyboardDialog) : Array { + return dialog.waitForSubmitOrCancel().let { arrayOf(if (it.cancelled) 1 else 0, it.text) } + } + + @Suppress("unused") + fun closeKeyboard(dialog : SoftwareKeyboardDialog) { + runOnUiThread { dialog.dismiss() } + } + + @Suppress("unused") + fun showValidationResult(dialog : SoftwareKeyboardDialog, validationResult : Int, message : String) : Int { + val confirm = validationResult == SoftwareKeyboardDialog.validationConfirm + var accepted = false + val validatorResult = FutureTask { return@FutureTask accepted } + runOnUiThread { + val builder = MaterialAlertDialogBuilder(dialog.requireContext()) + builder.setMessage(message) + builder.setPositiveButton(if (confirm) getString(android.R.string.ok) else getString(android.R.string.cancel)) { _, _ -> accepted = confirm } + if (confirm) + builder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> } + builder.setOnDismissListener { validatorResult.run() } + builder.show() + } + return if (validatorResult.get()) 0 else 1 + } + /** * @return A version code in Vulkan's format with 14-bit patch + 10-bit major and minor components */ diff --git a/app/src/main/java/emu/skyline/applet/swkbd/SoftwareKeyboardConfig.kt b/app/src/main/java/emu/skyline/applet/swkbd/SoftwareKeyboardConfig.kt new file mode 100644 index 00000000..efe135dc --- /dev/null +++ b/app/src/main/java/emu/skyline/applet/swkbd/SoftwareKeyboardConfig.kt @@ -0,0 +1,231 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright © 2022 Skyline Team and Contributors (https://github.com/skyline-emu/) + */ + +@file:OptIn(ExperimentalUnsignedTypes::class) + +package emu.skyline.applet.swkbd + +import emu.skyline.utils.* + +fun getCodepointArray(vararg chars : Char) : IntArray { + val codepointArray = IntArray(chars.size) + for (i in chars.indices) + codepointArray[i] = chars[i].code + return codepointArray +} + +val DownloadCodeCodepoints = getCodepointArray('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X') +val OutsideOfMiiNicknameCodepoints = getCodepointArray('@', '%', '\\', 'ō', 'Ō', '₽', '₩', '♥', '♡') + +/** + * This data class matches KeyboardMode in skyline/applet/swkbd/software_keyboard_config.h + */ +data class KeyboardMode( + var mode : u32 = 0u +) : ByteBufferSerializable { + companion object { + val Full = KeyboardMode(0u) + val Numeric = KeyboardMode(1u) + val ASCII = KeyboardMode(2u) + val FullLatin = KeyboardMode(3u) + val Alphabet = KeyboardMode(4u) + val SimplifiedChinese = KeyboardMode(5u) + val TraditionalChinese = KeyboardMode(6u) + val Korean = KeyboardMode(7u) + val LanguageSet2 = KeyboardMode(8u) + val LanguageSet2Latin = KeyboardMode(9u) + } +} + +/** + * This data class matches InvalidCharFlags in skyline/applet/swkbd/software_keyboard_config.h + */ +data class InvalidCharFlags( + var flags : u32 = 0u +) : ByteBufferSerializable { + // Access each flag as a bool + var space : Boolean + get() : Boolean = (flags and 0b1u shl 1) != 0u + set(value) { + flags = flags and (0b1u shl 1).inv() or (if (value) 0b1u shl 1 else 0u) + } + var atMark : Boolean + get() : Boolean = (flags and 0b1u shl 2) != 0u + set(value) { + flags = flags and (0b1u shl 2).inv() or (if (value) 0b1u shl 2 else 0u) + } + var percent : Boolean + get() : Boolean = (flags and 0b1u shl 3) != 0u + set(value) { + flags = flags and (0b1u shl 3).inv() or (if (value) 0b1u shl 3 else 0u) + } + var slash : Boolean + get() : Boolean = (flags and 0b1u shl 4) != 0u + set(value) { + flags = flags and (0b1u shl 4).inv() or (if (value) 0b1u shl 4 else 0u) + } + var backSlash : Boolean + get() : Boolean = (flags and 0b1u shl 5) != 0u + set(value) { + flags = flags and (0b1u shl 5).inv() or (if (value) 0b1u shl 5 else 0u) + } + var numeric : Boolean + get() : Boolean = (flags and 0b1u shl 6) != 0u + set(value) { + flags = flags and (0b1u shl 6).inv() or (if (value) 0b1u shl 6 else 0u) + } + var outsideOfDownloadCode : Boolean + get() : Boolean = (flags and 0b1u shl 7) != 0u + set(value) { + flags = flags and (0b1u shl 7).inv() or (if (value) 0b1u shl 7 else 0u) + } + var outsideOfMiiNickName : Boolean + get() : Boolean = (flags and 0b1u shl 8) != 0u + set(value) { + flags = flags and (0b1u shl 8).inv() or (if (value) 0b1u shl 8 else 0u) + } +} + +/** + * This data class matches InitialCursorPos in skyline/applet/swkbd/software_keyboard_config.h + */ +data class InitialCursorPos( + var pos : u32 = 0u +) : ByteBufferSerializable { + companion object { + val First = InitialCursorPos(0u) + val Last = InitialCursorPos(1u) + } +} + +/** + * This data class matches PasswordMode in skyline/applet/swkbd/software_keyboard_config.h + */ +data class PasswordMode( + var mode : u32 = 0u +) : ByteBufferSerializable { + companion object { + val Show = PasswordMode(0u) + val Hide = PasswordMode(1u) + } +} + +/** + * This data class matches InputFormMode in skyline/applet/swkbd/software_keyboard_config.h + */ +data class InputFormMode( + var mode : u32 = 0u +) : ByteBufferSerializable { + companion object { + val OneLine = InputFormMode(0u) + val MultiLine = InputFormMode(1u) + val Separate = InputFormMode(2u) + } +} + +/** + * This data class matches DictionaryLanguage in skyline/applet/swkbd/software_keyboard_config.h + */ +data class DictionaryLanguage( + var lang : u16 = 0u +) : ByteBufferSerializable + +/** + * This data class matches DictionaryInfo in skyline/applet/swkbd/software_keyboard_config.h + */ +data class DictionaryInfo( + var offset : u32 = 0u, + var size : u16 = 0u, + var dictionaryLang : DictionaryLanguage = DictionaryLanguage() +) : ByteBufferSerializable + +/** + * This data class matches KeyboardConfigVB in skyline/applet/swkbd/software_keyboard_config.h + */ +data class SoftwareKeyboardConfig( + var keyboardMode : KeyboardMode = KeyboardMode(), + @param:ByteBufferSerializable.ByteBufferSerializableArray(0x9) val okText : CharArray = CharArray(0x9), + var leftOptionalSymbolKey : Char = '\u0000', + var rightOptionalSymbolKey : Char = '\u0000', + var isPredictionEnabled : bool = false, + @param:ByteBufferSerializable.ByteBufferSerializablePadding(0x1) val _pad0_ : ByteBufferSerializable.ByteBufferPadding = ByteBufferSerializable.ByteBufferPadding, + var invalidCharsFlags : InvalidCharFlags = InvalidCharFlags(), + var initialCursorPos : InitialCursorPos = InitialCursorPos(), + @param:ByteBufferSerializable.ByteBufferSerializableArray(0x41) val headerText : CharArray = CharArray(0x41), + @param:ByteBufferSerializable.ByteBufferSerializableArray(0x81) val subText : CharArray = CharArray(0x81), + @param:ByteBufferSerializable.ByteBufferSerializableArray(0x101) val guideText : CharArray = CharArray(0x101), + @param:ByteBufferSerializable.ByteBufferSerializablePadding(0x2) val _pad2_ : ByteBufferSerializable.ByteBufferPadding = ByteBufferSerializable.ByteBufferPadding, + var textMaxLength : u32 = 0u, + var textMinLength : u32 = 0u, + var passwordMode : PasswordMode = PasswordMode(), + var inputFormMode : InputFormMode = InputFormMode(), + var isUseNewLine : bool = false, + var isUseUtf8 : bool = false, + var isUseBlurBackground : bool = false, + @param:ByteBufferSerializable.ByteBufferSerializablePadding(0x1) val _pad3_ : ByteBufferSerializable.ByteBufferPadding = ByteBufferSerializable.ByteBufferPadding, + var initialStringOffset : u32 = 0u, + var initialStringLength : u32 = 0u, + var userDictionaryOffset : u32 = 0u, + var userDictionaryNum : u32 = 0u, + var isUseTextCheck : bool = false, + @param:ByteBufferSerializable.ByteBufferSerializablePadding(0x3) val reserved0 : ByteBufferSerializable.ByteBufferPadding = ByteBufferSerializable.ByteBufferPadding, + @param:ByteBufferSerializable.ByteBufferSerializableArray(0x8) val separateTextPos : u32Array = u32Array(0x8), + @param:ByteBufferSerializable.ByteBufferSerializableArray(0x18) val customizedDicInfoList : Array = Array(0x18) { DictionaryInfo() }, + var customizedDicCount : u8 = 0u, + var isCancelButtonDisabled : bool = false, + @param:ByteBufferSerializable.ByteBufferSerializablePadding(0xD) val reserved1 : ByteBufferSerializable.ByteBufferPadding = ByteBufferSerializable.ByteBufferPadding, + var trigger : u8 = 0u, + @param:ByteBufferSerializable.ByteBufferSerializablePadding(0x4) val reserved2 : ByteBufferSerializable.ByteBufferPadding = ByteBufferSerializable.ByteBufferPadding, +) : ByteBufferSerializable { + fun isValid(codepoint : Int) : Boolean { + when (keyboardMode) { + KeyboardMode.Numeric -> { + if (!(codepoint in '0'.code..'9'.code || codepoint == leftOptionalSymbolKey.code || codepoint == rightOptionalSymbolKey.code)) + return false + } + KeyboardMode.ASCII -> { + if (codepoint !in 0x00..0x7F) + return false + } + } + if (invalidCharsFlags.space && Character.isSpaceChar(codepoint)) + return false + if (invalidCharsFlags.atMark && codepoint == '@'.code) + return false + if (invalidCharsFlags.slash && codepoint == '/'.code) + return false + if (invalidCharsFlags.backSlash && codepoint == '\\'.code) + return false + if (invalidCharsFlags.numeric && codepoint in '0'.code..'9'.code) + return false + if (invalidCharsFlags.outsideOfDownloadCode && codepoint !in DownloadCodeCodepoints) + return false + if (invalidCharsFlags.outsideOfMiiNickName && codepoint in OutsideOfMiiNicknameCodepoints) + return false + if (!isUseNewLine && codepoint == '\n'.code) + return false + return true + } + + fun isValid(string : String) : Boolean { + if (string.length !in textMinLength.toInt()..textMaxLength.toInt()) + return false + for (codepoint in string.codePoints()) { + if (!isValid(codepoint)) + return false + } + return true + } + + fun isValid(chars : CharSequence) : Boolean { + if (chars.length !in textMinLength.toInt()..textMaxLength.toInt()) + return false + for (codepoint in chars.codePoints()) { + if (!isValid(codepoint)) + return false + } + return true + } +} diff --git a/app/src/main/java/emu/skyline/applet/swkbd/SoftwareKeyboardDialog.kt b/app/src/main/java/emu/skyline/applet/swkbd/SoftwareKeyboardDialog.kt new file mode 100644 index 00000000..a00c4d35 --- /dev/null +++ b/app/src/main/java/emu/skyline/applet/swkbd/SoftwareKeyboardDialog.kt @@ -0,0 +1,125 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright © 2022 Skyline Team and Contributors (https://github.com/skyline-emu/) + */ + +package emu.skyline.applet.swkbd + +import android.annotation.SuppressLint +import android.os.Bundle +import android.text.InputType +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.DialogFragment +import emu.skyline.databinding.KeyboardDialogBinding +import emu.skyline.utils.stringFromChars +import java.util.concurrent.FutureTask + +data class SoftwareKeyboardResult(val cancelled : Boolean, val text : String) + +class SoftwareKeyboardDialog : DialogFragment() { + private val config by lazy { requireArguments().getParcelable("config")!! } + private val initialText by lazy { requireArguments().getString("initialText")!! } + private var stopped = false + + companion object { + /** + * @param config Holds the [SoftwareKeyboardConfig] that will be used to create the keyboard between instances of this dialog + * @param initialText Holds the text that was set by the guest when the dialog was created + */ + fun newInstance(config : SoftwareKeyboardConfig, initialText : String) : SoftwareKeyboardDialog { + val args = Bundle() + args.putParcelable("config", config) + args.putString("initialText", initialText) + val fragment = SoftwareKeyboardDialog() + fragment.arguments = args + return fragment + } + + const val validationConfirm = 2 + const val validationError = 1 + } + + private lateinit var binding : KeyboardDialogBinding + + private var cancelled : Boolean = false + private var futureResult : FutureTask = FutureTask { return@FutureTask SoftwareKeyboardResult(cancelled, binding.textInput.text.toString()) } + + + override fun onCreateView(inflater : LayoutInflater, container : ViewGroup?, savedInstanceState : Bundle?) = if (savedInstanceState?.getBoolean("stopped") != true) KeyboardDialogBinding.inflate(inflater).also { binding = it }.root else null + + @SuppressLint("SetTextI18n") + override fun onViewCreated(view : View, savedInstanceState : Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (config.inputFormMode == InputFormMode.OneLine) { + binding.header.text = stringFromChars(config.headerText) + binding.header.visibility = View.VISIBLE + binding.sub.text = stringFromChars(config.subText) + binding.sub.visibility = View.VISIBLE + } + + if (config.keyboardMode == KeyboardMode.Numeric) { + binding.textInput.inputType = if (config.passwordMode == PasswordMode.Hide) InputType.TYPE_NUMBER_VARIATION_PASSWORD else InputType.TYPE_CLASS_NUMBER + } else { + binding.textInput.inputType = if (config.passwordMode == PasswordMode.Hide) InputType.TYPE_TEXT_VARIATION_PASSWORD else InputType.TYPE_CLASS_TEXT + if (config.invalidCharsFlags.outsideOfDownloadCode) + binding.textInput.inputType = binding.textInput.inputType or InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS + if (config.isUseNewLine) + binding.textInput.inputType = binding.textInput.inputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE + else + binding.textInput.inputType = binding.textInput.inputType and InputType.TYPE_TEXT_FLAG_MULTI_LINE.inv() + } + + val okText = stringFromChars(config.okText) + if (okText.isNotBlank()) + binding.okButton.text = okText + val guideText = stringFromChars(config.guideText) + if (guideText.isNotBlank()) + binding.inputLayout.hint = guideText + + binding.textInput.setText(initialText) + binding.textInput.setSelection(if (config.initialCursorPos == InitialCursorPos.First) 0 else initialText.length) + binding.textInput.filters = arrayOf(SoftwareKeyboardFilter(config)) + binding.textInput.doOnTextChanged { text, _, _, _ -> + binding.okButton.isEnabled = config.isValid(text!!) + binding.lengthStatus.text = "${text.length}/${config.textMaxLength}" + } + binding.lengthStatus.text = "${initialText.length}/${config.textMaxLength}" + binding.okButton.isEnabled = config.isValid(initialText) + binding.okButton.setOnClickListener { + cancelled = false + futureResult.run() + } + if (config.isCancelButtonDisabled) { + binding.cancelButton.visibility = ViewGroup.GONE + } else { + binding.cancelButton.setOnClickListener { + cancelled = true + futureResult.run() + } + } + } + + override fun onStart() { + stopped = false + super.onStart() + } + + override fun onStop() { + stopped = true + super.onStop() + } + + override fun onSaveInstanceState(outState : Bundle) { + outState.putBoolean("stopped", stopped) + super.onSaveInstanceState(outState) + } + + fun waitForSubmitOrCancel() : SoftwareKeyboardResult { + val result = futureResult.get() + futureResult = FutureTask { return@FutureTask SoftwareKeyboardResult(cancelled, binding.textInput.text.toString()) } + return result + } +} diff --git a/app/src/main/java/emu/skyline/applet/swkbd/SoftwareKeyboardFilter.kt b/app/src/main/java/emu/skyline/applet/swkbd/SoftwareKeyboardFilter.kt new file mode 100644 index 00000000..021f5805 --- /dev/null +++ b/app/src/main/java/emu/skyline/applet/swkbd/SoftwareKeyboardFilter.kt @@ -0,0 +1,66 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright © 2022 Skyline Team and Contributors (https://github.com/skyline-emu/) + */ + +package emu.skyline.applet.swkbd + +import android.text.InputFilter +import android.text.SpannableStringBuilder +import android.text.Spanned + +class SoftwareKeyboardFilter(val config : SoftwareKeyboardConfig) : InputFilter { + override fun filter(source : CharSequence, start : Int, end : Int, dest : Spanned, dstart : Int, dend : Int) : CharSequence? { + val filteredStringBuilder = SpannableStringBuilder() + val newCharacterIndex = arrayOfNulls(end - start) + var currentIndex : Int + + val sourceCodepoints = source.subSequence(start, end).codePoints().iterator() + val replacedLength = dest.subSequence(dstart, dend).length + + var nextIndex = start + while (sourceCodepoints.hasNext()) { + var codepoint = sourceCodepoints.next() + currentIndex = nextIndex + nextIndex += Character.charCount(codepoint) + + if (config.invalidCharsFlags.outsideOfDownloadCode) + codepoint = Character.toUpperCase(codepoint) + if (!config.isValid(codepoint)) + continue + // Check if the string would be over the maximum length after adding this character + if (dest.length - replacedLength + filteredStringBuilder.length + Character.charCount(codepoint) > config.textMaxLength.toInt()) + break + else + // Keep track where in the new string each character ended up + newCharacterIndex[currentIndex - start] = filteredStringBuilder.length + filteredStringBuilder.append(String(Character.toChars(codepoint))) + } + + if (source is Spanned) { + // Copy the spans from the source to the returned SpannableStringBuilder + for (span in source.getSpans(start, end, Any::class.java)) { + val spanStart = source.getSpanStart(span) - start + val spanEnd = source.getSpanEnd(span) - start + + currentIndex = spanStart + var newStart = newCharacterIndex[currentIndex] + // Make the new span's start be at the first character that was in the span's range and wasn't removed + while (newStart == null && currentIndex < spanEnd - 1) { + newStart = newCharacterIndex[++currentIndex] + } + + currentIndex = spanEnd - 1 + var newEnd = newCharacterIndex[currentIndex] + // Make the span end be at the last character that was in the span's range and wasn't removed + while (newEnd == null && currentIndex > spanStart) { + newEnd = newCharacterIndex[--currentIndex] + } + + if(newStart != null && newEnd != null) + filteredStringBuilder.setSpan(span, newStart, newEnd + 1, source.getSpanFlags(span)) + } + } + return filteredStringBuilder + } +} diff --git a/app/src/main/res/layout/keyboard_dialog.xml b/app/src/main/res/layout/keyboard_dialog.xml new file mode 100644 index 00000000..fe9fc145 --- /dev/null +++ b/app/src/main/res/layout/keyboard_dialog.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 10de02ce..e2294514 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -166,6 +166,8 @@ Open Sans is used as our FOSS shared font replacement for Latin, Korean and Chinese Roboto is used as our FOSS shared font replacement for Nintendo\'s extended character set Source Sans Pro is used as our FOSS shared font replacement for Nintendo\'s extended Chinese character set + + Input Text Expand