package tr.xip.wanikani; import android.content.Context; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; import android.content.res.Resources; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.Typeface; import android.media.AudioManager; import android.os.Handler; import android.os.Looper; import android.preference.PreferenceManager; import android.text.Editable; import android.text.InputType; import android.text.TextWatcher; import android.util.DisplayMetrics; import android.util.TypedValue; import android.util.Log; import android.view.Gravity; import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup.LayoutParams; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; import android.webkit.JavascriptInterface; import android.webkit.ValueCallback; import android.webkit.WebView; import android.widget.Button; import android.widget.EditText; import android.widget.RelativeLayout; import android.widget.TextView; import android.widget.TextView.OnEditorActionListener; import java.lang.reflect.Method; import java.util.EnumMap; import java.util.Hashtable; import java.util.Map; import tr.xip.wanikani.app.activity.FocusWebView; import tr.xip.wanikani.app.activity.WebReviewActivity; import tr.xip.wanikani.models.WaniKaniItem; import tr.xip.wanikani.managers.PrefManager; import tr.xip.wanikani.userscripts.IgnoreButton; import tr.xip.wanikani.userscripts.LessonOrder; import tr.xip.wanikani.userscripts.MistakeDelay; import tr.xip.wanikani.userscripts.ReviewOrder; import tr.xip.wanikani.userscripts.WaniKaniImprove; import tr.xip.wanikani.userscripts.WaniKaniKunOn; import tr.xip.wanikani.utils.Fonts; import tr.xip.wanikani.utils.Utils; /* * Copyright (c) 2013 Alberto Cuda * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ /** * Implementation of the "Custom IME" keyboard. This is now the preferred choice, * since it is the only one that features tilde substitution, ignore button, and * is much more appealing on small devices. Basically we put a textbox on top * of the real answer form, and use a regular keyboard to input text. If it is * a "reading" question, we perform kana translation directly inside the app, * instead of using the WK JS-based IME. */ public class LocalIMEKeyboard implements Keyboard { /** * A listener of meaningful webview events. We need this to synchronized the position * of our text box with that of the HTML form. */ private class WebViewListener implements FocusWebView.Listener { /** * Called when the webview scrolls vertically. We relay the event * to {@link LocalIMEKeyboard#scroll(int, int)}. */ @Override public void onScroll (int dx, int dy) { Handler handler; if (!bpos.visible) return; scroll (dx, dy); handler = new Handler (); handler.postDelayed (new UpdatePositionTask (), 707); } } private class UpdatePositionTask implements Runnable { public UpdatePositionTask () { lpt = this; } @Override public void run () { if (lpt == this && bpos.visible) wv.js (JS_UPDATE_POSITION); } } /** * Listener of all the events related to the IME. This class is the glue between the app * and {@link JapaneseIME}: it handles text changes, delivers them to the IME and updates * the contents accordingly. */ private class IMEListener implements TextWatcher, OnEditorActionListener, View.OnClickListener { /// Set if we need to perform kana translation boolean translate; /// The list of chars are not allowed when entering a meaning private static final String M_BANNED_CHARS = ",/;[]\\`\"=+.?!"; /// The list of chars are not allowed when entering a reading private static final String R_BANNED_CHARS = M_BANNED_CHARS + " "; private int findFirstBannedChar (String s, boolean kana) { String chars; int i; chars = kana ? R_BANNED_CHARS : M_BANNED_CHARS; for (i = 0; i < s.length (); i++) if (chars.indexOf (s.charAt (i)) >= 0) return i; return -1; } /** * Called after the text is changed to perform kana translation (if enabled). * In that case, the new text is sent the IME and, if some changes need to be performed, * they are applied to the text view. After that, android will call this method again, * but that's safe because the IME won't ask for a replacement any more. */ @Override public void afterTextChanged (Editable et) { JapaneseIME.Replacement repl; int pos, i; i = findFirstBannedChar (et.toString (), translate); if (i >= 0) { et.replace (i, i + 1, ""); return; } // Aralox: Pressing backspace after a wrong answer will trigger the 'ignore' button. if (canIgnore && et.length() < previousLength) { ignore(); if (et.length() <= 0) // Have at least one character so we can easily move to next Q. et.append(previousLastCharacter); return; } if (!translate) return; pos = ew.getSelectionStart (); repl = ime.replace (et.toString (), pos); if (repl != null) et.replace (repl.start, repl.end, repl.text); } //Aralox: used for checking backspace press (used to trigger 'ignore' button). int previousLength; char previousLastCharacter = ' '; @Override public void beforeTextChanged (CharSequence cs, int start, int count, int after) { //Aralox: used for checking backspace press (used to trigger 'ignore' button). previousLength = cs.length(); if (previousLength > 0) previousLastCharacter = cs.charAt(previousLength-1); } @Override public void onTextChanged (CharSequence cs, int start, int before, int count) { /* empty */ } /** * Enable or disable kana translation. * @param enable set if should be enabled */ public void translate (boolean enable) { translate = enable; } /** * Handler of editor actions. It intercepts the "enter" key and moves to the * next question, by calling {@link #next()}. */ @Override public boolean onEditorAction (TextView tv, int actionId, KeyEvent event) { if (actionId == EditorInfo.IME_ACTION_DONE || (actionId == EditorInfo.IME_NULL && event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_DOWN)) { next (); return true; } return false; } /** * Handler of the next button. Calls {@link #next()}. */ @Override public void onClick (View view) { next (); } /** * Called when the user presses the "next" button. It completes kana translation * (to fix trailing 'n's), and injects the answer. */ private void next () { String s, orgs; s = ew.getText ().toString (); /* This prevents some race conditions */ if (s.length () == 0 || frozen) return; frozen = true; if (translate) { s = ime.fixup (orgs = s); if (!s.equals (orgs)) // We do this only if needed to avoid glitch on cursor ew.setText (s); } if (!editable) wv.js (JS_ENTER); else if (isWKIEnabled) wv.js (String.format (JS_INJECT_ANSWER, s) + WaniKaniImprove.getCode()); else wv.js (String.format (JS_INJECT_ANSWER, s)); } } /** * Chain runnable that handles "setText" events on the UI thread. */ private class JSSetText implements Runnable { /// The text to set public String text; /** * Constructor * @param text the text to set */ public JSSetText (String text) { this.text = text; wav.runOnUiThread (this); } /** * Sets the text on the EditView. */ public void run () { ew.setText(text); } } /** * Chain runnable that handles "setClass" events on the UI thread. */ private class JSListenerSetClass implements Runnable { /// Set if the classes should be added. If unset, all CSS classes should be removed public boolean enable; /// The CSS class to emulate public String clazz; /// Set if we reviewing, unset if we are in the lessons quiz module public boolean reviews; /// SRS level public int level; /** * Constructor. Used when the edittext changes class. * @param clazz the CSS class * @param reviews are we in the review module */ public JSListenerSetClass (String clazz, boolean reviews) { this.clazz = clazz; this.reviews = reviews; enable = true; wav.runOnUiThread (this); } /** * Constructor. Used when the edittext goes back to the default class */ public JSListenerSetClass (int level) { this.level = level; enable = false; wav.runOnUiThread (this); } /** * Delivers the event to {@link LocalIMEKeyboard#setClass(String)} * or {@link LocalIMEKeyboard#unsetClass()}, depending on the type of event. */ public void run () { if (enable) setClass (clazz, reviews); else unsetClass (level); setInputType(); } } private class JSHideShow implements Runnable { boolean show; public JSHideShow (boolean show) { this.show = show; wav.runOnUiThread (this); } public void run () { if (show) { divw.setVisibility (View.VISIBLE); if (!hwkeyb) { imm.showSoftInput (wv, InputMethodManager.SHOW_FORCED); if (PrefManager.getPortraitMode()) wav.setRequestedOrientation (ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); } ew.requestFocus (); if (hwkeyb) imm.hideSoftInputFromWindow (ew.getWindowToken (), 0); } else { if (!hwkeyb) { if (PrefManager.getPortraitMode()) wav.setRequestedOrientation (orientation); } divw.setVisibility (View.GONE); imm.hideSoftInputFromWindow (ew.getWindowToken (), 0); } showQuestionPatch (bpos.qvisible && show); } } private class JSListenerShow implements Runnable { int sequence; Rect frect, trect; public JSListenerShow (int sequence, Rect frect, Rect trect) { this.sequence = sequence; this.frect = frect; this.trect = trect; wav.runOnUiThread (this); } public void run () { if (updateSequence (sequence)) replace(frect, trect); } } private class JSListenerShowQuestion implements Runnable { int sequence; WaniKaniItem.Type type; String name; Rect rect; int size; public JSListenerShowQuestion (int sequence, WaniKaniItem.Type type, String name, Rect rect, int size) { this.sequence = sequence; this.type = type; this.name = name; this.rect = rect; this.size = size; wav.runOnUiThread (this); } public void run () { if (updateSequence (sequence)) showQuestion (type, name, rect, size); } } private class BoxPosition { Rect frect; Rect trect; /// The box is visible, or ought to be unless there is a timeout screen showing boolean visible; /// Is the timeout screen visible boolean timeout; /// Is the qbox visible boolean qvisible; /// Is this a review session boolean reviews; public boolean update (Rect frect, Rect trect) { boolean changed; changed = false; if (this.frect == null || !this.frect.equals (frect)) { changed = true; this.frect = frect; } if (this.trect == null || !this.trect.equals (trect)) { changed = true; this.trect = trect; } return changed; } public void shift (int yofs) { if (frect != null) frect.offset (0, yofs); if (trect != null) trect.offset (0, yofs); } public boolean shallShow () { return visible && !timeout; } } /** * A JS bridge that handles the answer text-box events. */ private class JSListener { /** * Called by {@link LocalIMEKeyboard#JS_INIT_TRIGGERS} when the form changes its position. * @param fleft form top-left X coordinate * @param ftop form top-left Y coordinate * @param fright form bottom-right X coordinate * @param fbottom form bottom-right Y coordinate * @param tleft textbox top-left X coordinate * @param ttop textbox top-left Y coordinate * @param tright textbox bottom-right X coordinate * @param tbottom textbox bottom-right Y coordinate */ @JavascriptInterface public void replace (int sequence, int fleft, int ftop, int fright, int fbottom, int tleft, int ttop, int tright, int tbottom) { fleft = (int) TypedValue.applyDimension (TypedValue.COMPLEX_UNIT_DIP, fleft, dm); fright = (int) TypedValue.applyDimension (TypedValue.COMPLEX_UNIT_DIP, fright, dm); ftop = (int) TypedValue.applyDimension (TypedValue.COMPLEX_UNIT_DIP, ftop, dm); fbottom = (int) TypedValue.applyDimension (TypedValue.COMPLEX_UNIT_DIP, fbottom, dm); tleft = (int) TypedValue.applyDimension (TypedValue.COMPLEX_UNIT_DIP, tleft, dm); tright = (int) TypedValue.applyDimension (TypedValue.COMPLEX_UNIT_DIP, tright, dm); ttop = (int) TypedValue.applyDimension (TypedValue.COMPLEX_UNIT_DIP, ttop, dm); tbottom = (int) TypedValue.applyDimension (TypedValue.COMPLEX_UNIT_DIP, tbottom, dm); new JSListenerShow (sequence, new Rect (fleft, ftop, fright, fbottom), new Rect (tleft, ttop, tright, tbottom)); } /** * Called when the question changes. * @param qtype the type of question (reading vs meaning) */ @JavascriptInterface public void newQuestion (String qtype, int level) { imel.translate (qtype.equals ("reading")); new JSListenerSetClass (level); } @JavascriptInterface public void overrideQuestion (int sequence, String radical, String kanji, String vocab, int left, int top, int right, int bottom, String size) { WaniKaniItem.Type type; String name; int xsize; left = (int) TypedValue.applyDimension (TypedValue.COMPLEX_UNIT_DIP, left, dm); right = (int) TypedValue.applyDimension (TypedValue.COMPLEX_UNIT_DIP, right, dm); top = (int) TypedValue.applyDimension (TypedValue.COMPLEX_UNIT_DIP, top, dm); bottom = (int) TypedValue.applyDimension (TypedValue.COMPLEX_UNIT_DIP, bottom, dm); if (radical != null) { type = WaniKaniItem.Type.RADICAL; name = radical; } else if (kanji != null) { type = WaniKaniItem.Type.KANJI; name = kanji; } else if (vocab != null) { type = WaniKaniItem.Type.VOCABULARY; name = vocab; } else { type = null; name = null; } try { if (size.endsWith ("px")) xsize = Integer.parseInt (size.substring (0, size.length () - 2)); else xsize = 0; } catch (NumberFormatException e) { xsize = 0; } new JSListenerShowQuestion (sequence, type, name, new Rect (left, top, right, bottom), xsize); } /** * Called when the text box changes its class * @param clazz the new CSS class * @param reviews if we are doing reviews */ @JavascriptInterface public void setClass (String clazz, boolean reviews) { new JSListenerSetClass (clazz, reviews); } /** * Called when a full CSS class and contents synchronization needs to be done. * @param correct * @param incorrect * @param text * @param reviews if we are doing reviews */ @JavascriptInterface public void sync (boolean correct, boolean incorrect, String text, boolean reviews) { //boolean needMenuUpdate; // Unused? //needMenuUpdate = reviews != bpos.reviews; bpos.reviews = reviews; // Also unused? //Log.d("aralox", "css sync called. correct: "+correct+" incorrect: "+incorrect+" text: '" + text +"' reviews: " + reviews); if (correct) { new JSListenerSetClass("correct", reviews); // No case where both correct and incorrect are true, so dont need to handle. } else if (incorrect) { new JSListenerSetClass("incorrect", reviews); } else { // Both correct and incorrect are false => answer was ignored. @Aralox // Blank text for the first element. // This only applies to reviews, as cannot ignore wrong answers in lessons (also causes issue #54). if (reviews) { if (!text.equals("")) { //Log.d("aralox", "set class to ignored. Blank text: " + (text.equals(""))); new JSListenerSetClass("WKO_ignored", reviews); } } else { // There are only 2 situations where correct, incorrect and reviews are all False: // 1. Start of a lesson quiz // 2. Tabbed out and back into the app during a lesson quiz // In situation 2, the text in the textbox is not saved (for whatever reason, even though it could be useful), but in situation 1, // the text is carried over from the previous quiz. Since this behaviour is undesired, we forcefully blank the text here. // In the future, if we figure out a way to preserve answer text on tabbing out, it would be best to not blank the text here // but instead do it after the 'start lesson quiz' button is clicked or on some other event that signals start of lesson quiz. text = ""; } } new JSSetText (text); } /** * Called when the text box should be shown */ @JavascriptInterface public void showKeyboard () { bpos.visible = true; new JSHideShow (bpos.shallShow ()); } /** * Called when the text box should be hidden */ @JavascriptInterface public void hideKeyboard () { bpos.visible = false; new JSHideShow (bpos.shallShow ()); } /** * Called when the timeout div is shown or hidden. Need to catch this event to hide * and show the windows as well. * @param enabled if the div is shown */ @JavascriptInterface public void timeout (boolean enabled) { bpos.timeout = enabled; new JSHideShow (bpos.shallShow ()); } /** * Re-enables the edit box, because the event has been delivered. */ @JavascriptInterface () public void unfreeze () { frozen = false; } @JavascriptInterface () public void refreshWKLO () { if (PrefManager.getLessonOrder()) wv.js (ifLessons (LessonOrder.JS_REFRESH_CODE)); } /** * Called by LocalIMEKeyboard.replace(), to appropriately call ew.requestFocus() if we are not editing a note/synonym. Added by @Aralox. * @param noteBeingEdited return value of javascript which checks if note/synonym being edited (true/false). */ @JavascriptInterface public void requestFocusIfSafe (String noteBeingEdited) { //Log.d("aralox", "received " + noteBeingEdited); if (noteBeingEdited.equals("false")) { //Log.d("aralox", "note not being edited. calling ew.requestFocus()"); // Need to run EditText.requestFocus() on main thread, not UI thread. Technique from https://stackoverflow.com/a/11125271/1072869 Handler mainHandler = new Handler(Looper.getMainLooper()); Runnable myRunnable = new Runnable() { @Override public void run() { //Log.d("aralox", "calling EditText.requestFocus()"); ew.requestFocus(); } }; mainHandler.post(myRunnable); } //else Log.d("aralox", "note being edited. not calling ew.requestFocus()"); } } /** * A JS condition that evaluates to true if we are reviewing, false if we are in the lessons module */ private static final String JS_REVIEWS_P = "(document.getElementById (\"quiz\") == null)"; /** * The javascript triggers. They are installed when the keyboard is shown. */ private static final String JS_INIT_TRIGGERS = "window.wknSequence = 1;" + "window.wknReplace = function () {" + " var form, frect, txt, trect, button, brect;" + " form = document.getElementById (\"answer-form\");" + " txt = document.getElementById (\"user-response\");" + " button = form.getElementsByTagName (\"button\") [0];" + " frect = form.getBoundingClientRect ();" + " trect = txt.getBoundingClientRect ();" + " brect = button.getBoundingClientRect ();" + " wknJSListener.replace (window.wknSequence, frect.left, frect.top, frect.right, frect.bottom," + " trect.left, trect.top, brect.left, trect.bottom);" + " window.wknSequence++;" + "};" + "window.wknOverrideQuestion = function () {" + " var item, question, rect, style;" + " item = $.jStorage.get (\"currentItem\");" + " question = document.getElementById (\"character\");" + " if (question.getElementsByTagName (\"span\").length > 0)" + " question = question.getElementsByTagName (\"span\") [0];" + " rect = question.getBoundingClientRect ();" + " style = window.getComputedStyle (question, null);" + " wknJSListener.overrideQuestion (window.wknSequence," + " item.rad ? item.rad : null," + " item.kan ? item.kan : null," + " item.voc ? item.voc : null, " + " rect.left, rect.top, rect.right, rect.bottom," + " style.getPropertyValue(\"font-size\"));" + " window.wknSequence++;" + "};" + "window.wknNewQuestion = function (entry, type) {" + WebReviewActivity.getHideLinkCode() + // Added by @Aralox, to hook onto new question event. " var qtype, e, item;" + " qtype = $.jStorage.get (\"questionType\");" + " window.wknReplace ();" + " if (" + JS_REVIEWS_P + ") " + " window.wknOverrideQuestion ();" + " if ($(\"#character\").hasClass (\"vocabulary\")) {" + " e = $(\"#character span\");" + " e.text(e.first().text().replace (/〜/g, \"~\")); " + // JQuery will automatically call this on every element retrieved by query // // Added by @Aralox, use these lines to print the page HTML, for debugging. // " console.log('document 2: ');" + // " doclines = $('body').html().split('\\n');" + // " for (var di = 0; di < doclines.length; di++) { console.log(doclines[di]); } " + " }" + " item = $.jStorage.get (\"currentItem\");" + " wknJSListener.newQuestion (qtype, item.srs);" + "};" + "$.jStorage.listenKeyChange (\"currentItem\", window.wknNewQuestion);" + "var oldAddClass = jQuery.fn.addClass;" + "jQuery.fn.addClass = function () {" + " var res;" + " res = oldAddClass.apply (this, arguments);" + " if (this.selector == \"#answer-form fieldset\")" + " wknJSListener.setClass (arguments [0], " + JS_REVIEWS_P + "); " + " if (arguments [0] == \"hidden\" && " + " (this.selector == \"#screen-quiz-ready\" || " + " this.selector == \"#screen-lesson-ready\" || " + " this.selector == \"#screen-lesson-done\" || " + " this.selector == \"#screen-time-out\"))" + " wknJSListener.timeout (false);" + " if (arguments [0] == \"hidden\" && " + " (this.selector == \"#screen-lesson-ready\"))" + " wknJSListener.refreshWKLO ();" + " return res;" + "};" + "var oldRemoveClass = jQuery.fn.removeClass;" + "jQuery.fn.removeClass = function () {" + " var res;" + " res = oldRemoveClass.apply (this, arguments);" + " if (arguments [0] == \"hidden\" && " + " (this.selector == \"#screen-quiz-ready\" || " + " this.selector == \"#screen-lesson-ready\" || " + " this.selector == \"#screen-lesson-done\" || " + " this.selector == \"#screen-time-out\"))" + " wknJSListener.timeout (true);" + " return res;" + "};" + "window.wknNewQuiz = function (entry, type) {" + " var qtype, e;" + " qtype = $.jStorage.get (\"l/questionType\");" + " window.wknReplace ();" + " if ($(\"#main-info\").hasClass (\"vocabulary\")) {" + " e = $(\"#character\");" + " e.text (e.text ().replace (/〜/g, \"~\")); " + " }" + " if ($(\"#quiz\").is (\":visible\"))" + " wknJSListener.newQuestion (qtype, -1);" + "};" + "$.jStorage.listenKeyChange (\"l/currentQuizItem\", window.wknNewQuiz);" + "var oldShow = jQuery.fn.show;" + "var oldHide = jQuery.fn.hide;" + "jQuery.fn.show = function () {" + " var res;" + " res = oldShow.apply (this, arguments);" + " if (this.selector == \"#quiz\")" + " wknJSListener.showKeyboard (); " + " if (this.selector == \"#timeout\") " + " wknJSListener.timeout (true);" + " return res;" + "};" + "jQuery.fn.hide = function () {" + " var res;" + " res = oldHide.apply (this, arguments);" + " if (this.selector == \"#quiz\")" + " wknJSListener.hideKeyboard (); " + " if (this.selector == \"#timeout\") " + " wknJSListener.timeout (false);" + " return res;" + "};" + "if (" + JS_REVIEWS_P + ") {" + " wknJSListener.showKeyboard ();" + " wknJSListener.timeout ($(\"#timeout\").is (\":visible\"));" + " window.wknNewQuestion ();" + "} else if ($(\"#quiz\").is (\":visible\")) {" + " window.wknNewQuiz ();" + " wknJSListener.showKeyboard ();" + " wknJSListener.timeout (" + " $(\"#screen-quiz-ready\").is (\":visible\") || " + " $(\"#screen-lesson-ready\").is (\":visible\") || " + " $(\"#screen-lesson-done\").is (\":visible\") || " + " $(\"#screen-time-out\").is (\":visible\")" + " ); " + "} else {" + " wknJSListener.hideKeyboard ();" + " wknJSListener.timeout (false);" + "}" + "if (typeof idleTime === \"object\") {" + // An explicit reset when entering an answer would be much better, " idleTime.view = function() { };" + // but the class does not expose it "}" + "var form, tbox;" + "form = $(\"#answer-form fieldset\");" + "form.css ('visibility', 'hidden');" + "tbox = $(\"#user-response\");" + "wknJSListener.sync (form.hasClass (\"correct\"), form.hasClass (\"incorrect\"), " + " tbox.val (), " + JS_REVIEWS_P + ");" + // Note: Don't need to print HTML, this involves android stuff, not HTML. // // Added by @Aralox, use these lines to print the page HTML, for debugging. // " console.log('(aralox) Document HTML: ');" + // " doclines = $('body').html().split('\\n');" + // " for (var di = 0; di < doclines.length; di++) { console.log(doclines[di]); } " + ""; /** * Uninstalls the triggers, when the keyboard is hidden */ private static final String JS_STOP_TRIGGERS = "var form;" + "form = $(\"#answer-form fieldset\");" + "form.css ('visibility','visible');" + "$.jStorage.stopListening (\"currentItem\", window.wknNewQuestion);" + "$.jStorage.stopListening (\"l/currentQuizItem\", window.wknNewQuiz);"; private static final String JS_UPDATE_POSITION = "if (window.wknReplace != null) { " + " window.wknReplace ();" + " if (" + JS_REVIEWS_P + ")" + " window.wknOverrideQuestion ();" + "}"; /** * Clicks the "next" button. Similar to {@link #JS_INJECT_ANSWER}, but it does * not touch the user-response field */ private static final String JS_ENTER = MistakeDelay.injectAnswer( "$(\"#answer-form button\").click ();") + "wknJSListener.unfreeze ();"; /** * Injects an answer into the HTML text box and clickes the "next" button. */ private static final String JS_INJECT_ANSWER = "$(\"#user-response\").val (\"%s\");" + JS_ENTER; private static final String JS_OVERRIDE = "window.wknOverrideQuestion ();"; private static final String JS_INFO_POPUP = "$('#option-item-info').removeClass ();" + "$('#option-item-info span').click ();"; private static final String JS_SHOW_QUESTION = "$('#character span').css ('visibility', '%s');"; private static final String JS_LESSONS_MUTE = "window.wkAutoplay = $.jStorage.get (\"l/audioAutoplay\"); " + "$.jStorage.set(\"l/audioAutoplay\",false);"; private static final String JS_LESSONS_UNMUTE = "if (window.wkAutoplay != null)" + " $.jStorage.set(\"l/audioAutoplay\",window.wkAutoplay);"; private static final String JS_REVIEWS_MUTE = "window.wkAutoplay = audioAutoplay;" + "audioAutoplay = false;"; private static final String JS_REVIEWS_UNMUTE = "if (window.wkAutoplay != null) " + " audioAutoplay = window.wkAutoplay;"; /// Parent activity WebReviewActivity wav; /// Internal browser FocusWebView wv; /// The manager, used to popup the keyboard when needed InputMethodManager imm; /// The IME JapaneseIME ime; /// The view that is placed on top of the answer form View divw; /// The edit text EditText ew; /// The question TextView qvw; /// The handler of all the ime events IMEListener imel; /// Bridge between JS and the main class JSListener jsl; /// Display metrics, needed to translate HTML coordinates into pixels DisplayMetrics dm; /// The next button Button next; /// The current box position and state BoxPosition bpos; /// The SRS view View srsv; int correctFG, incorrectFG, ignoredFG; int correctBG, incorrectBG, ignoredBG; Map<Integer, Integer> srsCols; EnumMap<WaniKaniItem.Type, Integer> cmap; WaniKaniImprove wki; boolean isWKIEnabled; private static final String PREFIX = LocalIMEKeyboard.class + "."; private static final String PREF_FONT_OVERRIDE = PREFIX + "PREF_FONT_OVERRIDE"; /// Set if the ignore button must be shown, because the answer is incorrect boolean canIgnore; boolean isMuted; /// Is the text box frozen because it is waiting for a class change boolean frozen; /// Is the text box disabled boolean editable; /// Was suggestion disabled last time we checked? boolean disableSuggestions; /// Was romaji enabled last time we checked? boolean romaji; /// Last Sequence number received from JS int lastSequence; /// Last scheduled position task UpdatePositionTask lpt; /// Use hw keyboard boolean hwkeyb; /// Default orientation int orientation; /** * Constructor * @param wav parent activity * @param wv the integrated browser */ public LocalIMEKeyboard (WebReviewActivity wav, FocusWebView wv) { Resources res; this.wav = wav; this.wv = wv; editable = true; bpos = new BoxPosition (); imm = (InputMethodManager) wav.getSystemService (Context.INPUT_METHOD_SERVICE); disableSuggestions = PrefManager.getNoSuggestion() | PrefManager.getRomaji(); dm = wav.getResources ().getDisplayMetrics (); ime = new JapaneseIME (); ew = (EditText) wav.findViewById (R.id.ime); divw = wav.findViewById (R.id.ime_div); imel = new IMEListener (); ew.addTextChangedListener (imel); ew.setInputType (InputType.TYPE_CLASS_TEXT); ew.setOnEditorActionListener (imel); ew.setGravity (Gravity.CENTER); ew.setImeActionLabel (">>", EditorInfo.IME_ACTION_DONE); ew.setImeOptions (EditorInfo.IME_ACTION_DONE); qvw = (TextView) wav.findViewById (R.id.txt_question_override); next = (Button) wav.findViewById (R.id.ime_next); next.setOnClickListener (imel); srsv = wav.findViewById (R.id.v_srs); jsl = new JSListener (); wv.addJavascriptInterface (jsl, "wknJSListener"); wki = new WaniKaniImprove (wav, wv); wv.registerListener (new WebViewListener ()); res = wav.getResources (); correctFG = res.getColor (R.color.correctfg); incorrectFG = res.getColor (R.color.incorrectfg); ignoredFG = res.getColor (R.color.ignoredfg); setupSRSColors (res); correctBG = R.drawable.card_reviews_edittext_correct; incorrectBG = R.drawable.card_reviews_edittext_incorrect; ignoredBG = R.drawable.card_reviews_edittext_ignored; cmap = new EnumMap<WaniKaniItem.Type, Integer> (WaniKaniItem.Type.class); cmap.put (WaniKaniItem.Type.RADICAL, res.getColor (R.color.wanikani_radical)); cmap.put (WaniKaniItem.Type.KANJI, res.getColor (R.color.wanikani_kanji)); cmap.put (WaniKaniItem.Type.VOCABULARY, res.getColor (R.color.wanikani_vocabulary)); } private void setupSRSColors (Resources res) { int c; srsCols = new Hashtable<Integer, Integer> (); c = R.drawable.oval_apprentice; srsCols.put (1, c); srsCols.put (2, c); srsCols.put (3, c); srsCols.put(4, c); c = R.drawable.oval_guru; srsCols.put (5, c); srsCols.put(6, c); c = R.drawable.oval_master; srsCols.put (7, c); c = R.drawable.oval_enlightened; srsCols.put (8, c); c = R.drawable.oval_burned; srsCols.put(9, c); } /** * Shows the keyboard. Actually we only inject the triggers: if the javascript code detects that * the form must be shown, it call the appropriate event listeners. * @param hasEnter */ @Override public void show (boolean hasEnter) { hwkeyb = false; // We no longer have a preference for this lastSequence = -1; wv.js (JS_INIT_TRIGGERS); orientation = wav.getRequestedOrientation (); if (PrefManager.getReviewOrder()) wv.js (ifReviews (ReviewOrder.JS_CODE)); if (PrefManager.getLessonOrder()) wv.js (ifLessons (LessonOrder.JS_CODE)); wv.js (MistakeDelay.JS_INIT); isWKIEnabled = PrefManager.getWaniKaniImprove(); if (isWKIEnabled) wki.initPage (); setInputType (); wv.enableFocus (); } /** * Called when the keyboard should be iconized. This does never happen when using the * native keyboard, so this method does nothing * @param hasEnter if the keyboard contains the enter key. If unset, the hide button is shown instead */ @Override public void iconize (boolean hasEnter) { /* empty */ } /** * Hides the keyboard and uninstalls the triggers. */ @Override public void hide () { reset(); bpos.visible = false; wv.js(JS_STOP_TRIGGERS); if (isWKIEnabled) wki.uninitPage (); if (PrefManager.getPortraitMode()) wav.setRequestedOrientation(orientation); if (PrefManager.getReviewOrder()) wv.js (ifReviews (ReviewOrder.JS_UNINIT_CODE)); if (PrefManager.getLessonOrder()) wv.js (ifReviews (LessonOrder.JS_UNINIT_CODE)); } /** * Hides the keyboard. */ @Override public void reset () { imm.hideSoftInputFromWindow (ew.getWindowToken (), 0); divw.setVisibility (View.GONE); showQuestionPatch(false); } @Override public void setMute (boolean m) { this.isMuted = m; /* Doesn't seem to work wv.js (ifLessons (m ? JS_LESSONS_MUTE : JS_LESSONS_UNMUTE) + ifReviews (m ? JS_REVIEWS_MUTE : JS_REVIEWS_UNMUTE)); */ // Use the old method instead: AudioManager am; am = (AudioManager) wav.getSystemService(Context.AUDIO_SERVICE); am.setStreamMute (AudioManager.STREAM_MUSIC, m); } public void showQuestionPatch (boolean enable) { qvw.setVisibility (enable ? View.VISIBLE : View.GONE); bpos.qvisible = enable; wv.js (String.format (JS_SHOW_QUESTION, enable ? "hidden" : "visible")); } /** * Called when the HTML textbox is moved. It moves the edittext as well * @param frect the form rect * @param trect text textbox rect */ protected void replace (Rect frect, Rect trect) { RelativeLayout.LayoutParams rparams; if (bpos.update (frect, trect)) { Utils utils = new Utils(wav); rparams = (RelativeLayout.LayoutParams) divw.getLayoutParams (); rparams.topMargin = frect.top + (int) utils.pxFromDp(8); rparams.leftMargin = frect.left + (int) utils.pxFromDp(8); rparams.width = LayoutParams.MATCH_PARENT; divw.setLayoutParams (rparams); } if (bpos.shallShow ()) { divw.setVisibility (View.VISIBLE); // This function (replace()) is called whenever the page is scrolled during reviews, e.g. when looking at the answer. // This function usually just calls ew.requestFocus(), which switches keyboard focus to the answer textbox, which is not // a javascript element but an android object (which is why pure JS solutions do not work for this bug). // This refocusing causes problems as the page is resized/scrolled whenever the reading/meaning note (and occasionally synonym edit box) is opened. // This problem is fixed by using JS to check the HTML to see if we are currently editing a note, then only calling requestFocus if we are not. // jsl (JSListener)'s requestFocusIfSafe() will call ew.requestFocus() if we aren't editing a note or synonym. wv.loadUrl("javascript:wknJSListener.requestFocusIfSafe(" + "$('#item-info').css('display') != 'none' && " + "( (typeof $('form>fieldset>textarea')[0] !== 'undefined') || (typeof $('.user-synonyms-add-form')[0] !== 'undefined') )" + ")"); if (hwkeyb) imm.hideSoftInputFromWindow (ew.getWindowToken (), 0); } } private void adjustWidth (TextView view, RelativeLayout.LayoutParams params, String text) { Paint tpaint; tpaint = view.getPaint (); params.width = (int) Math.max (params.width, tpaint.measureText (text)); } protected void showQuestion (WaniKaniItem.Type type, String name, Rect rect, int size) { RelativeLayout.LayoutParams params; Typeface jtf; if (!getOverrideFonts ()) return; params = (RelativeLayout.LayoutParams) qvw.getLayoutParams (); params.topMargin = rect.top - 5; params.leftMargin = rect.left - 5; params.height = rect.height () + 10; params.width = rect.width () + 10; params.addRule (RelativeLayout.CENTER_HORIZONTAL); if (!name.endsWith (".png")) { qvw.setTextSize (size); //qvw.setBackgroundColor (cmap.get (type)); qvw.setTextColor (Color.WHITE); qvw.setText (name); jtf = new Fonts().getKanjiFont(wav); qvw.setTypeface (jtf); adjustWidth (qvw, params, name); showQuestionPatch (jtf != null); } else showQuestionPatch (false); qvw.setLayoutParams (params); } /** * Called when the webview is scrolled. It moves the edittext as well * @param dx the horizontal displacement * @param dy the vertical displacement */ protected void scroll (int dx, int dy) { RelativeLayout.LayoutParams rparams; rparams = (RelativeLayout.LayoutParams) divw.getLayoutParams (); rparams.topMargin -= dy; divw.setLayoutParams (rparams); rparams = (RelativeLayout.LayoutParams) qvw.getLayoutParams (); rparams.topMargin -= dy; qvw.setLayoutParams (rparams); bpos.shift (-dy); } /** * Called when the HTML textbox changes class. It changes the edit text accordingly. * @param clazz the CSS class * @param reviews if we are reviewing */ public void setClass (String clazz, boolean reviews) { if (clazz.equals ("correct")) { enableIgnoreButton (false); disable (correctFG, correctBG); } else if (clazz.equals ("incorrect")) { if (PrefManager.getAutoPopup()) errorPopup (); if (PrefManager.getMistakeDelay()) wv.js (MistakeDelay.JS_MISTAKE); enableIgnoreButton (reviews && PrefManager.getIgnoreButton()); disable (incorrectFG, incorrectBG); } else if (clazz.equals ("WKO_ignored")) { enableIgnoreButton (false); disable (ignoredFG, ignoredBG); } } /** * Show the info popup */ protected void errorPopup () { imm.hideSoftInputFromWindow (ew.getWindowToken (), 0); wv.js (JS_INFO_POPUP); } /** * Called when the HTML textbox goes back to the default CSS class. */ public void unsetClass (int level) { Integer res; enableIgnoreButton (false); enable (); wv.js (ifReviews (WaniKaniKunOn.JS_CODE)); res = srsCols.get (level); if (PrefManager.getSRSIndication() && res != null) { srsv.setVisibility (View.VISIBLE); srsv.setBackgroundResource(res); } else srsv.setVisibility (View.GONE); } /** * Called to set/unset ignore button visbility * @param enable if it should be visible */ public void enableIgnoreButton (boolean enable) { canIgnore = enable; wav.updateCanIgnore (); } /** * Disables editing on the edit text * @param fg foreground color * @param backgroundResource background resource */ private void disable (int fg, int backgroundResource) { editable = false; ew.setTextColor (fg); next.setTextColor(fg); divw.setBackgroundResource(backgroundResource); ew.setCursorVisible(false); } /** * Enable editing on the edit text. It also clears the contents. */ private void enable () { editable = true; ew.setText (""); ew.setTextColor(wav.getResources().getColor(R.color.text_gray)); next.setTextColor(wav.getResources().getColor(R.color.text_gray)); divw.setBackgroundResource(R.drawable.card_reviews_edittext); ew.setCursorVisible(true); if (!hwkeyb) imm.showSoftInput (ew, 0); ew.requestFocus (); } /** * Runs the ignore button script. */ @Override public void ignore () { wv.js (IgnoreButton.JS_CODE); } /** * Tells if the ignore button can be shown * @return <tt>true</tt> if the current answer is wrong */ @Override public boolean canIgnore () { return canIgnore; } public static String ifReviews (String js) { return "if (" + JS_REVIEWS_P + ") {" + js + "}"; } public static String ifLessons (String js) { return "if (!" + JS_REVIEWS_P + ") {" + js + "}"; } @Override public boolean getOverrideFonts () { SharedPreferences prefs; if (!canOverrideFonts ()) return false; prefs = PreferenceManager.getDefaultSharedPreferences (wav); return prefs.getBoolean (PREF_FONT_OVERRIDE, false); } protected boolean toggleFontOverride () { SharedPreferences prefs; boolean ans; prefs = PreferenceManager.getDefaultSharedPreferences (wav); ans = !prefs.getBoolean (PREF_FONT_OVERRIDE, false); prefs.edit ().putBoolean (PREF_FONT_OVERRIDE, ans).commit (); return ans; } @Override public boolean canOverrideFonts () { return true; } @Override public void overrideFonts () { if (toggleFontOverride ()) wv.js (JS_OVERRIDE); else showQuestionPatch (false); } protected void setInputType () { boolean tren, trjp; trjp = PrefManager.getNoSuggestion(); tren = PrefManager.getRomaji(); if (trjp || tren) { disableSuggestions = true; if ((imel.translate && trjp) || (!imel.translate && tren)) ew.setInputType (InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD); else ew.setInputType (InputType.TYPE_CLASS_TEXT); } else if (disableSuggestions) { // This strange trick is to avoid messing with inputtype when not needed disableSuggestions = false; ew.setInputType (InputType.TYPE_CLASS_TEXT); } } private boolean updateSequence (int sequence) { if (sequence > lastSequence) { lastSequence = sequence; return true; } else return false; } }