From 0282c5ff185eef0bb933d889547a957ff8952cd4 Mon Sep 17 00:00:00 2001 From: zhupengfei Date: Wed, 25 Mar 2020 22:18:07 +0800 Subject: [PATCH] android: SoftwareKeyboard implementation --- .../citra_emu/applets/SoftwareKeyboard.java | 230 ++++++++++++++++++ src/android/app/src/main/jni/CMakeLists.txt | 2 + .../app/src/main/jni/applets/swkbd.cpp | 153 ++++++++++++ src/android/app/src/main/jni/applets/swkbd.h | 35 +++ src/android/app/src/main/jni/id_cache.cpp | 4 + src/android/app/src/main/jni/native.cpp | 15 +- .../app/src/main/res/values/strings.xml | 8 + 7 files changed, 434 insertions(+), 13 deletions(-) create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java create mode 100644 src/android/app/src/main/jni/applets/swkbd.cpp create mode 100644 src/android/app/src/main/jni/applets/swkbd.h diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java new file mode 100644 index 000000000..e02f4c5ef --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java @@ -0,0 +1,230 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.applets; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.support.annotation.Nullable; +import android.text.InputFilter; +import android.text.Spanned; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.FrameLayout; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.utils.Log; + +public final class SoftwareKeyboard { + /// Corresponds to Frontend::ButtonConfig + private interface ButtonConfig { + int Single = 0; /// Ok button + int Dual = 1; /// Cancel | Ok buttons + int Triple = 2; /// Cancel | I Forgot | Ok buttons + int None = 3; /// No button (returned by swkbdInputText in special cases) + } + + /// Corresponds to Frontend::ValidationError + public enum ValidationError { + None, + // Button Selection + ButtonOutOfRange, + // Configured Filters + MaxDigitsExceeded, + AtSignNotAllowed, + PercentNotAllowed, + BackslashNotAllowed, + ProfanityNotAllowed, + CallbackFailed, + // Allowed Input Type + FixedLengthRequired, + MaxLengthExceeded, + BlankInputNotAllowed, + EmptyInputNotAllowed, + } + + public static class KeyboardConfig { + public int button_config; + public int max_text_length; + public boolean multiline_mode; /// True if the keyboard accepts multiple lines of input + public String hint_text; /// Displayed in the field as a hint before + @Nullable public String[] button_text; /// Contains the button text that the caller provides + } + + /// Corresponds to Frontend::KeyboardData + public static class KeyboardData { + public int button; + public String text; + + private KeyboardData(int button, String text) { + this.button = button; + this.text = text; + } + } + + private static class Filter implements InputFilter { + @Override + public CharSequence filter(CharSequence source, int start, int end, Spanned dest, + int dstart, int dend) { + String text = new StringBuilder(dest) + .replace(dstart, dend, source.subSequence(start, end).toString()) + .toString(); + if (ValidateFilters(text) == ValidationError.None) { + return null; // Accept replacement + } + return dest.subSequence(dstart, dend); // Request the subsequence to be unchanged + } + } + + private static KeyboardData data; + private static final Object finishLock = new Object(); + + private static void ExecuteImpl(KeyboardConfig config) { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + + data = new KeyboardData(0, ""); + + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.leftMargin = params.rightMargin = + CitraApplication.getAppContext().getResources().getDimensionPixelSize( + R.dimen.dialog_margin); + + // Set up the input + EditText editText = new EditText(CitraApplication.getAppContext()); + editText.setHint(config.hint_text); + editText.setSingleLine(!config.multiline_mode); + editText.setLayoutParams(params); + editText.setFilters( + new InputFilter[] {new Filter(), new InputFilter.LengthFilter(config.max_text_length)}); + + FrameLayout container = new FrameLayout(emulationActivity); + container.addView(editText); + + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity) + .setTitle(R.string.software_keyboard) + .setView(container) + .setCancelable(false); + + switch (config.button_config) { + case ButtonConfig.Triple: { + final String text = config.button_text == null + ? emulationActivity.getString(R.string.i_forgot) + : config.button_text[1]; + builder.setNeutralButton(text, null); + } + // fallthrough + case ButtonConfig.Dual: { + final String text = config.button_text == null + ? emulationActivity.getString(android.R.string.cancel) + : config.button_text[0]; + builder.setNegativeButton(text, null); + } + // fallthrough + case ButtonConfig.Single: { + final String text = config.button_text == null + ? emulationActivity.getString(android.R.string.ok) + : config.button_text[config.button_config]; + builder.setPositiveButton(text, null); + break; + } + } + + final AlertDialog dialog = builder.create(); + dialog.show(); + if (dialog.getButton(DialogInterface.BUTTON_POSITIVE) != null) { + dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener((view) -> { + data.button = config.button_config; + data.text = editText.getText().toString(); + + final ValidationError error = ValidateInput(data.text); + if (error != ValidationError.None) { + HandleValidationError(config, error); + return; + } + + dialog.dismiss(); + + synchronized (finishLock) { + finishLock.notifyAll(); + } + }); + } + if (dialog.getButton(DialogInterface.BUTTON_NEUTRAL) != null) { + dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setOnClickListener((view) -> { + data.button = 1; + dialog.dismiss(); + synchronized (finishLock) { + finishLock.notifyAll(); + } + }); + } + if (dialog.getButton(DialogInterface.BUTTON_NEGATIVE) != null) { + dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((view) -> { + data.button = 0; + dialog.dismiss(); + synchronized (finishLock) { + finishLock.notifyAll(); + } + }); + } + } + + private static void HandleValidationError(KeyboardConfig config, ValidationError error) { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + String message = ""; + switch (error) { + case FixedLengthRequired: + message = + emulationActivity.getString(R.string.fixed_length_required, config.max_text_length); + break; + case MaxLengthExceeded: + message = + emulationActivity.getString(R.string.max_length_exceeded, config.max_text_length); + break; + case BlankInputNotAllowed: + message = emulationActivity.getString(R.string.blank_input_not_allowed); + break; + case EmptyInputNotAllowed: + message = emulationActivity.getString(R.string.empty_input_not_allowed); + break; + } + + new AlertDialog.Builder(emulationActivity) + .setTitle(R.string.software_keyboard) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + + public static KeyboardData Execute(KeyboardConfig config) { + if (config.button_config == ButtonConfig.None) { + Log.error("Unexpected button config None"); + return new KeyboardData(0, ""); + } + + NativeLibrary.sEmulationActivity.get().runOnUiThread(() -> ExecuteImpl(config)); + + synchronized (finishLock) { + try { + finishLock.wait(); + } catch (Exception ignored) { + } + } + + return data; + } + + public static void ShowError(String error) { + NativeLibrary.displayAlertMsg( + CitraApplication.getAppContext().getResources().getString(R.string.software_keyboard), + error, false); + } + + private static native ValidationError ValidateFilters(String text); + private static native ValidationError ValidateInput(String text); +} diff --git a/src/android/app/src/main/jni/CMakeLists.txt b/src/android/app/src/main/jni/CMakeLists.txt index e6f30ba72..3ed87339f 100644 --- a/src/android/app/src/main/jni/CMakeLists.txt +++ b/src/android/app/src/main/jni/CMakeLists.txt @@ -1,4 +1,6 @@ add_library(main SHARED + applets/swkbd.cpp + applets/swkbd.h button_manager.cpp button_manager.h config.cpp diff --git a/src/android/app/src/main/jni/applets/swkbd.cpp b/src/android/app/src/main/jni/applets/swkbd.cpp new file mode 100644 index 000000000..62e318483 --- /dev/null +++ b/src/android/app/src/main/jni/applets/swkbd.cpp @@ -0,0 +1,153 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include "core/core.h" +#include "jni/applets/swkbd.h" +#include "jni/id_cache.h" + +static std::string GetJString(JNIEnv* env, jstring jstr) { + if (!jstr) { + return {}; + } + + const char* s = env->GetStringUTFChars(jstr, nullptr); + std::string result = s; + env->ReleaseStringUTFChars(jstr, s); + return result; +} + +static jclass s_software_keyboard_class; +static jclass s_keyboard_config_class; +static jclass s_keyboard_data_class; +static jclass s_validation_error_class; +static jmethodID s_swkbd_execute; +static jmethodID s_swkbd_show_error; + +namespace SoftwareKeyboard { + +static jobject ToJavaKeyboardConfig(const Frontend::KeyboardConfig& config) { + JNIEnv* env = IDCache::GetEnvForThread(); + jobject object = env->AllocObject(s_keyboard_config_class); + env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "button_config", "I"), + static_cast(config.button_config)); + env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "max_text_length", "I"), + static_cast(config.max_text_length)); + env->SetBooleanField(object, env->GetFieldID(s_keyboard_config_class, "multiline_mode", "Z"), + static_cast(config.multiline_mode)); + env->SetObjectField(object, + env->GetFieldID(s_keyboard_config_class, "hint_text", "Ljava/lang/String;"), + env->NewStringUTF(config.hint_text.c_str())); + if (config.has_custom_button_text) { + const jclass string_class = + reinterpret_cast(env->NewGlobalRef(env->FindClass("java/lang/String"))); + const jobjectArray array = + env->NewObjectArray(static_cast(config.button_text.size()), string_class, + env->NewStringUTF(config.button_text[0].c_str())); + for (std::size_t i = 1; i < config.button_text.size(); ++i) { + env->SetObjectArrayElement(array, static_cast(i), + env->NewStringUTF(config.button_text[i].c_str())); + } + env->SetObjectField( + object, env->GetFieldID(s_keyboard_config_class, "button_text", "[Ljava/lang/String;"), + array); + env->DeleteGlobalRef(string_class); + } + return object; +} + +static Frontend::KeyboardData ToFrontendKeyboardData(jobject object) { + JNIEnv* env = IDCache::GetEnvForThread(); + const jstring string = reinterpret_cast(env->GetObjectField( + object, env->GetFieldID(s_keyboard_data_class, "text", "Ljava/lang/String;"))); + return Frontend::KeyboardData{ + GetJString(env, string), + static_cast( + env->GetIntField(object, env->GetFieldID(s_keyboard_data_class, "button", "I")))}; +} + +AndroidKeyboard::~AndroidKeyboard() = default; + +void AndroidKeyboard::Execute(const Frontend::KeyboardConfig& config) { + SoftwareKeyboard::Execute(config); + + const auto data = ToFrontendKeyboardData(IDCache::GetEnvForThread()->CallStaticObjectMethod( + s_software_keyboard_class, s_swkbd_execute, ToJavaKeyboardConfig(config))); + Finalize(data.text, data.button); +} + +void AndroidKeyboard::ShowError(const std::string& error) { + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(s_software_keyboard_class, s_swkbd_show_error, + env->NewStringUTF(error.c_str())); +} + +void InitJNI(JNIEnv* env) { + s_software_keyboard_class = reinterpret_cast( + env->NewGlobalRef(env->FindClass("org/citra/citra_emu/applets/SoftwareKeyboard"))); + s_keyboard_config_class = reinterpret_cast(env->NewGlobalRef( + env->FindClass("org/citra/citra_emu/applets/SoftwareKeyboard$KeyboardConfig"))); + s_keyboard_data_class = reinterpret_cast(env->NewGlobalRef( + env->FindClass("org/citra/citra_emu/applets/SoftwareKeyboard$KeyboardData"))); + s_validation_error_class = reinterpret_cast(env->NewGlobalRef( + env->FindClass("org/citra/citra_emu/applets/SoftwareKeyboard$ValidationError"))); + + s_swkbd_execute = env->GetStaticMethodID( + s_software_keyboard_class, "Execute", + "(Lorg/citra/citra_emu/applets/SoftwareKeyboard$KeyboardConfig;)Lorg/citra/citra_emu/" + "applets/SoftwareKeyboard$KeyboardData;"); + s_swkbd_show_error = + env->GetStaticMethodID(s_software_keyboard_class, "ShowError", "(Ljava/lang/String;)V"); +} + +void CleanupJNI(JNIEnv* env) { + env->DeleteGlobalRef(s_software_keyboard_class); + env->DeleteGlobalRef(s_keyboard_config_class); + env->DeleteGlobalRef(s_keyboard_data_class); + env->DeleteGlobalRef(s_validation_error_class); +} + +} // namespace SoftwareKeyboard + +jobject ToJavaValidationError(Frontend::ValidationError error) { + static const std::map ValidationErrorNameMap{{ + {Frontend::ValidationError::None, "None"}, + {Frontend::ValidationError::ButtonOutOfRange, "ButtonOutOfRange"}, + {Frontend::ValidationError::MaxDigitsExceeded, "MaxDigitsExceeded"}, + {Frontend::ValidationError::AtSignNotAllowed, "AtSignsNotAllowed"}, + {Frontend::ValidationError::PercentNotAllowed, "PercentNotAllowed"}, + {Frontend::ValidationError::BackslashNotAllowed, "BackslashNotAllowed"}, + {Frontend::ValidationError::ProfanityNotAllowed, "ProfanityNotAllowed"}, + {Frontend::ValidationError::CallbackFailed, "CallbackFailed"}, + {Frontend::ValidationError::FixedLengthRequired, "FixedLengthRequired"}, + {Frontend::ValidationError::MaxLengthExceeded, "MaxLengthExceeded"}, + {Frontend::ValidationError::BlankInputNotAllowed, "BlankInputNotAllowed"}, + {Frontend::ValidationError::EmptyInputNotAllowed, "EmptyInputNotAllowed"}, + }}; + ASSERT(ValidationErrorNameMap.count(error)); + + JNIEnv* env = IDCache::GetEnvForThread(); + return env->GetStaticObjectField( + s_validation_error_class, + env->GetStaticFieldID(s_validation_error_class, ValidationErrorNameMap.at(error), + "Lorg/citra/citra_emu/applets/SoftwareKeyboard$ValidationError;")); +} + +jobject Java_org_citra_citra_1emu_applets_SoftwareKeyboard_ValidateFilters(JNIEnv* env, + jclass clazz, + jstring text) { + + const auto ret = + Core::System::GetInstance().GetSoftwareKeyboard()->ValidateFilters(GetJString(env, text)); + return ToJavaValidationError(ret); +} + +jobject Java_org_citra_citra_1emu_applets_SoftwareKeyboard_ValidateInput(JNIEnv* env, jclass clazz, + jstring text) { + + const auto ret = + Core::System::GetInstance().GetSoftwareKeyboard()->ValidateInput(GetJString(env, text)); + return ToJavaValidationError(ret); +} diff --git a/src/android/app/src/main/jni/applets/swkbd.h b/src/android/app/src/main/jni/applets/swkbd.h new file mode 100644 index 000000000..664626695 --- /dev/null +++ b/src/android/app/src/main/jni/applets/swkbd.h @@ -0,0 +1,35 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include "core/frontend/applets/swkbd.h" + +namespace SoftwareKeyboard { + +class AndroidKeyboard final : public Frontend::SoftwareKeyboard { +public: + ~AndroidKeyboard(); + + void Execute(const Frontend::KeyboardConfig& config) override; + void ShowError(const std::string& error) override; +}; + +// Should be called in JNI_Load +void InitJNI(JNIEnv* env); + +// Should be called in JNI_Unload +void CleanupJNI(JNIEnv* env); + +} // namespace SoftwareKeyboard + +// Native function calls +extern "C" { +JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_applets_SoftwareKeyboard_ValidateFilters( + JNIEnv* env, jclass clazz, jstring text); + +JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_applets_SoftwareKeyboard_ValidateInput( + JNIEnv* env, jclass clazz, jstring text); +} diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp index f52515ad4..068162a63 100644 --- a/src/android/app/src/main/jni/id_cache.cpp +++ b/src/android/app/src/main/jni/id_cache.cpp @@ -7,6 +7,7 @@ #include "common/logging/filter.h" #include "common/logging/log.h" #include "core/settings.h" +#include "jni/applets/swkbd.h" #include "jni/id_cache.h" #include @@ -111,6 +112,8 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { s_exit_emulation_activity = env->GetStaticMethodID(s_native_library_class, "exitEmulationActivity", "(I)V"); + SoftwareKeyboard::InitJNI(env); + return JNI_VERSION; } @@ -121,6 +124,7 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) { } env->DeleteGlobalRef(s_native_library_class); + SoftwareKeyboard::CleanupJNI(env); } #ifdef __cplusplus diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index b1107a7a3..26640730d 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -19,6 +19,7 @@ #include "core/frontend/scope_acquire_context.h" #include "core/hle/service/am/am.h" #include "core/settings.h" +#include "jni/applets/swkbd.h" #include "jni/button_manager.h" #include "jni/config.h" #include "jni/emu_window/emu_window.h" @@ -83,18 +84,6 @@ static int AlertPromptButton() { IDCache::GetAlertPromptButton())); } -class AndroidKeyboard final : public Frontend::SoftwareKeyboard { -public: - void Execute(const Frontend::KeyboardConfig& config) override { - SoftwareKeyboard::Execute(config); - Finalize(DisplayAlertPrompt("Enter text", config.hint_text.c_str(), - static_cast(this->config.button_config)), - AlertPromptButton()); - } - - void ShowError(const std::string& error) override {} -}; - static Core::System::ResultStatus RunCitra(const std::string& filepath) { // Citra core only supports a single running instance std::lock_guard lock(running_mutex); @@ -118,7 +107,7 @@ static Core::System::ResultStatus RunCitra(const std::string& filepath) { // Register frontend applets Frontend::RegisterDefaultApplets(); - system.RegisterSoftwareKeyboard(std::make_shared()); + system.RegisterSoftwareKeyboard(std::make_shared()); InputManager::Init(); diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 1c608763d..a3391b1ea 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -137,4 +137,12 @@ The external storage needs to be available in order to use Citra Select This Directory + + + Software Keyboard + I Forgot + Text length is not correct (should be %d characters) + Text is too long (should be no more than %d characters) + Blank input is not allowed + Empty input is not allowed