/****************************************************************************************
* Copyright (c) 2011 Kostas Spyropoulos <inigo.aldana@gmail.com> *
* Copyright (c) 2014 Bruno Romero de Azevedo <brunodea@inf.ufsm.br> *
* Copyright (c) 2014–15 Roland Sieker <ospalh@gmail.com> *
* Copyright (c) 2015 Timothy Rae <perceptualchaos2@gmail.com> *
* Copyright (c) 2016 Mark Carter <mark@marcardar.com> *
* *
* 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/>. *
****************************************************************************************/
// TODO: implement own menu? http://www.codeproject.com/Articles/173121/Android-Menus-My-Way
package com.ichi2.anki;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Color;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.os.SystemClock;
import android.os.Vibrator;
import android.support.v4.content.ContextCompat;
import android.support.v4.view.GestureDetectorCompat;
import android.support.v7.app.ActionBar;
import android.text.ClipboardManager;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.SpannedString;
import android.text.TextUtils;
import android.text.style.UnderlineSpan;
import android.util.TypedValue;
import android.view.GestureDetector.SimpleOnGestureListener;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.webkit.JsResult;
import android.webkit.WebChromeClient;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Button;
import android.widget.Chronometer;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.RelativeLayout;
import android.widget.TextView;
import com.afollestad.materialdialogs.MaterialDialog;
import com.afollestad.materialdialogs.util.TypefaceHelper;
import com.ichi2.anim.ActivityTransitionAnimation;
import com.ichi2.anim.ViewAnimation;
import com.ichi2.anki.receiver.SdCardReceiver;
import com.ichi2.anki.reviewer.ReviewerExtRegistry;
import com.ichi2.async.DeckTask;
import com.ichi2.compat.CompatHelper;
import com.ichi2.libanki.Card;
import com.ichi2.libanki.Collection;
import com.ichi2.libanki.Consts;
import com.ichi2.libanki.Note;
import com.ichi2.libanki.Sched;
import com.ichi2.libanki.Sound;
import com.ichi2.libanki.Utils;
import com.ichi2.themes.HtmlColors;
import com.ichi2.themes.Themes;
import com.ichi2.utils.DiffEngine;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.net.URLDecoder;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import timber.log.Timber;
public abstract class AbstractFlashcardViewer extends NavigationDrawerActivity {
/**
* Whether to save the content of the card in the file system.
* <p>
* Set this to true for debugging only.
*/
private static final boolean SAVE_CARD_CONTENT = false;
/**
* Result codes that are returned when this activity finishes.
*/
public static final int RESULT_DEFAULT = 50;
public static final int RESULT_NO_MORE_CARDS = 52;
public static final int RESULT_EDIT_CARD_RESET = 53;
public static final int RESULT_DECK_CLOSED = 55;
/**
* Available options performed by other activities.
*/
public static final int EDIT_CURRENT_CARD = 0;
public static final int DECK_OPTIONS = 1;
/** Constant for class attribute signaling answer */
public static final String ANSWER_CLASS = "answer";
/** Constant for class attribute signaling question */
public static final String QUESTION_CLASS = "question";
/** Max size of the font for dynamic calculation of font size */
private static final int DYNAMIC_FONT_MAX_SIZE = 14;
/** Min size of the font for dynamic calculation of font size */
private static final int DYNAMIC_FONT_MIN_SIZE = 3;
private static final int DYNAMIC_FONT_FACTOR = 5;
public static final int EASE_1 = 1;
public static final int EASE_2 = 2;
public static final int EASE_3 = 3;
public static final int EASE_4 = 4;
/** Maximum time in milliseconds to wait before accepting answer button presses. */
private static final int DOUBLE_TAP_IGNORE_THRESHOLD = 200;
/** Time to wait in milliseconds before resuming fullscreen mode **/
protected static final int INITIAL_HIDE_DELAY = 200;
/** Regex pattern used in removing tags from text before diff */
private static final Pattern sSpanPattern = Pattern.compile("</?span[^>]*>");
private static final Pattern sBrPattern = Pattern.compile("<br\\s?/?>");
// Type answer patterns
private static final Pattern sTypeAnsPat = Pattern.compile("\\[\\[type:(.+?)\\]\\]");
private static final Pattern sTypeAnsTyped = Pattern.compile("typed=([^&]*)");
/** to be sent to and from the card editor */
private static Card sEditorCard;
protected static boolean sDisplayAnswer = false;
private boolean mTtsInitialized = false;
private boolean mReplayOnTtsInit = false;
protected static final int MENU_DISABLED = 3;
/**
* Broadcast that informs us when the sd card is about to be unmounted
*/
private BroadcastReceiver mUnmountReceiver = null;
/**
* Variables to hold preferences
*/
private boolean mPrefHideDueCount;
private boolean mPrefShowETA;
private boolean mShowTimer;
protected boolean mPrefWhiteboard;
private boolean mShowTypeAnswerField;
private boolean mInputWorkaround;
private boolean mLongClickWorkaround;
private int mPrefFullscreenReview;
private int mCardZoom;
private int mImageZoom;
private int mRelativeButtonSize;
private boolean mDoubleScrolling;
private boolean mScrollingButtons;
private boolean mGesturesEnabled;
// Android WebView
protected boolean mSpeakText;
protected boolean mDisableClipboard = false;
protected boolean mNightMode = false;
private boolean mPrefSafeDisplay;
protected boolean mPrefUseTimer;
private boolean mPrefCenterVertically;
protected boolean mUseInputTag;
// Preferences from the collection
private boolean mShowNextReviewTime;
private boolean mShowRemainingCardCount;
// Answer card & cloze deletion variables
private String mTypeCorrect = null;
// The correct answer in the compare to field if answer should be given by learner. Null if no answer is expected.
private String mTypeInput = ""; // What the learner actually typed
private String mTypeFont = ""; // Font face of the compare to field
private int mTypeSize = 0; // Its font size
private String mTypeWarning;
private boolean mIsSelecting = false;
private boolean mTouchStarted = false;
private boolean mInAnswer = false;
private boolean mAnswerSoundsAdded = false;
private String mCardTemplate;
/**
* Variables to hold layout objects that we need to update or handle events for
*/
private View mMainLayout;
private View mLookUpIcon;
private FrameLayout mCardContainer;
private WebView mCard;
private WebView mNextCard;
private FrameLayout mCardFrame;
private FrameLayout mTouchLayer;
private TextView mTextBarNew;
private TextView mTextBarLearn;
private TextView mTextBarReview;
private TextView mChosenAnswer;
protected TextView mNext1;
protected TextView mNext2;
protected TextView mNext3;
protected TextView mNext4;
private Button mFlipCard;
protected EditText mAnswerField;
protected TextView mEase1;
protected TextView mEase2;
protected TextView mEase3;
protected TextView mEase4;
protected LinearLayout mFlipCardLayout;
protected LinearLayout mEase1Layout;
protected LinearLayout mEase2Layout;
protected LinearLayout mEase3Layout;
protected LinearLayout mEase4Layout;
protected RelativeLayout mTopBarLayout;
private Chronometer mCardTimer;
protected Whiteboard mWhiteboard;
private ClipboardManager mClipboard;
protected Card mCurrentCard;
private int mCurrentEase;
private boolean mButtonHeightSet = false;
private boolean mConfigurationChanged = false;
private int mShowChosenAnswerLength = 2000;
/**
* A record of the last time the "show answer" or ease buttons were pressed. We keep track
* of this time to ignore accidental button presses.
*/
private long mLastClickTime;
/**
* Whether to use a single {@link WebView} and update its content.
* <p>
* If false, we will instead use two WebViews and switch them when changing the content. This is needed because of a
* bug in some versions of Android.
*/
private boolean mUseQuickUpdate = false;
/**
* Swipe Detection
*/
private GestureDetectorCompat gestureDetector;
private boolean mIsXScrolling = false;
private boolean mIsYScrolling = false;
/**
* Gesture Allocation
*/
private int mGestureSwipeUp;
private int mGestureSwipeDown;
private int mGestureSwipeLeft;
private int mGestureSwipeRight;
private int mGestureDoubleTap;
private int mGestureTapLeft;
private int mGestureTapRight;
private int mGestureTapTop;
private int mGestureTapBottom;
private int mGestureLongclick;
/**
* Custom button allocation
*/
protected Map<Integer, Integer> mCustomButtons = new HashMap<>();
protected static final int GESTURE_NOTHING = 0;
private static final int GESTURE_SHOW_ANSWER = 1;
private static final int GESTURE_ANSWER_EASE1 = 2;
private static final int GESTURE_ANSWER_EASE2 = 3;
private static final int GESTURE_ANSWER_EASE3 = 4;
private static final int GESTURE_ANSWER_EASE4 = 5;
private static final int GESTURE_ANSWER_RECOMMENDED = 6;
private static final int GESTURE_ANSWER_BETTER_THAN_RECOMMENDED = 7;
private static final int GESTURE_UNDO = 8;
public static final int GESTURE_EDIT = 9;
protected static final int GESTURE_MARK = 10;
protected static final int GESTURE_LOOKUP = 11;
private static final int GESTURE_BURY_CARD = 12;
private static final int GESTURE_SUSPEND_CARD = 13;
protected static final int GESTURE_DELETE = 14;
protected static final int GESTURE_PLAY_MEDIA = 16;
protected static final int GESTURE_EXIT = 17;
private static final int GESTURE_BURY_NOTE = 18;
private static final int GESTURE_SUSPEND_NOTE = 19;
private Spanned mCardContent;
private String mBaseUrl;
private int mFadeDuration = 300;
protected Sched mSched;
private ReviewerExtRegistry mExtensions;
private Sound mSoundPlayer = new Sound();
private long mUseTimerDynamicMS;
// private int zEase;
// ----------------------------------------------------------------------------
// LISTENERS
// ----------------------------------------------------------------------------
private Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
mSoundPlayer.stopSounds();
mSoundPlayer.playSound((String) msg.obj, null);
}
};
private final Handler longClickHandler = new Handler();
private final Runnable longClickTestRunnable = new Runnable() {
@Override
public void run() {
Timber.i("AbstractFlashcardViewer:: onEmulatedLongClick");
// Show hint about lookup function if dictionary available and Webview version supports text selection
if (!mDisableClipboard && Lookup.isAvailable() && CompatHelper.isHoneycomb()) {
String lookupHint = getResources().getString(R.string.lookup_hint);
UIUtils.showThemedToast(AbstractFlashcardViewer.this, lookupHint, false);
}
Vibrator vibratorManager = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
vibratorManager.vibrate(50);
longClickHandler.postDelayed(startLongClickAction, 300);
}
};
private final Runnable startLongClickAction = new Runnable() {
@Override
public void run() {
executeCommand(mGestureLongclick);
}
};
// Handler for the "show answer" button
private View.OnClickListener mFlipCardListener = new View.OnClickListener() {
@Override
public void onClick(View view) {
Timber.i("AbstractFlashcardViewer:: Show answer button pressed");
// Ignore what is most likely an accidental double-tap.
if (SystemClock.elapsedRealtime() - mLastClickTime < DOUBLE_TAP_IGNORE_THRESHOLD) {
return;
}
mLastClickTime = SystemClock.elapsedRealtime();
mTimeoutHandler.removeCallbacks(mShowAnswerTask);
displayCardAnswer();
}
};
private View.OnClickListener mSelectEaseHandler = new View.OnClickListener() {
@Override
public void onClick(View view) {
// Ignore what is most likely an accidental double-tap.
if (SystemClock.elapsedRealtime() - mLastClickTime < DOUBLE_TAP_IGNORE_THRESHOLD) {
return;
}
mLastClickTime = SystemClock.elapsedRealtime();
mTimeoutHandler.removeCallbacks(mShowQuestionTask);
switch (view.getId()) {
case R.id.flashcard_layout_ease1:
Timber.i("AbstractFlashcardViewer:: EASE_1 pressed");
answerCard(EASE_1);
break;
case R.id.flashcard_layout_ease2:
Timber.i("AbstractFlashcardViewer:: EASE_2 pressed");
answerCard(EASE_2);
break;
case R.id.flashcard_layout_ease3:
Timber.i("AbstractFlashcardViewer:: EASE_3 pressed");
answerCard(EASE_3);
break;
case R.id.flashcard_layout_ease4:
Timber.i("AbstractFlashcardViewer:: EASE_4 pressed");
answerCard(EASE_4);
break;
default:
mCurrentEase = 0;
}
}
};
private View.OnTouchListener mGestureListener = new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (gestureDetector.onTouchEvent(event)) {
return true;
}
if (!mDisableClipboard && !mLongClickWorkaround) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mTouchStarted = true;
longClickHandler.postDelayed(longClickTestRunnable, 800);
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_MOVE:
if (mTouchStarted) {
longClickHandler.removeCallbacks(longClickTestRunnable);
mTouchStarted = false;
}
break;
default:
longClickHandler.removeCallbacks(longClickTestRunnable);
mTouchStarted = false;
}
}
try {
if (event != null) {
mCard.dispatchTouchEvent(event);
}
} catch (NullPointerException e) {
Timber.e(e, "Error on dispatching touch event");
if (mInputWorkaround) {
Timber.e(e, "Error on using InputWorkaround");
AnkiDroidApp.getSharedPrefs(getBaseContext()).edit().putBoolean("inputWorkaround", false).commit();
AbstractFlashcardViewer.this.finishWithoutAnimation();
}
}
return false;
}
};
private View.OnLongClickListener mLongClickListener = new View.OnLongClickListener() {
@Override
public boolean onLongClick(View view) {
if (mIsSelecting) {
return false;
}
Timber.i("AbstractFlashcardViewer:: onLongClick");
Vibrator vibratorManager = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
vibratorManager.vibrate(50);
longClickHandler.postDelayed(startLongClickAction, 300);
return true;
}
};
protected DeckTask.TaskListener mDismissCardHandler = new DeckTask.TaskListener() {
@Override
public void onPreExecute() {
}
@Override
public void onProgressUpdate(DeckTask.TaskData... values) {
mAnswerCardHandler.onProgressUpdate(values);
}
@Override
public void onPostExecute(DeckTask.TaskData result) {
if (!result.getBoolean()) {
closeReviewer(DeckPicker.RESULT_DB_ERROR, false);
}
mAnswerCardHandler.onPostExecute(result);
}
@Override
public void onCancelled() {
}
};
private DeckTask.TaskListener mUpdateCardHandler = new DeckTask.TaskListener() {
private boolean mNoMoreCards;
@Override
public void onPreExecute() {
showProgressBar();
}
@Override
public void onProgressUpdate(DeckTask.TaskData... values) {
boolean cardChanged = false;
if (mCurrentCard != values[0].getCard()) {
/*
* Before updating mCurrentCard, we check whether it is changing or not. If the current card changes,
* then we need to display it as a new card, without showing the answer.
*/
sDisplayAnswer = false;
cardChanged = true; // Keep track of that so we can run a bit of new-card code
}
mCurrentCard = values[0].getCard();
if (mCurrentCard == null) {
// If the card is null means that there are no more cards scheduled for review.
mNoMoreCards = true;
showProgressBar();
return;
}
if (mPrefWhiteboard && mWhiteboard != null) {
mWhiteboard.clear();
}
if (sDisplayAnswer) {
mSoundPlayer.resetSounds(); // load sounds from scratch, to expose any edit changes
mAnswerSoundsAdded = false; // causes answer sounds to be reloaded
generateQuestionSoundList(); // questions must be intentionally regenerated
displayCardAnswer();
} else {
if (cardChanged) {
updateTypeAnswerInfo();
}
displayCardQuestion();
mCurrentCard.startTimer();
initTimer();
}
hideProgressBar();
}
@Override
public void onPostExecute(DeckTask.TaskData result) {
if (!result.getBoolean()) {
// RuntimeException occured on update cards
closeReviewer(DeckPicker.RESULT_DB_ERROR, false);
return;
}
if (mNoMoreCards) {
closeReviewer(RESULT_NO_MORE_CARDS, true);
}
}
@Override
public void onCancelled() {
}
};
protected DeckTask.TaskListener mAnswerCardHandler = new DeckTask.TaskListener() {
private boolean mNoMoreCards;
@Override
public void onPreExecute() {
blockControls();
}
@Override
public void onProgressUpdate(DeckTask.TaskData... values) {
Resources res = getResources();
if (mSched == null) {
// TODO: proper testing for restored activity
finishWithoutAnimation();
return;
}
mCurrentCard = values[0].getCard();
if (mCurrentCard == null) {
// If the card is null means that there are no more cards scheduled for review.
mNoMoreCards = true;
} else {
// Start reviewing next card
updateTypeAnswerInfo();
hideProgressBar();
AbstractFlashcardViewer.this.unblockControls();
AbstractFlashcardViewer.this.displayCardQuestion();
}
// Since reps are incremented on fetch of next card, we will miss counting the
// last rep since there isn't a next card. We manually account for it here.
if (mNoMoreCards) {
mSched.setReps(mSched.getReps() + 1);
}
Long[] elapsed = getCol().timeboxReached();
if (elapsed != null) {
// AnkiDroid is always counting one rep ahead, so we decrement it before displaying
// it to the user.
int nCards = elapsed[1].intValue() - 1;
int nMins = elapsed[0].intValue() / 60;
String mins = res.getQuantityString(R.plurals.timebox_reached_minutes, nMins, nMins);
String timeboxMessage = res.getQuantityString(R.plurals.timebox_reached, nCards, nCards, mins);
UIUtils.showThemedToast(AbstractFlashcardViewer.this, timeboxMessage, true);
getCol().startTimebox();
}
}
@Override
public void onPostExecute(DeckTask.TaskData result) {
if (!result.getBoolean()) {
// RuntimeException occured on answering cards
closeReviewer(DeckPicker.RESULT_DB_ERROR, false);
return;
}
// Check for no more cards before session complete. If they are both true, no more cards will take
// precedence when returning to study options.
if (mNoMoreCards) {
closeReviewer(RESULT_NO_MORE_CARDS, true);
}
// set the correct mark/unmark icon on action bar
refreshActionBar();
findViewById(R.id.root_layout).requestFocus();
}
@Override
public void onCancelled() {
}
};
/**
* Extract type answer/cloze text and font/size
*/
private void updateTypeAnswerInfo() {
mTypeCorrect = null;
mTypeInput = "";
String q = mCurrentCard.q(false);
Matcher m = sTypeAnsPat.matcher(q);
int clozeIdx = 0;
if (!m.find()) {
return;
}
String fld = m.group(1);
// if it's a cloze, extract data
if (fld.startsWith("cloze:", 0)) {
// get field and cloze position
clozeIdx = mCurrentCard.getOrd() + 1;
fld = fld.split(":")[1];
}
// loop through fields for a match
try {
JSONArray ja = mCurrentCard.model().getJSONArray("flds");
for (int i = 0; i < ja.length(); i++) {
String name = (String) (ja.getJSONObject(i).get("name"));
if (name.equals(fld)) {
mTypeCorrect = mCurrentCard.note().getItem(name);
if (clozeIdx != 0) {
// narrow to cloze
mTypeCorrect = contentForCloze(mTypeCorrect, clozeIdx);
}
mTypeFont = (String) (ja.getJSONObject(i).get("font"));
mTypeSize = (int) (ja.getJSONObject(i).get("size"));
break;
}
}
} catch (JSONException e) {
throw new RuntimeException(e);
}
if (mTypeCorrect == null) {
if (clozeIdx != 0) {
mTypeWarning = getResources().getString(R.string.empty_card_warning);
} else {
mTypeWarning = getResources().getString(R.string.unknown_type_field_warning, fld);
}
} else if (mTypeCorrect.equals("")) {
mTypeCorrect = null;
} else {
mTypeWarning = null;
}
}
/**
* Format question field when it contains typeAnswer or clozes. If there was an error during type text extraction, a
* warning is displayed
*
* @param buf The question text
* @return The formatted question text
*/
private String typeAnsQuestionFilter(String buf) {
Matcher m = sTypeAnsPat.matcher(buf);
if (mTypeWarning != null) {
return m.replaceFirst(mTypeWarning);
}
StringBuilder sb = new StringBuilder();
if (mUseInputTag) {
// These functions are definde in the JavaScript file assets/scripts/card.js. We get the text back in
// shouldOverrideUrlLoading() in createWebView() in this file.
sb.append("<center>\n<input type=text name=typed id=typeans onfocus=\"taFocus();\" " +
"onblur=\"taBlur(this);\" onKeyPress=\"return taKey(this, event)\" autocomplete=\"off\" ");
// We have to watch out. For the preview we don’t know the font or font size. Skip those there. (Anki
// desktop just doesn’t show the input tag there. Do it with standard values here instead.)
if (mTypeFont != null && !TextUtils.isEmpty(mTypeFont) && mTypeSize > 0) {
sb.append("style=\"font-family: '").append(mTypeFont).append("'; font-size: ")
.append(Integer.toString(mTypeSize)).append("px;\" ");
}
sb.append(">\n</center>\n");
} else {
sb.append("<span id=typeans class=\"typePrompt");
if (!mShowTypeAnswerField) {
sb.append(" typeOff");
}
sb.append("\">........</span>");
}
return m.replaceAll(sb.toString());
}
/**
* Fill the placeholder for the type comparison. Show the correct answer, and the comparison if appropriate.
*
* @param buf The answer text
* @param userAnswer Text typed by the user, or empty.
* @param correctAnswer The correct answer, taken from the note.
* @return The formatted answer text
*/
private String typeAnsAnswerFilter(String buf, String userAnswer, String correctAnswer) {
Matcher m = sTypeAnsPat.matcher(buf);
DiffEngine diffEngine = new DiffEngine();
StringBuilder sb = new StringBuilder();
sb.append("<div");
if (!mUseInputTag && !mShowTypeAnswerField) {
sb.append(" class=\"typeOff\"");
}
sb.append("><code id=typeans>");
if (!TextUtils.isEmpty(userAnswer)) {
// The user did type something.
if (userAnswer.equals(correctAnswer)) {
// and it was right.
sb.append(DiffEngine.wrapGood(correctAnswer));
sb.append("\u2714"); // Heavy check mark
} else {
// Answer not correct.
// Only use the complex diff code when needed, that is when we have some typed text that is not
// exactly the same as the correct text.
String[] diffedStrings = diffEngine.diffedHtmlStrings(correctAnswer, userAnswer);
// We know we get back two strings.
sb.append(diffedStrings[0]);
sb.append("<br>↓<br>");
sb.append(diffedStrings[1]);
}
} else {
if (mShowTypeAnswerField) {
sb.append(DiffEngine.wrapMissing(correctAnswer));
} else {
sb.append(correctAnswer);
}
}
sb.append("</code></div>");
return m.replaceAll(sb.toString());
}
/**
* Return the correct answer to use for {{type::cloze::NN}} fields.
*
* @param txt The field text with the clozes
* @param idx The index of the cloze to use
* @return A string with a comma-separeted list of unique cloze strings with the corret index.
*/
private String contentForCloze(String txt, int idx) {
Pattern re = Pattern.compile("\\{\\{c" + idx + "::(.+?)\\}\\}");
Matcher m = re.matcher(txt);
Set<String> matches = new LinkedHashSet<>();
// LinkedHashSet: make entries appear only once, like Anki desktop (see also issue #2208), and keep the order
// they appear in.
String groupOne;
int colonColonIndex = -1;
while (m.find()) {
groupOne = m.group(1);
colonColonIndex = groupOne.indexOf("::");
if (colonColonIndex > -1) {
// Cut out the hint.
groupOne = groupOne.substring(0, colonColonIndex);
}
matches.add(groupOne);
}
// Now do what the pythonic ", ".join(matches) does in a tricky way
String prefix = "";
StringBuilder resultBuilder = new StringBuilder();
for (String match : matches) {
resultBuilder.append(prefix);
resultBuilder.append(match);
prefix = ", ";
}
return resultBuilder.toString();
}
private Handler mTimerHandler = new Handler();
private Runnable removeChosenAnswerText = new Runnable() {
@Override
public void run() {
mChosenAnswer.setText("");
}
};
protected int mWaitAnswerSecond;
protected int mWaitQuestionSecond;
protected int getDefaultEase() {
if (getCol().getSched().answerButtons(mCurrentCard) == 4) {
return EASE_3;
} else {
return EASE_2;
}
}
// ----------------------------------------------------------------------------
// ANDROID METHODS
// ----------------------------------------------------------------------------
@Override
protected void onCreate(Bundle savedInstanceState) {
Timber.d("onCreate()");
// Create the extensions as early as possible, so that they can be offered events.
mExtensions = new ReviewerExtRegistry(getBaseContext());
restorePreferences();
super.onCreate(savedInstanceState);
setContentView(getContentViewAttr(mPrefFullscreenReview));
View mainView = findViewById(android.R.id.content);
initNavigationDrawer(mainView);
// Open collection asynchronously
startLoadingCollection();
}
protected int getContentViewAttr(int fullscreenMode) {
return R.layout.reviewer;
}
@ Override
public void onConfigurationChanged(Configuration config) {
// called when screen rotated, etc, since recreating the Webview is too expensive
super.onConfigurationChanged(config);
refreshActionBar();
}
protected abstract void setTitle();
// Finish initializing the activity after the collection has been correctly loaded
@Override
protected void onCollectionLoaded(Collection col) {
super.onCollectionLoaded(col);
mSched = col.getSched();
mBaseUrl = Utils.getBaseUrl(col.getMedia().dir());
registerExternalStorageListener();
mUseQuickUpdate = shouldUseQuickUpdate();
initLayout();
setTitle();
if (!mDisableClipboard) {
clipboardSetText("");
}
// Load the template for the card
try {
mCardTemplate = Utils.convertStreamToString(getAssets().open("card_template.html"));
} catch (IOException e) {
e.printStackTrace();
}
// Initialize text-to-speech. This is an asynchronous operation.
if (mSpeakText) {
ReadText.initializeTts(this);
}
// Initialize dictionary lookup feature
Lookup.initialize(this);
updateScreenCounts();
supportInvalidateOptionsMenu();
}
// Saves deck each time Reviewer activity loses focus
@Override
protected void onPause() {
super.onPause();
Timber.d("onPause()");
mTimeoutHandler.removeCallbacks(mShowAnswerTask);
mTimeoutHandler.removeCallbacks(mShowQuestionTask);
longClickHandler.removeCallbacks(longClickTestRunnable);
longClickHandler.removeCallbacks(startLongClickAction);
pauseTimer();
mSoundPlayer.stopSounds();
// Prevent loss of data in Cookies
CompatHelper.getCompat().flushWebViewCookies();
}
@Override
protected void onResume() {
super.onResume();
resumeTimer();
// Set the context for the Sound manager
mSoundPlayer.setContext(new WeakReference<Activity>(this));
// Reset the activity title
setTitle();
updateScreenCounts();
selectNavigationItem(-1);
}
@Override
protected void onDestroy() {
super.onDestroy();
Timber.d("onDestroy()");
if (mSpeakText) {
ReadText.releaseTts();
}
if (mUnmountReceiver != null) {
unregisterReceiver(mUnmountReceiver);
}
// WebView.destroy() should be called after the end of use
// http://developer.android.com/reference/android/webkit/WebView.html#destroy()
mCardFrame.removeAllViews();
destroyWebView(mCard);
destroyWebView(mNextCard);
}
@Override
public void onBackPressed() {
if (isDrawerOpen()) {
super.onBackPressed();
} else {
Timber.i("Back key pressed");
closeReviewer(RESULT_DEFAULT, false);
}
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
// Hardware buttons for scrolling
if (keyCode == KeyEvent.KEYCODE_PAGE_UP) {
mCard.pageUp(false);
if (mDoubleScrolling) {
mCard.pageUp(false);
}
return true;
}
if (keyCode == KeyEvent.KEYCODE_PAGE_DOWN) {
mCard.pageDown(false);
if (mDoubleScrolling) {
mCard.pageDown(false);
}
return true;
}
if (mScrollingButtons && keyCode == KeyEvent.KEYCODE_PICTSYMBOLS) {
mCard.pageUp(false);
if (mDoubleScrolling) {
mCard.pageUp(false);
}
return true;
}
if (mScrollingButtons && keyCode == KeyEvent.KEYCODE_SWITCH_CHARSET) {
mCard.pageDown(false);
if (mDoubleScrolling) {
mCard.pageDown(false);
}
return true;
}
return super.onKeyDown(keyCode, event);
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
if (mAnswerField != null && !mAnswerField.isFocused()) {
if (!sDisplayAnswer) {
if (keyCode == KeyEvent.KEYCODE_SPACE || keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) {
displayCardAnswer();
return true;
}
}
}
return super.onKeyUp(keyCode, event);
}
// These three methods use a deprecated API - they should be updated to possibly use its more modern version.
protected boolean clipboardHasText() {
return mClipboard != null && mClipboard.hasText();
}
private void clipboardSetText(CharSequence text) {
if (mClipboard != null) {
try {
mClipboard.setText(text);
} catch (Exception e) {
// https://code.google.com/p/ankidroid/issues/detail?id=1746
// https://code.google.com/p/ankidroid/issues/detail?id=1820
// Some devices or external applications make the clipboard throw exceptions. If this happens, we
// must disable it or AnkiDroid will crash if it tries to use it.
Timber.e("Clipboard error. Disabling text selection setting.");
mDisableClipboard = true;
}
}
}
/**
* Returns the text stored in the clipboard or the empty string if the clipboard is empty or contains something that
* cannot be convered to text.
*
* @return the text in clipboard or the empty string.
*/
private CharSequence clipboardGetText() {
CharSequence text = mClipboard != null ? mClipboard.getText() : null;
return text != null ? text : "";
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode == DeckPicker.RESULT_DB_ERROR) {
closeReviewer(DeckPicker.RESULT_DB_ERROR, false);
}
if (resultCode == DeckPicker.RESULT_MEDIA_EJECTED) {
finishNoStorageAvailable();
}
/* Reset the schedule and reload the latest card off the top of the stack if required.
The card could have been rescheduled, the deck could have changed, or a change of
note type could have lead to the card being deleted */
if (data != null && data.hasExtra("reloadRequired")) {
getCol().getSched().reset();
DeckTask.launchDeckTask(DeckTask.TASK_TYPE_ANSWER_CARD, mAnswerCardHandler,
new DeckTask.TaskData(null, 0));
}
if (requestCode == EDIT_CURRENT_CARD) {
if (resultCode == RESULT_OK) {
// content of note was changed so update the note and current card
Timber.i("AbstractFlashcardViewer:: Saving card...");
DeckTask.launchDeckTask(DeckTask.TASK_TYPE_UPDATE_FACT, mUpdateCardHandler,
new DeckTask.TaskData(mCurrentCard, true));
} else if (resultCode == RESULT_CANCELED && !(data!=null && data.hasExtra("reloadRequired"))) {
// nothing was changed by the note editor so just redraw the card
fillFlashcard();
}
} else if (requestCode == DECK_OPTIONS && resultCode == RESULT_OK) {
getCol().getSched().reset();
DeckTask.launchDeckTask(DeckTask.TASK_TYPE_ANSWER_CARD, mAnswerCardHandler,
new DeckTask.TaskData(null, 0));
}
if (!mDisableClipboard) {
clipboardSetText("");
}
}
// ----------------------------------------------------------------------------
// CUSTOM METHODS
// ----------------------------------------------------------------------------
// Get the did of the parent deck (ignoring any subdecks)
protected long getParentDid() {
long deckID;
try {
deckID = getCol().getDecks().current().getLong("id");
} catch (JSONException e) {
throw new RuntimeException(e);
}
return deckID;
}
public GestureDetectorCompat getGestureDetector() {
return gestureDetector;
}
/**
* Show/dismiss dialog when sd card is ejected/remounted (collection is saved by SdCardReceiver)
*/
private void registerExternalStorageListener() {
if (mUnmountReceiver == null) {
mUnmountReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (intent.getAction().equals(SdCardReceiver.MEDIA_EJECT)) {
finishWithoutAnimation();
}
}
};
IntentFilter iFilter = new IntentFilter();
iFilter.addAction(SdCardReceiver.MEDIA_EJECT);
registerReceiver(mUnmountReceiver, iFilter);
}
}
private void pauseTimer() {
if (mCurrentCard != null) {
mCurrentCard.stopTimer();
}
// We also stop the UI timer so it doesn't trigger the tick listener while paused. Letting
// it run would trigger the time limit condition (red, stopped timer) in the background.
if (mCardTimer != null) {
mCardTimer.stop();
}
}
private void resumeTimer() {
if (mCurrentCard != null) {
// Resume the card timer first. It internally accounts for the time gap between
// suspend and resume.
mCurrentCard.resumeTimer();
// Then update and resume the UI timer. Set the base time as if the timer had started
// timeTaken() seconds ago.
mCardTimer.setBase(SystemClock.elapsedRealtime() - mCurrentCard.timeTaken());
// Don't start the timer if we have already reached the time limit or it will tick over
if ((SystemClock.elapsedRealtime() - mCardTimer.getBase()) < mCurrentCard.timeLimit()) {
mCardTimer.start();
}
}
}
protected void undo() {
if (getCol().undoAvailable()) {
DeckTask.launchDeckTask(DeckTask.TASK_TYPE_UNDO, mAnswerCardHandler);
}
}
private void finishNoStorageAvailable() {
AbstractFlashcardViewer.this.setResult(DeckPicker.RESULT_MEDIA_EJECTED);
finishWithoutAnimation();
}
protected boolean editCard() {
Intent editCard = new Intent(AbstractFlashcardViewer.this, NoteEditor.class);
editCard.putExtra(NoteEditor.EXTRA_CALLER, NoteEditor.CALLER_REVIEWER);
sEditorCard = mCurrentCard;
startActivityForResultWithAnimation(editCard, EDIT_CURRENT_CARD, ActivityTransitionAnimation.LEFT);
return true;
}
protected void generateQuestionSoundList() {
mSoundPlayer.addSounds(mBaseUrl, mCurrentCard.qSimple(), Sound.SOUNDS_QUESTION);
}
protected void lookUpOrSelectText() {
if (clipboardHasText()) {
Timber.d("Clipboard has text = " + clipboardHasText());
lookUp();
} else {
selectAndCopyText();
}
}
private boolean lookUp() {
mLookUpIcon.setVisibility(View.GONE);
mIsSelecting = false;
if (Lookup.lookUp(clipboardGetText().toString())) {
clipboardSetText("");
}
return true;
}
private void showLookupButtonIfNeeded() {
if (!mDisableClipboard && mClipboard != null) {
if (clipboardGetText().length() != 0 && Lookup.isAvailable() && mLookUpIcon.getVisibility() != View.VISIBLE) {
mLookUpIcon.setVisibility(View.VISIBLE);
enableViewAnimation(mLookUpIcon, ViewAnimation.fade(ViewAnimation.FADE_IN, mFadeDuration, 0));
} else if (mLookUpIcon.getVisibility() == View.VISIBLE) {
mLookUpIcon.setVisibility(View.GONE);
enableViewAnimation(mLookUpIcon, ViewAnimation.fade(ViewAnimation.FADE_OUT, mFadeDuration, 0));
}
}
}
private void hideLookupButton() {
if (!mDisableClipboard && mLookUpIcon.getVisibility() != View.GONE) {
mLookUpIcon.setVisibility(View.GONE);
enableViewAnimation(mLookUpIcon, ViewAnimation.fade(ViewAnimation.FADE_OUT, mFadeDuration, 0));
clipboardSetText("");
}
}
protected void showDeleteNoteDialog() {
Resources res = getResources();
new MaterialDialog.Builder(this)
.title(res.getString(R.string.delete_card_title))
.iconAttr(R.attr.dialogErrorIcon)
.content(String.format(res.getString(R.string.delete_note_message),
Utils.stripHTML(mCurrentCard.q(true))))
.positiveText(res.getString(R.string.dialog_positive_delete))
.negativeText(res.getString(R.string.dialog_cancel))
.callback(new MaterialDialog.ButtonCallback() {
@Override
public void onPositive(MaterialDialog dialog) {
Timber.i("AbstractFlashcardViewer:: OK button pressed to delete note %d", mCurrentCard.getNid());
dismiss(Collection.DismissType.DELETE_NOTE);
}
})
.build().show();
}
private int getRecommendedEase(boolean easy) {
try {
switch (mSched.answerButtons(mCurrentCard)) {
case 2:
return EASE_2;
case 3:
return easy ? EASE_3 : EASE_2;
case 4:
return easy ? EASE_4 : EASE_3;
default:
return 0;
}
} catch (RuntimeException e) {
AnkiDroidApp.sendExceptionReport(e, "AbstractReviewer-getRecommendedEase");
closeReviewer(DeckPicker.RESULT_DB_ERROR, true);
return 0;
}
}
protected void answerCard(int ease) {
if (mInAnswer) {
return;
}
mIsSelecting = false;
hideLookupButton();
int buttonNumber = getCol().getSched().answerButtons(mCurrentCard);
// Detect invalid ease for current card (e.g. by using keyboard shortcut or gesture).
if (buttonNumber < ease) {
return;
}
// Set the dots appearing below the toolbar
switch (ease) {
case EASE_1:
mChosenAnswer.setText("\u2022");
mChosenAnswer.setTextColor(ContextCompat.getColor(this, R.color.material_red_500));
break;
case EASE_2:
mChosenAnswer.setText("\u2022\u2022");
mChosenAnswer.setTextColor(ContextCompat.getColor(this, buttonNumber == 4 ?
R.color.material_blue_grey_600:
R.color.material_green_500));
break;
case EASE_3:
mChosenAnswer.setText("\u2022\u2022\u2022");
mChosenAnswer.setTextColor(ContextCompat.getColor(this, buttonNumber == 4 ?
R.color.material_green_500 :
R.color.material_light_blue_500));
break;
case EASE_4:
mChosenAnswer.setText("\u2022\u2022\u2022\u2022");
mChosenAnswer.setTextColor(ContextCompat.getColor(this, R.color.material_light_blue_500));
break;
}
// remove chosen answer hint after a while
mTimerHandler.removeCallbacks(removeChosenAnswerText);
mTimerHandler.postDelayed(removeChosenAnswerText, mShowChosenAnswerLength);
mSoundPlayer.stopSounds();
mCurrentEase = ease;
DeckTask.launchDeckTask(DeckTask.TASK_TYPE_ANSWER_CARD, mAnswerCardHandler,
new DeckTask.TaskData(mCurrentCard, mCurrentEase));
}
// Set the content view to the one provided and initialize accessors.
protected void initLayout() {
mMainLayout = findViewById(R.id.main_layout);
mCardContainer = (FrameLayout) findViewById(R.id.flashcard_frame);
mTopBarLayout = (RelativeLayout) findViewById(R.id.top_bar);
mCardFrame = (FrameLayout) findViewById(R.id.flashcard);
mTouchLayer = (FrameLayout) findViewById(R.id.touch_layer);
mTouchLayer.setOnTouchListener(mGestureListener);
if (!mDisableClipboard && mLongClickWorkaround) {
mTouchLayer.setOnLongClickListener(mLongClickListener);
}
if (!mDisableClipboard) {
mClipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
}
mCardFrame.removeAllViews();
// Initialize swipe
gestureDetector = new GestureDetectorCompat(this, new MyGestureDetector());
mEase1 = (TextView) findViewById(R.id.ease1);
mEase1.setTypeface(TypefaceHelper.get(this, "Roboto-Medium"));
mEase1Layout = (LinearLayout) findViewById(R.id.flashcard_layout_ease1);
mEase1Layout.setOnClickListener(mSelectEaseHandler);
mEase2 = (TextView) findViewById(R.id.ease2);
mEase2.setTypeface(TypefaceHelper.get(this, "Roboto-Medium"));
mEase2Layout = (LinearLayout) findViewById(R.id.flashcard_layout_ease2);
mEase2Layout.setOnClickListener(mSelectEaseHandler);
mEase3 = (TextView) findViewById(R.id.ease3);
mEase3.setTypeface(TypefaceHelper.get(this, "Roboto-Medium"));
mEase3Layout = (LinearLayout) findViewById(R.id.flashcard_layout_ease3);
mEase3Layout.setOnClickListener(mSelectEaseHandler);
mEase4 = (TextView) findViewById(R.id.ease4);
mEase4.setTypeface(TypefaceHelper.get(this, "Roboto-Medium"));
mEase4Layout = (LinearLayout) findViewById(R.id.flashcard_layout_ease4);
mEase4Layout.setOnClickListener(mSelectEaseHandler);
mNext1 = (TextView) findViewById(R.id.nextTime1);
mNext2 = (TextView) findViewById(R.id.nextTime2);
mNext3 = (TextView) findViewById(R.id.nextTime3);
mNext4 = (TextView) findViewById(R.id.nextTime4);
mNext1.setTypeface(TypefaceHelper.get(this, "Roboto-Regular"));
mNext2.setTypeface(TypefaceHelper.get(this, "Roboto-Regular"));
mNext3.setTypeface(TypefaceHelper.get(this, "Roboto-Regular"));
mNext4.setTypeface(TypefaceHelper.get(this, "Roboto-Regular"));
if (!mShowNextReviewTime) {
mNext1.setVisibility(View.GONE);
mNext2.setVisibility(View.GONE);
mNext3.setVisibility(View.GONE);
mNext4.setVisibility(View.GONE);
}
mFlipCard = (Button) findViewById(R.id.flip_card);
mFlipCard.setTypeface(TypefaceHelper.get(this, "Roboto-Medium"));
mFlipCardLayout = (LinearLayout) findViewById(R.id.flashcard_layout_flip);
mFlipCardLayout.setOnClickListener(mFlipCardListener);
if (!mButtonHeightSet && mRelativeButtonSize != 100) {
ViewGroup.LayoutParams params = mFlipCardLayout.getLayoutParams();
params.height = params.height * mRelativeButtonSize / 100;
params = mEase1Layout.getLayoutParams();
params.height = params.height * mRelativeButtonSize / 100;
params = mEase2Layout.getLayoutParams();
params.height = params.height * mRelativeButtonSize / 100;
params = mEase3Layout.getLayoutParams();
params.height = params.height * mRelativeButtonSize / 100;
params = mEase4Layout.getLayoutParams();
params.height = params.height * mRelativeButtonSize / 100;
mButtonHeightSet = true;
}
mTextBarNew = (TextView) findViewById(R.id.new_number);
mTextBarLearn = (TextView) findViewById(R.id.learn_number);
mTextBarReview = (TextView) findViewById(R.id.review_number);
if (!mShowRemainingCardCount) {
mTextBarNew.setVisibility(View.GONE);
mTextBarLearn.setVisibility(View.GONE);
mTextBarReview.setVisibility(View.GONE);
}
mCardTimer = (Chronometer) findViewById(R.id.card_time);
mChosenAnswer = (TextView) findViewById(R.id.choosen_answer);
mAnswerField = (EditText) findViewById(R.id.answer_field);
mLookUpIcon = findViewById(R.id.lookup_button);
mLookUpIcon.setVisibility(View.GONE);
mLookUpIcon.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View arg0) {
Timber.i("AbstractFlashcardViewer:: Lookup button pressed");
if (clipboardHasText()) {
lookUp();
}
}
});
initControls();
// Position answer buttons
String answerButtonsPosition = AnkiDroidApp.getSharedPrefs(this).getString(
getString(R.string.answer_buttons_position_preference),
"bottom"
);
LinearLayout answerArea = (LinearLayout) findViewById(R.id.bottom_area_layout);
RelativeLayout.LayoutParams answerAreaParams = (RelativeLayout.LayoutParams) answerArea.getLayoutParams();
RelativeLayout.LayoutParams cardContainerParams = (RelativeLayout.LayoutParams) mCardContainer.getLayoutParams();
switch (answerButtonsPosition) {
case "top":
cardContainerParams.addRule(RelativeLayout.BELOW, R.id.bottom_area_layout);
answerAreaParams.addRule(RelativeLayout.BELOW, R.id.top_bar);
answerArea.removeView(mAnswerField);
answerArea.addView(mAnswerField, 1);
break;
case "bottom":
cardContainerParams.addRule(RelativeLayout.ABOVE, R.id.bottom_area_layout);
cardContainerParams.addRule(RelativeLayout.BELOW, R.id.top_bar);
answerAreaParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM);
break;
}
answerArea.setLayoutParams(answerAreaParams);
mCardContainer.setLayoutParams(cardContainerParams);
}
@SuppressLint({"NewApi", "SetJavaScriptEnabled"})
// because of setDisplayZoomControls.
private WebView createWebView() {
WebView webView = new MyWebView(this);
webView.setWillNotCacheDrawing(true);
webView.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_OVERLAY);
if (CompatHelper.isHoneycomb()) {
// Disable the on-screen zoom buttons for API > 11
webView.getSettings().setDisplayZoomControls(false);
}
webView.getSettings().setBuiltInZoomControls(true);
webView.getSettings().setSupportZoom(true);
// Start at the most zoomed-out level
webView.getSettings().setLoadWithOverviewMode(true);
webView.getSettings().setJavaScriptEnabled(true);
webView.setWebChromeClient(new AnkiDroidWebChromeClient());
// Problems with focus and input tags is the reason we keep the old type answer mechanism for old Androids.
webView.setFocusableInTouchMode(mUseInputTag);
webView.setScrollbarFadingEnabled(true);
Timber.d("Focusable = %s, Focusable in touch mode = %s", webView.isFocusable(), webView.isFocusableInTouchMode());
webView.setWebViewClient(new WebViewClient() {
// Filter any links using the custom "playsound" protocol defined in Sound.java.
// We play sounds through these links when a user taps the sound icon.
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url.startsWith("playsound:")) {
// Send a message that will be handled on the UI thread.
Message msg = Message.obtain();
String soundPath = url.replaceFirst("playsound:", "");
msg.obj = soundPath;
mHandler.sendMessage(msg);
return true;
}
if (url.startsWith("file") || url.startsWith("data:")) {
return false; // Let the webview load files, i.e. local images.
}
if (url.startsWith("typeblurtext:")) {
// Store the text the javascript has send us…
mTypeInput = URLDecoder.decode(url.replaceFirst("typeblurtext:", ""));
// … and show the “SHOW ANSWER” button again.
mFlipCardLayout.setVisibility(View.VISIBLE);
return true;
}
if (url.startsWith("typeentertext:")) {
// Store the text the javascript has send us…
mTypeInput = URLDecoder.decode(url.replaceFirst("typeentertext:", ""));
// … and show the answer.
mFlipCardLayout.performClick();
return true;
}
if (url.equals("signal:typefocus")) {
// Hide the “SHOW ANSWER” button when the input has focus. The soft keyboard takes up enough space
// by itself.
mFlipCardLayout.setVisibility(View.GONE);
return true;
}
Intent intent = null;
try {
if (url.startsWith("intent:")) {
intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
} else if (url.startsWith("android-app:")) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) {
intent = Intent.parseUri(url, 0);
intent.setData(null);
intent.setPackage(Uri.parse(url).getHost());
} else {
intent = Intent.parseUri(url, Intent.URI_ANDROID_APP_SCHEME);
}
}
if (intent != null) {
if (getPackageManager().resolveActivity(intent, 0) == null) {
String packageName = intent.getPackage();
if (packageName == null) {
Timber.d("Not using resolved intent uri because not available: %s", intent);
intent = null;
} else {
Timber.d("Resolving intent uri to market uri because not available: %s", intent);
intent = new Intent(Intent.ACTION_VIEW,
Uri.parse("market://details?id=" + packageName));
if (getPackageManager().resolveActivity(intent, 0) == null) {
intent = null;
}
}
} else {
// https://developer.chrome.com/multidevice/android/intents says that we should remove this
intent.addCategory(Intent.CATEGORY_BROWSABLE);
}
}
} catch (Throwable t) {
Timber.w("Unable to parse intent uri: %s because: %s", url, t.getMessage());
}
if (intent == null) {
Timber.d("Opening external link \"%s\" with an Intent", url);
intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
} else {
Timber.d("Opening resolved external link \"%s\" with an Intent: %s", url, intent);
}
try {
startActivityWithoutAnimation(intent);
} catch (ActivityNotFoundException e) {
e.printStackTrace(); // Don't crash if the intent is not handled
}
return true;
}
// Run any post-load events in javascript that rely on the window being completely loaded.
@Override
public void onPageFinished(WebView view, String url) {
Timber.d("onPageFinished triggered");
view.loadUrl("javascript:onPageFinished();");
}
});
// Set transparent color to prevent flashing white when night mode enabled
webView.setBackgroundColor(Color.argb(1, 0, 0, 0));
return webView;
}
private void destroyWebView(WebView webView) {
if (webView != null) {
webView.stopLoading();
webView.setWebChromeClient(null);
webView.setWebViewClient(null);
webView.destroy();
}
}
protected void showEaseButtons() {
Resources res = getResources();
// hide flipcard button
mFlipCardLayout.setVisibility(View.GONE);
int buttonCount;
try {
buttonCount = mSched.answerButtons(mCurrentCard);
} catch (RuntimeException e) {
AnkiDroidApp.sendExceptionReport(e, "AbstractReviewer-showEaseButtons");
closeReviewer(DeckPicker.RESULT_DB_ERROR, true);
return;
}
// Set correct label and background resource for each button
// Note that it's necessary to set the resource dynamically as the ease2 / ease3 buttons
// (which libanki expects ease to be 2 and 3) can either be hard, good, or easy - depending on num buttons shown
final int[] background = Themes.getResFromAttr(this, new int [] {
R.attr.againButtonRef,
R.attr.hardButtonRef,
R.attr.goodButtonRef,
R.attr.easyButtonRef});
final int[] textColor = Themes.getColorFromAttr(this, new int [] {
R.attr.againButtonTextColor,
R.attr.hardButtonTextColor,
R.attr.goodButtonTextColor,
R.attr.easyButtonTextColor});
mEase1Layout.setVisibility(View.VISIBLE);
mEase1Layout.setBackgroundResource(background[0]);
mEase4Layout.setBackgroundResource(background[3]);
switch (buttonCount) {
case 2:
// Ease 2 is "good"
mEase2Layout.setVisibility(View.VISIBLE);
mEase2Layout.setBackgroundResource(background[2]);
mEase2.setText(R.string.ease_button_good);
mEase2.setTextColor(textColor[2]);
mNext2.setTextColor(textColor[2]);
mEase2Layout.requestFocus();
break;
case 3:
// Ease 2 is good
mEase2Layout.setVisibility(View.VISIBLE);
mEase2Layout.setBackgroundResource(background[2]);
mEase2.setText(R.string.ease_button_good);
mEase2.setTextColor(textColor[2]);
mNext2.setTextColor(textColor[2]);
// Ease 3 is easy
mEase3Layout.setVisibility(View.VISIBLE);
mEase3Layout.setBackgroundResource(background[3]);
mEase3.setText(R.string.ease_button_easy);
mEase3.setTextColor(textColor[3]);
mNext3.setTextColor(textColor[3]);
mEase2Layout.requestFocus();
break;
default:
mEase2Layout.setVisibility(View.VISIBLE);
// Ease 2 is "hard"
mEase2Layout.setVisibility(View.VISIBLE);
mEase2Layout.setBackgroundResource(background[1]);
mEase2.setText(R.string.ease_button_hard);
mEase2.setTextColor(textColor[1]);
mNext2.setTextColor(textColor[1]);
mEase2Layout.requestFocus();
// Ease 3 is good
mEase3Layout.setVisibility(View.VISIBLE);
mEase3Layout.setBackgroundResource(background[2]);
mEase3.setText(R.string.ease_button_good);
mEase3.setTextColor(textColor[2]);
mNext3.setTextColor(textColor[2]);
mEase4Layout.setVisibility(View.VISIBLE);
mEase3Layout.requestFocus();
}
// Show next review time
if (mShowNextReviewTime) {
mNext1.setText(mSched.nextIvlStr(this, mCurrentCard, 1));
mNext2.setText(mSched.nextIvlStr(this, mCurrentCard, 2));
if (buttonCount > 2) {
mNext3.setText(mSched.nextIvlStr(this, mCurrentCard, 3));
}
if (buttonCount > 3) {
mNext4.setText(mSched.nextIvlStr(this, mCurrentCard, 4));
}
}
}
protected void hideEaseButtons() {
mEase1Layout.setVisibility(View.GONE);
mEase2Layout.setVisibility(View.GONE);
mEase3Layout.setVisibility(View.GONE);
mEase4Layout.setVisibility(View.GONE);
mFlipCardLayout.setVisibility(View.VISIBLE);
if (typeAnswer()) {
mAnswerField.requestFocus();
} else {
mFlipCardLayout.requestFocus();
}
}
private void switchTopBarVisibility(int visible) {
if (mShowTimer) {
mCardTimer.setVisibility(visible);
}
if (mShowRemainingCardCount) {
mTextBarNew.setVisibility(visible);
mTextBarLearn.setVisibility(visible);
mTextBarReview.setVisibility(visible);
}
mChosenAnswer.setVisibility(visible);
}
protected void initControls() {
mCardFrame.setVisibility(View.VISIBLE);
if (mShowRemainingCardCount) {
mTextBarNew.setVisibility(View.VISIBLE);
mTextBarLearn.setVisibility(View.VISIBLE);
mTextBarReview.setVisibility(View.VISIBLE);
}
mChosenAnswer.setVisibility(View.VISIBLE);
mFlipCardLayout.setVisibility(View.VISIBLE);
mAnswerField.setVisibility(typeAnswer() ? View.VISIBLE : View.GONE);
mAnswerField.setOnEditorActionListener(new EditText.OnEditorActionListener() {
@Override
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
if (actionId == EditorInfo.IME_ACTION_DONE) {
displayCardAnswer();
return true;
}
return false;
}
});
mAnswerField.setOnKeyListener(new View.OnKeyListener() {
@Override
public boolean onKey(View v, int keyCode, KeyEvent event) {
if (event.getAction() == KeyEvent.ACTION_UP &&
(keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER)) {
displayCardAnswer();
return true;
}
return false;
}
});
}
protected SharedPreferences restorePreferences() {
SharedPreferences preferences = AnkiDroidApp.getSharedPrefs(getBaseContext());
mPrefHideDueCount = preferences.getBoolean("hideDueCount", false);
mPrefShowETA = preferences.getBoolean("showETA", true);
mUseInputTag = preferences.getBoolean("useInputTag", false) && (CompatHelper.getSdkVersion() >= 15);
mShowTypeAnswerField = (!preferences.getBoolean("writeAnswersDisable", false)) && !mUseInputTag;
// On newer Androids, ignore this setting, which sholud be hidden in the prefs anyway.
mDisableClipboard = preferences.getString("dictionary", "0").equals("0");
mLongClickWorkaround = preferences.getBoolean("textSelectionLongclickWorkaround", false);
// mDeckFilename = preferences.getString("deckFilename", "");
mNightMode = preferences.getBoolean("invertedColors", false);
mPrefFullscreenReview = Integer.parseInt(preferences.getString("fullscreenMode", "0"));
mCardZoom = preferences.getInt("cardZoom", 100);
mImageZoom = preferences.getInt("imageZoom", 100);
mRelativeButtonSize = preferences.getInt("answerButtonSize", 100);
mInputWorkaround = preferences.getBoolean("inputWorkaround", false);
mSpeakText = preferences.getBoolean("tts", false);
mPrefSafeDisplay = preferences.getBoolean("safeDisplay", false);
mPrefUseTimer = preferences.getBoolean("timeoutAnswer", false);
mWaitAnswerSecond = preferences.getInt("timeoutAnswerSeconds", 20);
mWaitQuestionSecond = preferences.getInt("timeoutQuestionSeconds", 60);
mScrollingButtons = preferences.getBoolean("scrolling_buttons", false);
mDoubleScrolling = preferences.getBoolean("double_scrolling", false);
mPrefCenterVertically = preferences.getBoolean("centerVertically", false);
mGesturesEnabled = AnkiDroidApp.initiateGestures(preferences);
if (mGesturesEnabled) {
mGestureSwipeUp = Integer.parseInt(preferences.getString("gestureSwipeUp", "9"));
mGestureSwipeDown = Integer.parseInt(preferences.getString("gestureSwipeDown", "0"));
mGestureSwipeLeft = Integer.parseInt(preferences.getString("gestureSwipeLeft", "8"));
mGestureSwipeRight = Integer.parseInt(preferences.getString("gestureSwipeRight", "17"));
mGestureDoubleTap = Integer.parseInt(preferences.getString("gestureDoubleTap", "7"));
mGestureTapLeft = Integer.parseInt(preferences.getString("gestureTapLeft", "3"));
mGestureTapRight = Integer.parseInt(preferences.getString("gestureTapRight", "6"));
mGestureTapTop = Integer.parseInt(preferences.getString("gestureTapTop", "12"));
mGestureTapBottom = Integer.parseInt(preferences.getString("gestureTapBottom", "2"));
mGestureLongclick = Integer.parseInt(preferences.getString("gestureLongclick", "11"));
}
mCustomButtons.put(R.id.action_undo, Integer.parseInt(preferences.getString("customButtonUndo", "2")));
mCustomButtons.put(R.id.action_mark_card, Integer.parseInt(preferences.getString("customButtonMarkCard", "2")));
mCustomButtons.put(R.id.action_edit, Integer.parseInt(preferences.getString("customButtonEditCard", "1")));
mCustomButtons.put(R.id.action_add_note_reviewer, Integer.parseInt(preferences.getString("customButtonAddCard", "3")));
mCustomButtons.put(R.id.action_replay, Integer.parseInt(preferences.getString("customButtonReplay", "1")));
mCustomButtons.put(R.id.action_clear_whiteboard, Integer.parseInt(preferences.getString("customButtonClearWhiteboard", "1")));
mCustomButtons.put(R.id.action_hide_whiteboard, Integer.parseInt(preferences.getString("customButtonShowHideWhiteboard", "2")));
mCustomButtons.put(R.id.action_select_tts, Integer.parseInt(preferences.getString("customButtonSelectTts", "0")));
mCustomButtons.put(R.id.action_open_deck_options, Integer.parseInt(preferences.getString("customButtonDeckOptions", "0")));
mCustomButtons.put(R.id.action_bury, Integer.parseInt(preferences.getString("customButtonBury", "0")));
mCustomButtons.put(R.id.action_suspend, Integer.parseInt(preferences.getString("customButtonSuspend", "0")));
mCustomButtons.put(R.id.action_delete, Integer.parseInt(preferences.getString("customButtonDelete", "0")));
if (mLongClickWorkaround) {
mGestureLongclick = GESTURE_LOOKUP;
}
if (preferences.getBoolean("keepScreenOn", false)) {
this.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
// These are preferences we pull out of the collection instead of SharedPreferences
try {
mShowNextReviewTime = getCol().getConf().getBoolean("estTimes");
mShowRemainingCardCount = getCol().getConf().getBoolean("dueCounts");
} catch (JSONException e) {
throw new RuntimeException();
}
return preferences;
}
private void setInterface() {
if (mCurrentCard == null) {
return;
}
if (mCard == null) {
mCard = createWebView();
mCardFrame.addView(mCard);
if (!mUseQuickUpdate) {
mNextCard = createWebView();
mNextCard.setVisibility(View.GONE);
mCardFrame.addView(mNextCard, 0);
}
}
if (mCard.getVisibility() != View.VISIBLE) {
mCard.setVisibility(View.VISIBLE);
}
}
private void updateForNewCard() {
updateScreenCounts();
// Clean answer field
if (typeAnswer()) {
mAnswerField.setText("");
}
if (mPrefWhiteboard && mWhiteboard != null) {
mWhiteboard.clear();
}
}
protected void updateScreenCounts() {
ActionBar actionBar = getSupportActionBar();
if (mCurrentCard == null) return;
int[] counts = mSched.counts(mCurrentCard);
if (actionBar != null) {
try {
String[] title = getCol().getDecks().get(mCurrentCard.getDid()).getString("name").split("::");
actionBar.setTitle(title[title.length - 1]);
} catch (JSONException e) {
throw new RuntimeException(e);
}
if (mPrefShowETA) {
int eta = mSched.eta(counts, false);
actionBar.setSubtitle(getResources().getQuantityString(R.plurals.reviewer_window_title, eta, eta));
}
}
SpannableString newCount = new SpannableString(String.valueOf(counts[0]));
SpannableString lrnCount = new SpannableString(String.valueOf(counts[1]));
SpannableString revCount = new SpannableString(String.valueOf(counts[2]));
if (mPrefHideDueCount) {
revCount = new SpannableString("???");
}
switch (mSched.countIdx(mCurrentCard)) {
case Card.TYPE_NEW:
newCount.setSpan(new UnderlineSpan(), 0, newCount.length(), 0);
break;
case Card.TYPE_LRN:
lrnCount.setSpan(new UnderlineSpan(), 0, lrnCount.length(), 0);
break;
case Card.TYPE_REV:
revCount.setSpan(new UnderlineSpan(), 0, revCount.length(), 0);
break;
}
mTextBarNew.setText(newCount);
mTextBarLearn.setText(lrnCount);
mTextBarReview.setText(revCount);
}
/*
* Handler for the delay in auto showing question and/or answer One toggle for both question and answer, could set
* longer delay for auto next question
*/
protected Handler mTimeoutHandler = new Handler();
protected Runnable mShowQuestionTask = new Runnable() {
@Override
public void run() {
// Assume hitting the "Again" button when auto next question
if (mEase1Layout.isEnabled() && mEase1Layout.getVisibility() == View.VISIBLE) {
mEase1Layout.performClick();
}
}
};
protected Runnable mShowAnswerTask = new Runnable() {
@Override
public void run() {
if (mFlipCardLayout.isEnabled() && mFlipCardLayout.getVisibility() == View.VISIBLE) {
mFlipCardLayout.performClick();
}
}
};
protected void initTimer() {
final TypedValue typedValue = new TypedValue();
mShowTimer = mCurrentCard.showTimer();
if (mShowTimer && mCardTimer.getVisibility() == View.INVISIBLE) {
mCardTimer.setVisibility(View.VISIBLE);
} else if (!mShowTimer && mCardTimer.getVisibility() != View.INVISIBLE) {
mCardTimer.setVisibility(View.INVISIBLE);
}
// Set normal timer color
getTheme().resolveAttribute(android.R.attr.textColor, typedValue, true);
mCardTimer.setTextColor(typedValue.data);
mCardTimer.setBase(SystemClock.elapsedRealtime());
mCardTimer.start();
// Stop and highlight the timer if it reaches the time limit.
getTheme().resolveAttribute(R.attr.maxTimerColor, typedValue, true);
final int limit = mCurrentCard.timeLimit();
mCardTimer.setOnChronometerTickListener(new Chronometer.OnChronometerTickListener() {
@Override
public void onChronometerTick(Chronometer chronometer) {
long elapsed = SystemClock.elapsedRealtime() - chronometer.getBase();
if (elapsed >= limit) {
chronometer.setTextColor(typedValue.data);
chronometer.stop();
}
}
});
}
protected void displayCardQuestion() {
Timber.d("displayCardQuestion()");
sDisplayAnswer = false;
setInterface();
String question;
String displayString = "";
if (mCurrentCard.isEmpty()) {
displayString = getResources().getString(R.string.empty_card_warning);
} else {
question = mCurrentCard.q();
question = getCol().getMedia().escapeImages(question);
question = typeAnsQuestionFilter(question);
Timber.d("question: '%s'", question);
// If the user wants to write the answer
if (typeAnswer()) {
mAnswerField.setVisibility(View.VISIBLE);
}
displayString = enrichWithQADiv(question, false);
if (mSpeakText) {
// ReadText.setLanguageInformation(Model.getModel(DeckManager.getMainDeck(),
// mCurrentCard.getCardModelId(), false).getId(), mCurrentCard.getCardModelId());
}
}
updateCard(displayString);
hideEaseButtons();
// If the user wants to show the answer automatically
if (mPrefUseTimer) {
long delay = mWaitAnswerSecond * 1000 + mUseTimerDynamicMS;
if (delay > 0) {
mTimeoutHandler.removeCallbacks(mShowAnswerTask);
mTimeoutHandler.postDelayed(mShowAnswerTask, delay);
}
}
Timber.i("AbstractFlashcardViewer:: Question successfully shown for card id %d", mCurrentCard.getId());
}
/**
* Clean up the correct answer text, so it can be used for the comparison with the typed text
*
* @param answer The content of the field the text typed by the user is compared to.
* @return The correct answer text, with actual HTML and media references removed, and HTML entities unescaped.
*/
protected String cleanCorrectAnswer(String answer) {
if (answer == null || answer.equals("")) {
return "";
}
answer = answer.trim();
Matcher matcher = sSpanPattern.matcher(Utils.stripHTMLMedia(answer));
String answerText = matcher.replaceAll("");
matcher = sBrPattern.matcher(answerText);
answerText = matcher.replaceAll("\n");
matcher = Sound.sSoundPattern.matcher(answerText);
answerText = matcher.replaceAll("");
return Utils.nfcNormalized(answerText);
}
/**
* Clean up the typed answer text, so it can be used for the comparison with the correct answer
*
* @param answer The answer text typed by the user.
* @return The typed answer text, cleaned up.
*/
protected String cleanTypedAnswer(String answer) {
if (answer == null || answer.equals("")) {
return "";
}
return Utils.nfcNormalized(answer.trim());
}
protected void displayCardAnswer() {
Timber.d("displayCardAnswer()");
// prevent answering (by e.g. gestures) before card is loaded
if (mCurrentCard == null) {
return;
}
// Explicitly hide the soft keyboard. It *should* be hiding itself automatically,
// but sometimes failed to do so (e.g. if an OnKeyListener is attached).
if (typeAnswer()) {
InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
inputMethodManager.hideSoftInputFromWindow(mAnswerField.getWindowToken(), 0);
}
sDisplayAnswer = true;
String answer = mCurrentCard.a();
mSoundPlayer.stopSounds();
answer = getCol().getMedia().escapeImages(answer);
mAnswerField.setVisibility(View.GONE);
// Clean up the user answer and the correct answer
String userAnswer;
if (mUseInputTag) {
userAnswer = cleanTypedAnswer(mTypeInput);
} else {
userAnswer = cleanTypedAnswer(mAnswerField.getText().toString());
}
String correctAnswer = cleanCorrectAnswer(mTypeCorrect);
Timber.d("correct answer = %s", correctAnswer);
Timber.d("user answer = %s", userAnswer);
answer = typeAnsAnswerFilter(answer, userAnswer, correctAnswer);
mIsSelecting = false;
updateCard(enrichWithQADiv(answer, true));
showEaseButtons();
// If the user wants to show the next question automatically
if (mPrefUseTimer) {
long delay = mWaitQuestionSecond * 1000 + mUseTimerDynamicMS;
if (delay > 0) {
mTimeoutHandler.removeCallbacks(mShowQuestionTask);
mTimeoutHandler.postDelayed(mShowQuestionTask, delay);
}
}
}
/**
* getAnswerFormat returns the answer part of this card's template as entered by user, without any parsing
*/
public String getAnswerFormat() {
try {
JSONObject model = mCurrentCard.model();
JSONObject template;
if (model.getInt("type") == Consts.MODEL_STD) {
template = model.getJSONArray("tmpls").getJSONObject(mCurrentCard.getOrd());
} else {
template = model.getJSONArray("tmpls").getJSONObject(0);
}
return template.getString("afmt");
} catch (JSONException e) {
throw new RuntimeException(e);
}
}
private void addAnswerSounds(String answer) {
// don't add answer sounds multiple times, such as when reshowing card after exiting editor
// additionally, this condition reduces computation time
if (!mAnswerSoundsAdded) {
String answerSoundSource = removeFrontSideAudio(answer);
mSoundPlayer.addSounds(mBaseUrl, answerSoundSource, Sound.SOUNDS_ANSWER);
mAnswerSoundsAdded = true;
}
}
private void updateCard(String content) {
Timber.d("updateCard()");
mUseTimerDynamicMS = 0;
// Add CSS for font color and font size
if (mCurrentCard == null) {
mCard.getSettings().setDefaultFontSize(calculateDynamicFontSize(content));
}
if (sDisplayAnswer) {
addAnswerSounds(content);
} else {
// reset sounds each time first side of card is displayed, which may happen repeatedly without ever
// leaving the card (such as when edited)
mSoundPlayer.resetSounds();
mAnswerSoundsAdded = false;
mSoundPlayer.addSounds(mBaseUrl, content, Sound.SOUNDS_QUESTION);
if (mPrefUseTimer && !mAnswerSoundsAdded && getConfigForCurrentCard().optBoolean("autoplay", false)) {
addAnswerSounds(mCurrentCard.a());
}
}
content = Sound.expandSounds(mBaseUrl, content);
// In order to display the bold style correctly, we have to change
// font-weight to 700
content = content.replace("font-weight:600;", "font-weight:700;");
// CSS class for card-specific styling
String cardClass = "card card" + (mCurrentCard.getOrd() + 1);
if (mPrefCenterVertically) {
cardClass += " vertically_centered";
}
Timber.d("content card = \n %s", content);
StringBuilder style = new StringBuilder();
mExtensions.updateCssStyle(style);
// Zoom cards
if (mCardZoom != 100) {
style.append(String.format("body { zoom: %s }\n", mCardZoom / 100.0));
}
// Zoom images
if (mImageZoom != 100) {
style.append(String.format("img { zoom: %s }\n", mImageZoom / 100.0));
}
Timber.d("::style::", style);
if (mNightMode) {
// Enable the night-mode class
cardClass += " night_mode";
// If card styling doesn't contain any mention of the night_mode class then do color inversion as fallback
// TODO: find more robust solution that won't match unrelated classes like "night_mode_old"
if (!mCurrentCard.css().contains(".night_mode")) {
content = HtmlColors.invertColors(content);
}
}
content = SmpToHtmlEntity(content);
mCardContent = new SpannedString(mCardTemplate.replace("::content::", content)
.replace("::style::", style.toString()).replace("::class::", cardClass));
Timber.d("base url = %s", mBaseUrl);
if (SAVE_CARD_CONTENT) {
try {
FileOutputStream f = new FileOutputStream(new File(CollectionHelper.getCurrentAnkiDroidDirectory(this),
"card.html"));
try {
f.write(mCardContent.toString().getBytes());
} finally {
f.close();
}
} catch (IOException e) {
Timber.d(e, "failed to save card");
}
}
fillFlashcard();
if (!mConfigurationChanged) {
playSounds(false); // Play sounds if appropriate
}
}
/**
* Converts characters in Unicode Supplementary Multilingual Plane (SMP) to their equivalent Html Entities. This is
* done because webview has difficulty displaying these characters.
*
* @param text
* @return
*/
private String SmpToHtmlEntity(String text) {
StringBuffer sb = new StringBuffer();
Matcher m = Pattern.compile("([^\u0000-\uFFFF])").matcher(text);
while (m.find()) {
String a = "" + Integer.toHexString(m.group(1).codePointAt(0)) + ";";
m.appendReplacement(sb, Matcher.quoteReplacement(a));
}
m.appendTail(sb);
return sb.toString();
}
/**
* Plays sounds (or TTS, if configured) for currently shown side of card.
*
* @param doAudioReplay indicates an anki desktop-like replay call is desired, whose behavior is identical to
* pressing the keyboard shortcut R on the desktop
*/
protected void playSounds(boolean doAudioReplay) {
boolean replayQuestion = getConfigForCurrentCard().optBoolean("replayq", true);
if (getConfigForCurrentCard().optBoolean("autoplay", false) || doAudioReplay) {
// Use TTS if TTS preference enabled and no other sound source
boolean useTTS = mSpeakText &&
!(sDisplayAnswer && mSoundPlayer.hasAnswer()) && !(!sDisplayAnswer && mSoundPlayer.hasQuestion());
// We need to play the sounds from the proper side of the card
if (!useTTS) { // Text to speech not in effect here
if (doAudioReplay && replayQuestion && sDisplayAnswer) {
// only when all of the above are true will question be played with answer, to match desktop
mSoundPlayer.playSounds(Sound.SOUNDS_QUESTION_AND_ANSWER);
} else if (sDisplayAnswer) {
mSoundPlayer.playSounds(Sound.SOUNDS_ANSWER);
if (mPrefUseTimer) {
mUseTimerDynamicMS = mSoundPlayer.getSoundsLength(Sound.SOUNDS_ANSWER);
}
} else { // question is displayed
mSoundPlayer.playSounds(Sound.SOUNDS_QUESTION);
// If the user wants to show the answer automatically
if (mPrefUseTimer) {
mUseTimerDynamicMS = mSoundPlayer.getSoundsLength(Sound.SOUNDS_QUESTION_AND_ANSWER);
}
}
} else { // Text to speech is in effect here
// If the question is displayed or if the question should be replayed, read the question
if (mTtsInitialized) {
if (!sDisplayAnswer || doAudioReplay && replayQuestion) {
readCardText(mCurrentCard, Sound.SOUNDS_QUESTION);
}
if (sDisplayAnswer) {
readCardText(mCurrentCard, Sound.SOUNDS_ANSWER);
}
} else {
mReplayOnTtsInit = true;
}
}
}
}
/**
* Reads the text (using TTS) for the given side of a card.
*
* @param card The card to play TTS for
* @param cardSide The side of the current card to play TTS for
*/
private static void readCardText(final Card card, final int cardSide) {
if (Sound.SOUNDS_QUESTION == cardSide) {
ReadText.textToSpeech(Utils.stripHTML(card.q(true)), getDeckIdForCard(card), card.getOrd(),
Sound.SOUNDS_QUESTION);
} else if (Sound.SOUNDS_ANSWER == cardSide) {
ReadText.textToSpeech(Utils.stripHTML(card.getPureAnswer()), getDeckIdForCard(card),
card.getOrd(), Sound.SOUNDS_ANSWER);
}
}
/**
* Shows the dialogue for selecting TTS for the current card and cardside.
*/
protected void showSelectTtsDialogue() {
if (mTtsInitialized) {
if (!sDisplayAnswer) {
ReadText.selectTts(Utils.stripHTML(mCurrentCard.q(true)), getDeckIdForCard(mCurrentCard), mCurrentCard.getOrd(),
Sound.SOUNDS_QUESTION);
}
else {
ReadText.selectTts(Utils.stripHTML(mCurrentCard.getPureAnswer()), getDeckIdForCard(mCurrentCard),
mCurrentCard.getOrd(), Sound.SOUNDS_ANSWER);
}
}
}
/**
* Returns the configuration for the current {@link Card}.
*
* @return The configuration for the current {@link Card}
*/
private JSONObject getConfigForCurrentCard() {
return getCol().getDecks().confForDid(getDeckIdForCard(mCurrentCard));
}
/**
* Returns the deck ID of the given {@link Card}.
*
* @param card The {@link Card} to get the deck ID
* @return The deck ID of the {@link Card}
*/
private static long getDeckIdForCard(final Card card) {
// Try to get the configuration by the original deck ID (available in case of a cram deck),
// else use the direct deck ID (in case of a 'normal' deck.
return card.getODid() == 0 ? card.getDid() : card.getODid();
}
public void fillFlashcard() {
Timber.d("fillFlashcard()");
Timber.d("base url = %s", mBaseUrl);
if (!mUseQuickUpdate && mCard != null && mNextCard != null) {
CompatHelper.getCompat().setHTML5MediaAutoPlay(mNextCard.getSettings(), getConfigForCurrentCard().optBoolean("autoplay"));
mNextCard.loadDataWithBaseURL(mBaseUrl + "__viewer__.html", mCardContent.toString(), "text/html", "utf-8", null);
mNextCard.setVisibility(View.VISIBLE);
mCardFrame.removeView(mCard);
destroyWebView(mCard);
mCard = mNextCard;
mNextCard = createWebView();
mNextCard.setVisibility(View.GONE);
mCardFrame.addView(mNextCard, 0);
} else if (mCard != null) {
CompatHelper.getCompat().setHTML5MediaAutoPlay(mCard.getSettings(), getConfigForCurrentCard().optBoolean("autoplay"));
mCard.loadDataWithBaseURL(mBaseUrl + "__viewer__.html", mCardContent.toString(), "text/html", "utf-8", null);
}
if (mShowTimer && mCardTimer.getVisibility() == View.INVISIBLE) {
switchTopBarVisibility(View.VISIBLE);
}
if (!sDisplayAnswer) {
updateForNewCard();
}
}
public static Card getEditorCard() {
return sEditorCard;
}
/**
* Adds a div html tag around the contents to have an indication, where answer/question is displayed
*
* @param content
* @param isAnswer if true then the class attribute is set to "answer", "question" otherwise.
* @return
*/
private static String enrichWithQADiv(String content, boolean isAnswer) {
StringBuilder sb = new StringBuilder();
sb.append("<div class=\"");
if (isAnswer) {
sb.append(ANSWER_CLASS);
} else {
sb.append(QUESTION_CLASS);
}
sb.append("\">");
sb.append(content);
sb.append("</div>");
return sb.toString();
}
/**
* @return true if the AnkiDroid preference for writing answer is true and if the Anki Deck CardLayout specifies a
* field to query
*/
private boolean typeAnswer() {
return mShowTypeAnswerField && null != mTypeCorrect;
}
/**
* Calculates a dynamic font size depending on the length of the contents taking into account that the input string
* contains html-tags, which will not be displayed and therefore should not be taken into account.
*
* @param htmlContent
* @return font size respecting MIN_DYNAMIC_FONT_SIZE and MAX_DYNAMIC_FONT_SIZE
*/
private static int calculateDynamicFontSize(String htmlContent) {
// Replace each <br> with 15 spaces, each <hr> with 30 spaces, then
// remove all html tags and spaces
String realContent = htmlContent.replaceAll("\\<br.*?\\>", " ");
realContent = realContent.replaceAll("\\<hr.*?\\>", " ");
realContent = realContent.replaceAll("\\<.*?\\>", "");
realContent = realContent.replaceAll(" ", " ");
return Math.max(DYNAMIC_FONT_MIN_SIZE, DYNAMIC_FONT_MAX_SIZE - realContent.length() / DYNAMIC_FONT_FACTOR);
}
private void unblockControls() {
mCardFrame.setEnabled(true);
mFlipCardLayout.setEnabled(true);
switch (mCurrentEase) {
case EASE_1:
mEase1Layout.setClickable(true);
mEase2Layout.setEnabled(true);
mEase3Layout.setEnabled(true);
mEase4Layout.setEnabled(true);
break;
case EASE_2:
mEase1Layout.setEnabled(true);
mEase2Layout.setClickable(true);
mEase3Layout.setEnabled(true);
mEase4Layout.setEnabled(true);
break;
case EASE_3:
mEase1Layout.setEnabled(true);
mEase2Layout.setEnabled(true);
mEase3Layout.setClickable(true);
mEase4Layout.setEnabled(true);
break;
case EASE_4:
mEase1Layout.setEnabled(true);
mEase2Layout.setEnabled(true);
mEase3Layout.setEnabled(true);
mEase4Layout.setClickable(true);
break;
default:
mEase1Layout.setEnabled(true);
mEase2Layout.setEnabled(true);
mEase3Layout.setEnabled(true);
mEase4Layout.setEnabled(true);
break;
}
if (mPrefWhiteboard && mWhiteboard != null) {
mWhiteboard.setEnabled(true);
}
if (typeAnswer()) {
mAnswerField.setEnabled(true);
}
mTouchLayer.setVisibility(View.VISIBLE);
mInAnswer = false;
}
private void blockControls() {
mCardFrame.setEnabled(false);
mFlipCardLayout.setEnabled(false);
mTouchLayer.setVisibility(View.INVISIBLE);
mInAnswer = true;
switch (mCurrentEase) {
case EASE_1:
mEase1Layout.setClickable(false);
mEase2Layout.setEnabled(false);
mEase3Layout.setEnabled(false);
mEase4Layout.setEnabled(false);
break;
case EASE_2:
mEase1Layout.setEnabled(false);
mEase2Layout.setClickable(false);
mEase3Layout.setEnabled(false);
mEase4Layout.setEnabled(false);
break;
case EASE_3:
mEase1Layout.setEnabled(false);
mEase2Layout.setEnabled(false);
mEase3Layout.setClickable(false);
mEase4Layout.setEnabled(false);
break;
case EASE_4:
mEase1Layout.setEnabled(false);
mEase2Layout.setEnabled(false);
mEase3Layout.setEnabled(false);
mEase4Layout.setClickable(false);
break;
default:
mEase1Layout.setEnabled(false);
mEase2Layout.setEnabled(false);
mEase3Layout.setEnabled(false);
mEase4Layout.setEnabled(false);
break;
}
if (mPrefWhiteboard && mWhiteboard != null) {
mWhiteboard.setEnabled(false);
}
if (typeAnswer()) {
mAnswerField.setEnabled(false);
}
}
/**
* Select Text in the webview and automatically sends the selected text to the clipboard. From
* http://cosmez.blogspot.com/2010/04/webview-emulateshiftheld-on-android.html
*/
private void selectAndCopyText() {
try {
KeyEvent shiftPressEvent = new KeyEvent(0, 0, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_SHIFT_LEFT, 0, 0);
shiftPressEvent.dispatch(mCard);
shiftPressEvent.isShiftPressed();
mIsSelecting = true;
} catch (Exception e) {
throw new AssertionError(e);
}
}
/**
* Returns true if we should update the content of a single {@link WebView} (called quick update) instead of switch
* between two instances.
* <p>
* Webview switching is needed in some versions of Android when using custom fonts because of a memory leak in
* WebView.
* <p>
* It is also needed to solve a refresh issue on Nook devices.
*
* @return true if we should use a single WebView
*/
private boolean shouldUseQuickUpdate() {
return !mPrefSafeDisplay;
}
protected void executeCommand(int which) {
switch (which) {
case GESTURE_NOTHING:
break;
case GESTURE_SHOW_ANSWER:
if (!sDisplayAnswer) {
displayCardAnswer();
}
break;
case GESTURE_ANSWER_EASE1:
if (sDisplayAnswer) {
answerCard(EASE_1);
} else {
displayCardAnswer();
}
break;
case GESTURE_ANSWER_EASE2:
if (sDisplayAnswer) {
answerCard(EASE_2);
} else {
displayCardAnswer();
}
break;
case GESTURE_ANSWER_EASE3:
if (sDisplayAnswer) {
answerCard(EASE_3);
} else {
displayCardAnswer();
}
break;
case GESTURE_ANSWER_EASE4:
if (sDisplayAnswer) {
answerCard(EASE_4);
} else {
displayCardAnswer();
}
break;
case GESTURE_ANSWER_RECOMMENDED:
if (sDisplayAnswer) {
answerCard(getRecommendedEase(false));
} else {
displayCardAnswer();
}
break;
case GESTURE_ANSWER_BETTER_THAN_RECOMMENDED:
if (sDisplayAnswer) {
answerCard(getRecommendedEase(true));
} else {
displayCardAnswer();
}
break;
case GESTURE_EXIT:
closeReviewer(RESULT_DEFAULT, false);
break;
case GESTURE_UNDO:
if (getCol().undoAvailable()) {
undo();
}
break;
case GESTURE_EDIT:
editCard();
break;
case GESTURE_MARK:
onMark(mCurrentCard);
break;
case GESTURE_LOOKUP:
lookUpOrSelectText();
break;
case GESTURE_BURY_CARD:
dismiss(Collection.DismissType.BURY_CARD);
break;
case GESTURE_BURY_NOTE:
dismiss(Collection.DismissType.BURY_NOTE);
break;
case GESTURE_SUSPEND_CARD:
dismiss(Collection.DismissType.SUSPEND_CARD);
break;
case GESTURE_SUSPEND_NOTE:
dismiss(Collection.DismissType.SUSPEND_NOTE);
break;
case GESTURE_DELETE:
showDeleteNoteDialog();
break;
case GESTURE_PLAY_MEDIA:
playSounds(true);
break;
}
}
// ----------------------------------------------------------------------------
// INNER CLASSES
// ----------------------------------------------------------------------------
/**
* Provides a hook for calling "alert" from javascript. Useful for debugging your javascript.
*/
public final class AnkiDroidWebChromeClient extends WebChromeClient {
@Override
public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
Timber.i("AbstractFlashcardViewer:: onJsAlert: %s", message);
result.confirm();
return true;
}
}
protected void closeReviewer(int result, boolean saveDeck) {
mTimeoutHandler.removeCallbacks(mShowAnswerTask);
mTimeoutHandler.removeCallbacks(mShowQuestionTask);
mTimerHandler.removeCallbacks(removeChosenAnswerText);
longClickHandler.removeCallbacks(longClickTestRunnable);
longClickHandler.removeCallbacks(startLongClickAction);
AbstractFlashcardViewer.this.setResult(result);
if (saveDeck) {
UIUtils.saveCollectionInBackground(this);
}
finishWithAnimation(ActivityTransitionAnimation.RIGHT);
}
protected void refreshActionBar() {
supportInvalidateOptionsMenu();
}
/** Fixing bug 720: <input> focus, thanks to pablomouzo on android issue 7189 */
class MyWebView extends WebView {
public MyWebView(Context context) {
super(context);
}
@Override
public boolean onCheckIsTextEditor() {
if (mInputWorkaround) {
return true;
} else {
return super.onCheckIsTextEditor();
}
}
@Override
protected void onScrollChanged(int horiz, int vert, int oldHoriz, int oldVert) {
super.onScrollChanged(horiz, vert, oldHoriz, oldVert);
if (Math.abs(horiz - oldHoriz) > Math.abs(vert - oldVert)) {
mIsXScrolling = true;
scrollHandler.removeCallbacks(scrollXRunnable);
scrollHandler.postDelayed(scrollXRunnable, 300);
} else {
mIsYScrolling = true;
scrollHandler.removeCallbacks(scrollYRunnable);
scrollHandler.postDelayed(scrollYRunnable, 300);
}
}
private final Handler scrollHandler = new Handler();
private final Runnable scrollXRunnable = new Runnable() {
@Override
public void run() {
mIsXScrolling = false;
}
};
private final Runnable scrollYRunnable = new Runnable() {
@Override
public void run() {
mIsYScrolling = false;
}
};
}
class MyGestureDetector extends SimpleOnGestureListener {
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
// Go back to immersive mode if the user had temporarily exited it (and then execute swipe gesture)
if (mPrefFullscreenReview > 0 &&
CompatHelper.getCompat().isImmersiveSystemUiVisible(AbstractFlashcardViewer.this)) {
delayedHide(INITIAL_HIDE_DELAY);
}
if (mGesturesEnabled) {
try {
float dy = e2.getY() - e1.getY();
float dx = e2.getX() - e1.getX();
if (Math.abs(dx) > Math.abs(dy)) {
// horizontal swipe if moved further in x direction than y direction
if (dx > AnkiDroidApp.sSwipeMinDistance
&& Math.abs(velocityX) > AnkiDroidApp.sSwipeThresholdVelocity
&& !mIsXScrolling && !mIsSelecting) {
// right
executeCommand(mGestureSwipeRight);
} else if (dx < -AnkiDroidApp.sSwipeMinDistance
&& Math.abs(velocityX) > AnkiDroidApp.sSwipeThresholdVelocity
&& !mIsXScrolling && !mIsSelecting) {
// left
executeCommand(mGestureSwipeLeft);
}
} else {
// otherwise vertical swipe
if (dy > AnkiDroidApp.sSwipeMinDistance
&& Math.abs(velocityY) > AnkiDroidApp.sSwipeThresholdVelocity
&& !mIsYScrolling) {
// down
executeCommand(mGestureSwipeDown);
} else if (dy < -AnkiDroidApp.sSwipeMinDistance
&& Math.abs(velocityY) > AnkiDroidApp.sSwipeThresholdVelocity
&& !mIsYScrolling) {
// up
executeCommand(mGestureSwipeUp);
}
}
} catch (Exception e) {
Timber.e(e, "onFling Exception");
}
}
return false;
}
@Override
public boolean onDoubleTap(MotionEvent e) {
if (mGesturesEnabled) {
executeCommand(mGestureDoubleTap);
}
return true;
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
if (mTouchStarted) {
longClickHandler.removeCallbacks(longClickTestRunnable);
mTouchStarted = false;
}
return false;
}
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
// Go back to immersive mode if the user had temporarily exited it (and ignore the tap gesture)
if (mPrefFullscreenReview > 0 &&
CompatHelper.getCompat().isImmersiveSystemUiVisible(AbstractFlashcardViewer.this)) {
delayedHide(INITIAL_HIDE_DELAY);
return true;
}
if (mGesturesEnabled && !mIsSelecting) {
int height = mTouchLayer.getHeight();
int width = mTouchLayer.getWidth();
float posX = e.getX();
float posY = e.getY();
if (posX > posY / height * width) {
if (posY > height * (1 - posX / width)) {
executeCommand(mGestureTapRight);
} else {
executeCommand(mGestureTapTop);
}
} else {
if (posY > height * (1 - posX / width)) {
executeCommand(mGestureTapBottom);
} else {
executeCommand(mGestureTapLeft);
}
}
}
mIsSelecting = false;
showLookupButtonIfNeeded();
return false;
}
}
protected final Handler mFullScreenHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
if (mPrefFullscreenReview > 0) {
CompatHelper.getCompat().setFullScreen(AbstractFlashcardViewer.this);
}
}
};
protected void delayedHide(int delayMillis) {
mFullScreenHandler.removeMessages(0);
mFullScreenHandler.sendEmptyMessageDelayed(0, delayMillis);
}
/**
* Removes first occurrence in answerContent of any audio that is present due to use of
* {{FrontSide}} on the answer.
* @param answerContent The content from which to remove front side audio.
* @return The content stripped of audio due to {{FrontSide}} inclusion.
*/
private String removeFrontSideAudio(String answerContent) {
String answerFormat = getAnswerFormat();
if (answerFormat.contains("{{FrontSide}}")) { // possible audio removal necessary
String frontSideFormat = mCurrentCard._getQA(false).get("q");
Matcher audioReferences = Sound.sSoundPattern.matcher(frontSideFormat);
// remove the first instance of audio contained in "{{FrontSide}}"
while (audioReferences.find()) {
answerContent = answerContent.replaceFirst(Pattern.quote(audioReferences.group()), "");
}
}
return answerContent;
}
/**
* Public method to start new video player activity
*/
public void playVideo(String path) {
Intent videoPlayer = new Intent(this, VideoPlayer.class);
videoPlayer.putExtra("path", path);
startActivityWithoutAnimation(videoPlayer);
}
/** Callback for when TTS has been initialized. */
public void ttsInitialized() {
mTtsInitialized = true;
if (mReplayOnTtsInit) {
playSounds(true);
}
}
protected void onMark(Card card) {
Note note = card.note();
if (note.hasTag("marked")) {
note.delTag("marked");
} else {
note.addTag("marked");
}
note.flush();
refreshActionBar();
}
protected void dismiss(Collection.DismissType type) {
DeckTask.launchDeckTask(DeckTask.TASK_TYPE_DISMISS, mDismissCardHandler,
new DeckTask.TaskData(new Object[]{mCurrentCard, type}));
}
}