Skip to content

Commit

Permalink
Improve text input (#43)
Browse files Browse the repository at this point in the history
* Improve inputting Korean
* Improve log to be useful for development
* Improve the readability of filter event
* Set more status to edit text properly
* Delete preedit text more appropriately
* Add DeviceProfile
* Removed the resizing code that was blocked for a while
  Please re-implement it, when the problem is fully identified
  and solved. See #28 for more details

When using ecore-imf, disappointingly, The behavior of
several factors related to text input in each device profile
is different(such as key-event, calling callbacks for imf).
So I've fine-tuned it, but this may not be the best.
Maybe it's better to separate it by profile in the future.

Signed-off-by: Boram Bae <boram21.bae@samsung.com>
bbrto21 authored Feb 23, 2021
1 parent 7e50d5d commit 90eae04
Showing 5 changed files with 178 additions and 180 deletions.
299 changes: 139 additions & 160 deletions shell/platform/tizen/channels/text_input_channel.cc
Original file line number Diff line number Diff line change
@@ -70,7 +70,7 @@ void TextInputChannel::PreeditCallback(void* data, Ecore_IMF_Context* ctx,
int cursor_pos;
ecore_imf_context_preedit_string_get(ctx, &preedit_string, &cursor_pos);
if (preedit_string) {
self->OnPredit(preedit_string, cursor_pos);
self->OnPreedit(preedit_string, cursor_pos);
free(preedit_string);
}
}
@@ -79,68 +79,41 @@ void TextInputChannel::PrivateCommandCallback(void* data,
Ecore_IMF_Context* ctx,
void* event_info) {
// TODO
FT_LOGD_UNIMPLEMENTED();
FT_UNIMPLEMENTED();
}

void TextInputChannel::DeleteSurroundingCallback(void* data,
Ecore_IMF_Context* ctx,
void* event_info) {
// TODO
FT_LOGD_UNIMPLEMENTED();
FT_UNIMPLEMENTED();
}

void TextInputChannel::InputPanelStateChangedCallback(
void* data, Ecore_IMF_Context* context, int value) {
FT_LOGD("Change input panel state[%d]", value);
if (!data) {
FT_LOGD("[No Data]\n");
FT_LOGW("No Data");
return;
}
TextInputChannel* self = (TextInputChannel*)data;
switch (value) {
case ECORE_IMF_INPUT_PANEL_STATE_SHOW: {
FT_LOGD("[PANEL_STATE_SHOW]\n");
if (self->engine_->device_profile ==
"mobileD") { // FIXME : Needs improvement on other devices.
ecore_timer_add(
0.25,
[](void* data) -> Eina_Bool {
TextInputChannel* self = (TextInputChannel*)data;
auto window_geometry =
self->engine_->tizen_renderer->GetGeometry();
int32_t surface_w = window_geometry.w;
int32_t surface_h =
window_geometry.h - self->current_keyboard_geometry_.h;

self->engine_->tizen_renderer->ResizeWithRotation(0, 0, surface_w,
surface_h, 0);
if (self->rotation == 90 || self->rotation == 270) {
self->engine_->SendWindowMetrics(surface_h, surface_w, 0);
} else {
self->engine_->SendWindowMetrics(surface_w, surface_h, 0);
}

return ECORE_CALLBACK_CANCEL;
},
self);
}
} break;
case ECORE_IMF_INPUT_PANEL_STATE_SHOW:
break;
case ECORE_IMF_INPUT_PANEL_STATE_HIDE:
self->HideSoftwareKeyboard(); // FIXME: Fallback for HW back-key
FT_LOGD("[PANEL_STATE_HIDE]\n");
break;
case ECORE_IMF_INPUT_PANEL_STATE_WILL_SHOW:
FT_LOGD("[PANEL_STATE_WILL_SHOW]\n");
break;
default:
FT_LOGD("[PANEL_STATE_EVENT (default: %d)]\n", value);
break;
}
}

void TextInputChannel::InputPanelGeometryChangedCallback(
void* data, Ecore_IMF_Context* context, int value) {
if (!data) {
FT_LOGD("[No Data]\n");
FT_LOGW("No Data");
return;
}
TextInputChannel* self = (TextInputChannel*)data;
@@ -150,7 +123,7 @@ void TextInputChannel::InputPanelGeometryChangedCallback(
&self->current_keyboard_geometry_.h);

FT_LOGD(
"[Current keyboard geometry] x:%d y:%d w:%d h:%d\n",
"Current keyboard geometry x:[%d] y:[%d] w:[%d] h:[%d]",
self->current_keyboard_geometry_.x, self->current_keyboard_geometry_.y,
self->current_keyboard_geometry_.w, self->current_keyboard_geometry_.h);
}
@@ -159,13 +132,7 @@ Eina_Bool TextInputChannel::RetrieveSurroundingCallback(void* data,
Ecore_IMF_Context* ctx,
char** text,
int* cursor_pos) {
// TODO
if (text) {
*text = strdup("");
}
if (cursor_pos) {
*cursor_pos = 0;
}
FT_UNIMPLEMENTED();
return EINA_TRUE;
}

@@ -275,11 +242,6 @@ TextInputChannel::TextInputChannel(flutter::BinaryMessenger* messenger,
TizenEmbedderEngine* engine)
: channel_(std::make_unique<flutter::MethodChannel<rapidjson::Document>>(
messenger, kChannelName, &flutter::JsonMethodCodec::GetInstance())),
active_model_(nullptr),
is_software_keyboard_showing_(false),
last_preedit_string_length_(0),
imf_context_(nullptr),
in_select_mode_(false),
engine_(engine) {
channel_->SetMethodCallHandler(
[this](
@@ -313,7 +275,7 @@ TextInputChannel::~TextInputChannel() {
}

void TextInputChannel::OnKeyDown(Ecore_Event_Key* key) {
if (active_model_ && !FilterEvent(key)) {
if (active_model_ && !FilterEvent(key) && !have_preedit_) {
NonIMFFallback(key);
}
}
@@ -429,11 +391,11 @@ void TextInputChannel::SendStateUpdate(const flutter::TextInputModel& model) {
kTextKey, rapidjson::Value(model.GetText(), allocator).Move(), allocator);
args->PushBack(editing_state, allocator);

FT_LOGD("Send text[%s]", model.GetText().data());
channel_->InvokeMethod(kUpdateEditingStateMethod, std::move(args));
}

bool TextInputChannel::FilterEvent(Ecore_Event_Key* keyDownEvent) {
FT_LOGD("NonIMFFallback key name [%s]", keyDownEvent->keyname);
bool handled = false;
const char* device = ecore_device_name_get(keyDownEvent->dev);

@@ -457,97 +419,104 @@ bool TextInputChannel::FilterEvent(Ecore_Event_Key* keyDownEvent) {
#endif

bool isIME = strcmp(device, "ime") == 0;

if (isIME && strcmp(keyDownEvent->key, "Select") == 0) {
if (engine_->device_profile == "wearable") {
if (engine_->device_profile == DeviceProfile::kWearable) {
// FIXME: for wearable
in_select_mode_ = true;
} else {
SelectPressed(active_model_.get());
return true;
FT_LOGD("Set select mode[true]");
}
}

if (isIME && strcmp(keyDownEvent->key, "Left") == 0) {
if (active_model_->MoveCursorBack()) {
SendStateUpdate(*active_model_);
return true;
}
} else if (isIME && strcmp(keyDownEvent->key, "Right") == 0) {
if (active_model_->MoveCursorForward()) {
SendStateUpdate(*active_model_);
return true;
}
} else if (isIME && strcmp(keyDownEvent->key, "End") == 0) {
active_model_->MoveCursorToEnd();
SendStateUpdate(*active_model_);
return true;
} else if (isIME && strcmp(keyDownEvent->key, "Home") == 0) {
active_model_->MoveCursorToBeginning();
SendStateUpdate(*active_model_);
return true;
} else if (isIME && strcmp(keyDownEvent->key, "BackSpace") == 0) {
if (active_model_->Backspace()) {
SendStateUpdate(*active_model_);
return true;
}
} else if (isIME && strcmp(keyDownEvent->key, "Delete") == 0) {
if (active_model_->Delete()) {
SendStateUpdate(*active_model_);
return true;
}
} else {
handled = ecore_imf_context_filter_event(
imf_context_, ECORE_IMF_EVENT_KEY_DOWN,
reinterpret_cast<Ecore_IMF_Event*>(&ecoreKeyDownEvent));
}

if (!handled && !strcmp(keyDownEvent->key, "Return")) {
if (in_select_mode_) {
in_select_mode_ = false;
handled = true;
} else {
ecore_imf_context_reset(imf_context_);
EnterPressed(active_model_.get());
if (isIME) {
if (!strcmp(keyDownEvent->key, "Left") ||
!strcmp(keyDownEvent->key, "Right") ||
!strcmp(keyDownEvent->key, "End") ||
!strcmp(keyDownEvent->key, "Home") ||
!strcmp(keyDownEvent->key, "BackSpace") ||
!strcmp(keyDownEvent->key, "Delete") ||
(!strcmp(keyDownEvent->key, "Select") && !in_select_mode_)) {
// Force redirect to fallback!(especially on TV)
// If you don't do this, it affects the input panel.
// For example, when the left key of the input panel is pressed, the focus
// of the input panel is shifted to left!
// What we want is to move only the cursor on the text editor.
ResetCurrentContext();
FT_LOGD("Force redirect IME key-event[%s] to fallback",
keyDownEvent->keyname);
return false;
}
}

handled = ecore_imf_context_filter_event(
imf_context_, ECORE_IMF_EVENT_KEY_DOWN,
reinterpret_cast<Ecore_IMF_Event*>(&ecoreKeyDownEvent));

if (handled) {
last_handled_ecore_event_keyname_ = keyDownEvent->keyname;
}

FT_LOGD("The %skey-event[%s] are%s filtered", isIME ? "IME " : "",
keyDownEvent->keyname, handled ? "" : " not");

if (!handled && !strcmp(keyDownEvent->key, "Return") && in_select_mode_ &&
engine_->device_profile == DeviceProfile::kWearable) {
in_select_mode_ = false;
handled = true;
FT_LOGD("Set select mode[false]");
}

return handled;
}

void TextInputChannel::NonIMFFallback(Ecore_Event_Key* keyDownEvent) {
FT_LOGD("NonIMFFallback key name [%s]", keyDownEvent->keyname);
if (strcmp(keyDownEvent->key, "Left") == 0) {

// For mobile, fix me!
if (engine_->device_profile == DeviceProfile::kMobile &&
edit_status_ == EditStatus::kPreeditEnd) {
SetEditStatus(EditStatus::kNone);
FT_LOGD("Ignore key-event[%s]!", keyDownEvent->keyname);
return;
}

bool select = !strcmp(keyDownEvent->key, "Select");
if (!strcmp(keyDownEvent->key, "Left")) {
if (active_model_->MoveCursorBack()) {
SendStateUpdate(*active_model_);
}
} else if (strcmp(keyDownEvent->key, "Right") == 0) {
} else if (!strcmp(keyDownEvent->key, "Right")) {
if (active_model_->MoveCursorForward()) {
SendStateUpdate(*active_model_);
}
} else if (strcmp(keyDownEvent->key, "End") == 0) {
} else if (!strcmp(keyDownEvent->key, "End")) {
active_model_->MoveCursorToEnd();
SendStateUpdate(*active_model_);
} else if (strcmp(keyDownEvent->key, "Home") == 0) {
} else if (!strcmp(keyDownEvent->key, "Home")) {
active_model_->MoveCursorToBeginning();
SendStateUpdate(*active_model_);
} else if (strcmp(keyDownEvent->key, "BackSpace") == 0) {
} else if (!strcmp(keyDownEvent->key, "BackSpace")) {
if (active_model_->Backspace()) {
SendStateUpdate(*active_model_);
}
} else if (strcmp(keyDownEvent->key, "Delete") == 0) {
} else if (!strcmp(keyDownEvent->key, "Delete")) {
if (active_model_->Delete()) {
SendStateUpdate(*active_model_);
}
} else if (strcmp(keyDownEvent->key, "Return") == 0) {
EnterPressed(active_model_.get());
} else if (!strcmp(keyDownEvent->key, "Return") ||
(select && !in_select_mode_)) {
EnterPressed(active_model_.get(), select);
} else if (keyDownEvent->string && strlen(keyDownEvent->string) == 1 &&
IsASCIIPrintableKey(keyDownEvent->string[0])) {
active_model_->AddCodePoint(keyDownEvent->string[0]);
SendStateUpdate(*active_model_);
}
SetEditStatus(EditStatus::kNone);
}

void TextInputChannel::EnterPressed(flutter::TextInputModel* model) {
if (input_type_ == kMultilineInputType) {
void TextInputChannel::EnterPressed(flutter::TextInputModel* model,
bool select) {
if (!select && input_type_ == kMultilineInputType) {
model->AddCodePoint('\n');
SendStateUpdate(*model);
}
@@ -559,42 +528,50 @@ void TextInputChannel::EnterPressed(flutter::TextInputModel* model) {
channel_->InvokeMethod(kPerformActionMethod, std::move(args));
}

void TextInputChannel::SelectPressed(flutter::TextInputModel* model) {
auto args = std::make_unique<rapidjson::Document>(rapidjson::kArrayType);
auto& allocator = args->GetAllocator();
args->PushBack(client_id_, allocator);
args->PushBack(rapidjson::Value(input_action_, allocator).Move(), allocator);
void TextInputChannel::OnCommit(std::string str) {
FT_LOGD("OnCommit str[%s]", str.data());
SetEditStatus(EditStatus::kCommit);

channel_->InvokeMethod(kPerformActionMethod, std::move(args));
}
ConsumeLastPreedit();

void TextInputChannel::OnCommit(const char* str) {
for (int i = last_preedit_string_length_; i > 0; i--) {
active_model_->Backspace();
}
active_model_->AddText(str);
FT_LOGD("Add Text[%s]", str.data());

SendStateUpdate(*active_model_);
last_preedit_string_length_ = 0;
SetEditStatus(EditStatus::kNone);
}

void TextInputChannel::OnPredit(const char* str, int cursorPos) {
if (strcmp(str, "") == 0) {
last_preedit_string_length_ = 0;
return;
}

for (int i = last_preedit_string_length_; i > 0; i--) {
active_model_->Backspace();
void TextInputChannel::OnPreedit(std::string str, int cursor_pos) {
FT_LOGD("OnPreedit str[%s], cursor_pos[%d]", str.data(), cursor_pos);
SetEditStatus(EditStatus::kPreeditStart);
if (str.compare("") == 0) {
SetEditStatus(EditStatus::kPreeditEnd);
}

if (edit_status_ == EditStatus::kPreeditStart ||
(edit_status_ == EditStatus::kPreeditEnd &&
// For tv, fix me
last_handled_ecore_event_keyname_.compare("Return") != 0)) {
FT_LOGD("last_handled_ecore_event_keyname_[%s]",
last_handled_ecore_event_keyname_.data());
last_handled_ecore_event_keyname_ = "";
ConsumeLastPreedit();
}

have_preedit_ = false;
if (edit_status_ == EditStatus::kPreeditStart) {
preedit_start_pos_ = active_model_->selection_base();
active_model_->AddText(str);
preedit_end_pos_ = active_model_->selection_base();
have_preedit_ = true;
SendStateUpdate(*active_model_);
FT_LOGD("preedit start pos[%d], preedit end pos[%d]", preedit_start_pos_,
preedit_end_pos_);
}

active_model_->AddText(str);
SendStateUpdate(*active_model_);
last_preedit_string_length_ = cursorPos;
}

void TextInputChannel::ShowSoftwareKeyboard() {
FT_LOGD("ShowPanel() [is_software_keyboard_showing_:%d] \n",
is_software_keyboard_showing_);
FT_LOGD("Show input panel");
if (imf_context_ && !is_software_keyboard_showing_) {
is_software_keyboard_showing_ = true;
ecore_imf_context_input_panel_show(imf_context_);
@@ -603,40 +580,19 @@ void TextInputChannel::ShowSoftwareKeyboard() {
}

void TextInputChannel::HideSoftwareKeyboard() {
FT_LOGD("HidePanel() [is_software_keyboard_showing_:%d] \n",
is_software_keyboard_showing_);
FT_LOGD("Hide input panel");
if (imf_context_ && is_software_keyboard_showing_) {
is_software_keyboard_showing_ = false;

if (engine_->device_profile ==
"mobileD") { // FIXME : Needs improvement on other devices.
auto window_geometry = engine_->tizen_renderer->GetGeometry();

if (rotation == 90 || rotation == 270) {
engine_->SendWindowMetrics(window_geometry.h, window_geometry.w, 0);
} else {
engine_->SendWindowMetrics(window_geometry.w, window_geometry.h, 0);
}
engine_->tizen_renderer->ResizeWithRotation(0, 0, window_geometry.w,
window_geometry.h, 0);
ecore_timer_add(
0.05,
[](void* data) -> Eina_Bool {
Ecore_IMF_Context* imfContext = (Ecore_IMF_Context*)data;
ecore_imf_context_reset(imfContext);
ecore_imf_context_focus_out(imfContext);
ecore_imf_context_input_panel_hide(imfContext);
return ECORE_CALLBACK_CANCEL;
},
imf_context_);
} else {
ecore_imf_context_reset(imf_context_);
ecore_imf_context_focus_out(imf_context_);
ecore_imf_context_input_panel_hide(imf_context_);
}
ResetCurrentContext();
ecore_imf_context_focus_out(imf_context_);
}
}

void TextInputChannel::SetEditStatus(EditStatus edit_status) {
FT_LOGD("Set edit status[%d]", edit_status);
edit_status_ = edit_status;
}

void TextInputChannel::RegisterIMFCallback() {
// ecore_imf_context_input_panel_enabled_set(imf_context_, false);
ecore_imf_context_event_callback_add(imf_context_, ECORE_IMF_CALLBACK_COMMIT,
@@ -689,3 +645,26 @@ void TextInputChannel::UnregisterIMFCallback() {
imf_context_, ECORE_IMF_INPUT_PANEL_GEOMETRY_EVENT,
InputPanelGeometryChangedCallback);
}

void TextInputChannel::ConsumeLastPreedit() {
if (have_preedit_) {
std::string before = active_model_->GetText();
int count = preedit_end_pos_ - preedit_start_pos_;
active_model_->DeleteSurrounding(-count, count);
std::string after = active_model_->GetText();
FT_LOGD("Consume last preedit count:[%d] text:[%s] -> [%s]", count,
before.data(), after.data());
SendStateUpdate(*active_model_);
}
have_preedit_ = false;
preedit_end_pos_ = 0;
preedit_start_pos_ = 0;
}

void TextInputChannel::ResetCurrentContext() {
SetEditStatus(EditStatus::kNone);
ecore_imf_context_reset(imf_context_);
preedit_start_pos_ = 0;
preedit_end_pos_ = 0;
have_preedit_ = false;
}
32 changes: 20 additions & 12 deletions shell/platform/tizen/channels/text_input_channel.h
Original file line number Diff line number Diff line change
@@ -22,15 +22,17 @@ class TextInputChannel {
struct SoftwareKeyboardGeometry {
int32_t x = 0, y = 0, w = 0, h = 0;
};
enum EditStatus { kNone, kPreeditStart, kPreeditEnd, kCommit };
explicit TextInputChannel(flutter::BinaryMessenger* messenger,
TizenEmbedderEngine* engine);
virtual ~TextInputChannel();
void OnKeyDown(Ecore_Event_Key* key);
void OnCommit(const char* str);
void OnPredit(const char* str, int cursorPos);
void OnCommit(std::string str);
void OnPreedit(std::string str, int cursor_pos);
void ShowSoftwareKeyboard();
void HideSoftwareKeyboard();
bool IsSoftwareKeyboardShowing() { return is_software_keyboard_showing_; }
void SetEditStatus(EditStatus edit_status);
SoftwareKeyboardGeometry GetCurrentKeyboardGeometry() {
return current_keyboard_geometry_;
}
@@ -46,10 +48,11 @@ class TextInputChannel {
void SendStateUpdate(const flutter::TextInputModel& model);
bool FilterEvent(Ecore_Event_Key* keyDownEvent);
void NonIMFFallback(Ecore_Event_Key* keyDownEvent);
void EnterPressed(flutter::TextInputModel* model);
void SelectPressed(flutter::TextInputModel* model);
void EnterPressed(flutter::TextInputModel* model, bool select);
void RegisterIMFCallback();
void UnregisterIMFCallback();
void ConsumeLastPreedit();
void ResetCurrentContext();

std::unique_ptr<flutter::MethodChannel<rapidjson::Document>> channel_;
std::unique_ptr<flutter::TextInputModel> active_model_;
@@ -72,15 +75,20 @@ class TextInputChannel {
Ecore_IMF_Context* ctx,
char** text, int* cursor_pos);

int client_id_;
std::string input_type_;
std::string input_action_;
bool is_software_keyboard_showing_;
int last_preedit_string_length_;
Ecore_IMF_Context* imf_context_;
bool in_select_mode_;
TizenEmbedderEngine* engine_;
int client_id_{0};
SoftwareKeyboardGeometry current_keyboard_geometry_;
bool is_software_keyboard_showing_{false};
std::string input_action_;
std::string input_type_;

EditStatus edit_status_{EditStatus::kNone};
bool have_preedit_{false};
bool in_select_mode_{false};
int preedit_end_pos_{0};
int preedit_start_pos_{0};
std::string last_handled_ecore_event_keyname_;
TizenEmbedderEngine* engine_{nullptr};
Ecore_IMF_Context* imf_context_{nullptr};
};

#endif // EMBEDDER_TEXT_INPUT_CHANNEL_H_
21 changes: 15 additions & 6 deletions shell/platform/tizen/tizen_embedder_engine.cc
Original file line number Diff line number Diff line change
@@ -16,13 +16,22 @@
// Unique number associated with platform tasks.
static constexpr size_t kPlatformTaskRunnerIdentifier = 1;

static std::string GetDeviceProfile() {
static DeviceProfile GetDeviceProfile() {
char* feature_profile;
system_info_get_platform_string("http://tizen.org/feature/profile",
&feature_profile);
std::string profile(feature_profile);
free(feature_profile);
return profile;

if (profile == "mobile") {
return DeviceProfile::kMobile;
} else if (profile == "wearable") {
return DeviceProfile::kWearable;
} else if (profile == "tv") {
return DeviceProfile::kTV;
}
FT_LOGW("Flutter-tizen is running on an unknown device profile!");
return DeviceProfile::kUnknown;
}

static double GetDeviceDpi() {
@@ -250,14 +259,14 @@ void TizenEmbedderEngine::SendWindowMetrics(int32_t width, int32_t height,
// profile. A fixed DPI value (72) is used on TVs. See:
// https://docs.tizen.org/application/native/guides/ui/efl/multiple-screens
double profile_factor = 1.0;
if (device_profile == "wearable") {
if (device_profile == DeviceProfile::kWearable) {
profile_factor = 0.4;
} else if (device_profile == "mobile") {
} else if (device_profile == DeviceProfile::kMobile) {
profile_factor = 0.7;
} else if (device_profile == "tv") {
} else if (device_profile == DeviceProfile::kTV) {
profile_factor = 2.0;
}
double dpi = device_profile == "tv" ? 72.0 : device_dpi;
double dpi = device_profile == DeviceProfile::kTV ? 72.0 : device_dpi;
double scale_factor = dpi / 90.0 * profile_factor;
event.pixel_ratio = std::max(scale_factor, 1.0);
} else {
4 changes: 3 additions & 1 deletion shell/platform/tizen/tizen_embedder_engine.h
Original file line number Diff line number Diff line change
@@ -64,6 +64,8 @@ struct FlutterTextureRegistrar {

using UniqueAotDataPtr = std::unique_ptr<_FlutterEngineAOTData, AOTDataDeleter>;

enum DeviceProfile { kUnknown, kMobile, kWearable, kTV };

// Manages state associated with the underlying FlutterEngine.
class TizenEmbedderEngine : public TizenRenderer::Delegate {
public:
@@ -111,7 +113,7 @@ class TizenEmbedderEngine : public TizenRenderer::Delegate {
std::unique_ptr<TextInputChannel> text_input_channel;
std::unique_ptr<PlatformViewChannel> platform_view_channel;

const std::string device_profile;
const DeviceProfile device_profile;
const double device_dpi;

private:
2 changes: 1 addition & 1 deletion shell/platform/tizen/tizen_log.h
Original file line number Diff line number Diff line change
@@ -64,6 +64,6 @@ void StartLogging();
#define FT_COMPILE_ASSERT(exp, name) static_assert((exp), #name)
#endif

#define FT_LOGD_UNIMPLEMENTED() FT_LOGD("UNIMPLEMENTED")
#define FT_UNIMPLEMENTED() FT_LOGD("UNIMPLEMENTED!")

#endif // EMBEDDER_TIZEN_LOG_H_

0 comments on commit 90eae04

Please sign in to comment.