diff options
33 files changed, 2533 insertions, 388 deletions
@@ -19,21 +19,14 @@ include $(CLEAR_VARS) LOCAL_MODULE_TAGS := optional -LOCAL_STATIC_JAVA_LIBRARIES := libarity android-support-v4 guava +LOCAL_STATIC_JAVA_LIBRARIES := cr android-support-v4 LOCAL_SRC_FILES := $(call all-java-files-under, src) LOCAL_SDK_VERSION := current -LOCAL_PACKAGE_NAME := Calculator +LOCAL_PACKAGE_NAME := ExactCalculator -include $(BUILD_PACKAGE) -################################################## -include $(CLEAR_VARS) - -LOCAL_PREBUILT_STATIC_JAVA_LIBRARIES := libarity:arity-2.1.2.jar +LOCAL_AAPT_FLAGS := --rename-manifest-package com.android.exactcalculator -include $(BUILD_MULTI_PREBUILT) - -# Use the following include to make our test apk. -include $(call all-makefiles-under,$(LOCAL_PATH)) +include $(BUILD_PACKAGE) diff --git a/arity-2.1.2.jar b/arity-2.1.2.jar Binary files differdeleted file mode 100644 index 54c4a98..0000000 --- a/arity-2.1.2.jar +++ /dev/null diff --git a/assets/about.txt b/assets/about.txt new file mode 100644 index 0000000..89ad1ad --- /dev/null +++ b/assets/about.txt @@ -0,0 +1,25 @@ +The calculator uses a software implementation of arithmetic remembers how each number was computed, and always knows how to evaluate each intermediate result to any given precision. Numbers are reevaluated as you scroll. + +The underlying open source library was previously distributed as part of the "CRCalc" calculator. The version distributed here was very slightly modified. + +The library and its original test code is distributed under the following open source license: + +Copyright (c) 1999, Silicon Graphics, Inc. -- ALL RIGHTS RESERVED + +Permission is granted free of charge to copy, modify, use and distribute this software provided you include the entirety of this notice in all copies made. + +THIS SOFTWARE IS PROVIDED ON AN AS IS BASIS, WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, WITHOUT LIMITATION, WARRANTIES THAT THE SUBJECT SOFTWARE IS FREE OF DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR PURPOSE OR NON-INFRINGING. SGI ASSUMES NO RISK AS TO THE QUALITY AND PERFORMANCE OF THE SOFTWARE. SHOULD THE SOFTWARE PROVE DEFECTIVE IN ANY RESPECT, SGI ASSUMES NO COST OR LIABILITY FOR ANY SERVICING, REPAIR OR CORRECTION. THIS DISCLAIMER OF WARRANTY CONSTITUTES AN ESSENTIAL PART OF THIS LICENSE. NO USE OF ANY SUBJECT SOFTWARE IS AUTHORIZED HEREUNDER EXCEPT UNDER THIS DISCLAIMER. + +UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT (INCLUDING, WITHOUT LIMITATION, NEGLIGENCE OR STRICT LIABILITY), CONTRACT, OR OTHERWISE, SHALL SGI BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES OF ANY CHARACTER WITH RESPECT TO THE SOFTWARE INCLUDING, WITHOUT LIMITATION, DAMAGES FOR LOSS OF GOODWILL, WORK STOPPAGE, LOSS OF DATA, COMPUTER FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER COMMERCIAL DAMAGES OR LOSSES, EVEN IF SGI SHALL HAVE BEEN INFORMED OF THE POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF LIABILITY SHALL NOT APPLY TO LIABILITY RESULTING FROM SGI's NEGLIGENCE TO THE EXTENT APPLICABLE LAW PROHIBITS SUCH LIMITATION. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO THAT EXCLUSION AND LIMITATION MAY NOT APPLY TO YOU. + +These license terms shall be governed by and construed in accordance with the laws of the United States and the State of California as applied to agreements entered into and to be performed entirely within California between California residents. Any litigation relating to these license terms shall be subject to the exclusive jurisdiction of the Federal Courts of the Northern District of California (or, absent subject matter jurisdiction in such courts, the courts of the State of California), with venue lying exclusively in Santa Clara County, California. + +Copyright (c) 2001-2004, Hewlett-Packard Development Company, L.P. + +Permission is granted free of charge to copy, modify, use and distribute this software provided you include the entirety of this notice in all copies made. + +THIS SOFTWARE IS PROVIDED ON AN AS IS BASIS, WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, WITHOUT LIMITATION, WARRANTIES THAT THE SUBJECT SOFTWARE IS FREE OF DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR PURPOSE OR NON-INFRINGING. HEWLETT-PACKARD ASSUMES NO RISK AS TO THE QUALITY AND PERFORMANCE OF THE SOFTWARE. SHOULD THE SOFTWARE PROVE DEFECTIVE IN ANY RESPECT, HEWLETT-PACKARD ASSUMES NO COST OR LIABILITY FOR ANY SERVICING, REPAIR OR CORRECTION. THIS DISCLAIMER OF WARRANTY CONSTITUTES AN ESSENTIAL PART OF THIS LICENSE. NO USE OF ANY SUBJECT SOFTWARE IS AUTHORIZED HEREUNDER EXCEPT UNDER THIS DISCLAIMER. + +UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT (INCLUDING, WITHOUT LIMITATION, NEGLIGENCE OR STRICT LIABILITY), CONTRACT, OR OTHERWISE, SHALL HEWLETT-PACKARD BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES OF ANY CHARACTER WITH RESPECT TO THE SOFTWARE INCLUDING, WITHOUT LIMITATION, DAMAGES FOR LOSS OF GOODWILL, WORK STOPPAGE, LOSS OF DATA, COMPUTER FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER COMMERCIAL DAMAGES OR LOSSES, EVEN IF HEWLETT-PACKARD SHALL HAVE BEEN INFORMED OF THE POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF LIABILITY SHALL NOT APPLY TO LIABILITY RESULTING FROM HEWLETT-PACKARD's NEGLIGENCE TO THE EXTENT APPLICABLE LAW PROHIBITS SUCH LIMITATION. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO THAT EXCLUSION AND LIMITATION MAY NOT APPLY TO YOU. + + diff --git a/build.gradle b/build.gradle index 7a2ab0e..8abdaba 100644 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,6 @@ repositories { } dependencies { - compile files("arity-2.1.2.jar") + compile files("cr.jar") compile "com.android.support:support-v4:+" } diff --git a/res/drawable-hdpi/ic_more_vert_grey600_24dp.png b/res/drawable-hdpi/ic_more_vert_grey600_24dp.png Binary files differnew file mode 100644 index 0000000..e141502 --- /dev/null +++ b/res/drawable-hdpi/ic_more_vert_grey600_24dp.png diff --git a/res/drawable-mdpi/ic_more_vert_grey600_24dp.png b/res/drawable-mdpi/ic_more_vert_grey600_24dp.png Binary files differnew file mode 100644 index 0000000..4ed3435 --- /dev/null +++ b/res/drawable-mdpi/ic_more_vert_grey600_24dp.png diff --git a/res/drawable-xhdpi/ic_more_vert_grey600_24dp.png b/res/drawable-xhdpi/ic_more_vert_grey600_24dp.png Binary files differnew file mode 100644 index 0000000..7bc63a5 --- /dev/null +++ b/res/drawable-xhdpi/ic_more_vert_grey600_24dp.png diff --git a/res/drawable-xxhdpi/ic_more_vert_grey600_24dp.png b/res/drawable-xxhdpi/ic_more_vert_grey600_24dp.png Binary files differnew file mode 100644 index 0000000..44012b8 --- /dev/null +++ b/res/drawable-xxhdpi/ic_more_vert_grey600_24dp.png diff --git a/res/drawable-xxxhdpi/ic_more_vert_grey600_24dp.png b/res/drawable-xxxhdpi/ic_more_vert_grey600_24dp.png Binary files differnew file mode 100644 index 0000000..0042578 --- /dev/null +++ b/res/drawable-xxxhdpi/ic_more_vert_grey600_24dp.png diff --git a/res/layout/activity_calculator_land.xml b/res/layout/activity_calculator_land.xml index 1fe12db..9182598 100644 --- a/res/layout/activity_calculator_land.xml +++ b/res/layout/activity_calculator_land.xml @@ -21,6 +21,12 @@ android:layout_height="match_parent" android:orientation="vertical"> + <!-- Unclear we actually have the vertical space to do this. Revisit! --> + <include + layout="@layout/extras" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> + <include layout="@layout/display" android:layout_width="match_parent" diff --git a/res/layout/activity_calculator_port.xml b/res/layout/activity_calculator_port.xml index 0cb5dc7..3b4351a 100644 --- a/res/layout/activity_calculator_port.xml +++ b/res/layout/activity_calculator_port.xml @@ -22,6 +22,11 @@ android:orientation="vertical"> <include + layout="@layout/extras" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> + + <include layout="@layout/display" android:layout_width="match_parent" android:layout_height="wrap_content" /> diff --git a/res/layout/display.xml b/res/layout/display.xml index 066838a..94f7848 100644 --- a/res/layout/display.xml +++ b/res/layout/display.xml @@ -31,7 +31,7 @@ android:inputType="text|textNoSuggestions" android:textColor="@color/display_formula_text_color" /> - <com.android.calculator2.CalculatorEditText + <com.android.calculator2.CalculatorResult android:id="@+id/result" style="@style/DisplayEditTextStyle.Result" android:layout_width="match_parent" @@ -41,4 +41,4 @@ android:focusable="false" android:textColor="@color/display_result_text_color" /> -</RelativeLayout>
\ No newline at end of file +</RelativeLayout> diff --git a/res/layout/extras.xml b/res/layout/extras.xml new file mode 100644 index 0000000..ec7418d --- /dev/null +++ b/res/layout/extras.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2015 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + --> + +<!-- + TODO: Use framework Toolbar instead of custom overflow menu. + --> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/display" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:background="@color/display_background_color" + android:gravity="right" + android:elevation="4dip"> + + <!-- Degree/Radian display goes here. Use gravity="left" --> + + <ImageButton + android:id="@+id/overflow_menu" + android:layout_width="48dip" + android:layout_height="match_parent" + android:src="@drawable/ic_more_vert_grey600_24dp" + android:background="?android:attr/selectableItemBackgroundBorderless" + android:contentDescription="@string/overflow_menu_description" + android:onClick="onButtonClick" /> + +</LinearLayout> diff --git a/res/layout/pad_advanced.xml b/res/layout/pad_advanced.xml index 5e8fec0..eb91b48 100644 --- a/res/layout/pad_advanced.xml +++ b/res/layout/pad_advanced.xml @@ -43,6 +43,27 @@ android:text="@string/fun_tan" /> <Button + android:id="@+id/fun_arcsin" + style="@style/PadButtonStyle.Advanced" + android:contentDescription="@string/desc_fun_arcsin" + android:onClick="onButtonClick" + android:text="@string/fun_arcsin" /> + + <Button + android:id="@+id/fun_arccos" + style="@style/PadButtonStyle.Advanced" + android:contentDescription="@string/desc_fun_arccos" + android:onClick="onButtonClick" + android:text="@string/fun_arccos" /> + + <Button + android:id="@+id/fun_arctan" + style="@style/PadButtonStyle.Advanced" + android:contentDescription="@string/desc_fun_arctan" + android:onClick="onButtonClick" + android:text="@string/fun_arctan" /> + + <Button android:id="@+id/fun_ln" style="@style/PadButtonStyle.Advanced" android:contentDescription="@string/desc_fun_ln" diff --git a/res/menu/menu.xml b/res/menu/menu.xml new file mode 100644 index 0000000..16712ea --- /dev/null +++ b/res/menu/menu.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* + * Copyright (C) 2011, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +--> + +<menu xmlns:android="http://schemas.android.com/apk/res/android"> + + <item android:id="@+id/menu_help" + android:title="@string/help"/> + + <item android:id="@+id/menu_about" + android:title="@string/about"/> + +</menu> diff --git a/res/mipmap-hdpi/ic_launcher_calculator.png b/res/mipmap-hdpi/ic_launcher_calculator.png Binary files differindex 6ea7fc4..e943cb4 100644 --- a/res/mipmap-hdpi/ic_launcher_calculator.png +++ b/res/mipmap-hdpi/ic_launcher_calculator.png diff --git a/res/mipmap-mdpi/ic_launcher_calculator.png b/res/mipmap-mdpi/ic_launcher_calculator.png Binary files differindex 534b165..c1cb23a 100644 --- a/res/mipmap-mdpi/ic_launcher_calculator.png +++ b/res/mipmap-mdpi/ic_launcher_calculator.png diff --git a/res/mipmap-xhdpi/ic_launcher_calculator.png b/res/mipmap-xhdpi/ic_launcher_calculator.png Binary files differindex 2e90135..f09c7a6 100644 --- a/res/mipmap-xhdpi/ic_launcher_calculator.png +++ b/res/mipmap-xhdpi/ic_launcher_calculator.png diff --git a/res/mipmap-xxhdpi/ic_launcher_calculator.png b/res/mipmap-xxhdpi/ic_launcher_calculator.png Binary files differindex 9bb8754..098f545 100644 --- a/res/mipmap-xxhdpi/ic_launcher_calculator.png +++ b/res/mipmap-xxhdpi/ic_launcher_calculator.png diff --git a/res/mipmap-xxxhdpi/ic_launcher_calculator.png b/res/mipmap-xxxhdpi/ic_launcher_calculator.png Binary files differindex 567c539..5e57435 100644 --- a/res/mipmap-xxxhdpi/ic_launcher_calculator.png +++ b/res/mipmap-xxxhdpi/ic_launcher_calculator.png diff --git a/res/values-land/styles.xml b/res/values-land/styles.xml index f82c152..ac8a566 100644 --- a/res/values-land/styles.xml +++ b/res/values-land/styles.xml @@ -74,7 +74,7 @@ <item name="android:paddingStart">8dip</item> <item name="android:paddingEnd">8dip</item> <item name="android:columnCount">3</item> - <item name="android:rowCount">4</item> + <item name="android:rowCount">5</item> </style> <style name="PadLayoutStyle.Numeric"> diff --git a/res/values-port/styles.xml b/res/values-port/styles.xml index d2de4b5..ff6e303 100644 --- a/res/values-port/styles.xml +++ b/res/values-port/styles.xml @@ -69,7 +69,7 @@ <item name="android:paddingStart">20dip</item> <item name="android:paddingEnd">20dip</item> <item name="android:columnCount">3</item> - <item name="android:rowCount">4</item> + <item name="android:rowCount">5</item> </style> <style name="PadLayoutStyle.Numeric"> @@ -90,4 +90,4 @@ <item name="android:paddingEnd">28dip</item> </style> -</resources>
\ No newline at end of file +</resources> diff --git a/res/values/donottranslate_strings.xml b/res/values/donottranslate_strings.xml index 69d2959..1ef264e 100644 --- a/res/values/donottranslate_strings.xml +++ b/res/values/donottranslate_strings.xml @@ -44,7 +44,5 @@ <!-- Equals operator (e.g. "1 + 2 = ?"). [CHAR_LIMIT=1] --> <string name="eq">=</string> - <!-- Result displayed when expression evaluates to infinity. [CHAR_LIMIT=1] --> - <string name="inf">∞</string> </resources> diff --git a/res/values/strings.xml b/res/values/strings.xml index bcdd55e..408d5ec 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -20,10 +20,14 @@ <!-- Name of the application. [CHAR_LIMIT=NONE] --> <string name="app_name">Calculator</string> - <!-- Error displayed when expression evaluates to NaN. [CHAR_LIMIT=14] --> + <!-- Error displayed when expression evaluates a function at undefined point. [CHAR_LIMIT=14] --> <string name="error_nan">Not a number</string> <!-- Error displayed when expression contains a syntax error. [CHAR_LIMIT=14] --> - <string name="error_syntax">Error</string> + <string name="error_syntax">Malformed expression</string> + <!-- Error displayed when evaluation is manually aborted. [CHAR_LIMIT=14] --> + <string name="error_aborted">Aborted</string> + <!-- Error displayed when excessive precision is required. [CHAR_LIMIT=14] --> + <string name="error_overflow">Infinite?</string> <!-- Decimal separator (e.g. "1.23"). [CHAR_LIMIT=1] --> <string name="dec_point">.</string> @@ -59,6 +63,14 @@ <string name="fun_sin">sin</string> <!-- Abbrev. name of tangent function (e.g. "tan(π)". [CHAR_LIMIT=3] --> <string name="fun_tan">tan</string> + <!-- Abbrev. name of cosine function (e.g. "arccos(π)". Often cos with a -1 superscript [CHAR_LIMIT=5] --> + <string name="fun_arccos">cos\u207B\u00B9</string> + <!-- Abbrev. name of sine function (e.g. "arcsin(π)". [CHAR_LIMIT=5] --> + <string name="fun_arcsin">sin\u207B\u00B9</string> + <!-- Abbrev. name of tangent function (e.g. "arctan(π)". [CHAR_LIMIT=5] --> + <string name="fun_arctan">tan\u207B\u00B9</string> + <!-- Ellipsis string used in display (e.g. "...". [CHAR_LIMIT=3] --> + <string name="ellipsis">\u2026</string> <!-- Clear operation to clear the currently entered expression. [CHAR_LIMIT=3] --> <string name="clr">clr</string> @@ -91,6 +103,12 @@ <string name="desc_fun_sin">sine</string> <!-- Content description for 'tan' button. [CHAR_LIMIT=NONE] --> <string name="desc_fun_tan">tangent</string> + <!-- Content description for 'arccos' button. [CHAR_LIMIT=NONE] --> + <string name="desc_fun_arccos">inverse cosine</string> + <!-- Content description for 'arcsin' button. [CHAR_LIMIT=NONE] --> + <string name="desc_fun_arcsin">inverse sine</string> + <!-- Content description for 'arctan' button. [CHAR_LIMIT=NONE] --> + <string name="desc_fun_arctan">inverse tangent</string> <!-- Content description for '+' button. [CHAR_LIMIT=NONE] --> <string name="desc_op_add">plus</string> @@ -114,4 +132,26 @@ <!-- Content description for '=' button. [CHAR_LIMIT=NONE] --> <string name="desc_eq">equals</string> + <!-- TODO: Revisit everything below here --> + <!-- Displayed briefly to indicate not-yet-computed digit. --> + <string name="guessed_digit">"?"</string> + <!-- Dialog message when a computation is cancelled by the user. --> + <string name="cancelled">Computation cancelled!</string> + <!-- Button label to dismiss informative text message. --> + <string name="dismiss">Dismiss</string> + <!-- Dialog message when a computation times out. --> + <string name="timeout">Timed out trying to compute an infinite or huge number</string> + <!-- Button label for "remove timeout" button. --> + <string name="ok_remove_timeout">OK, but longer timeouts, please!</string> + <!-- Help menu entry for context menu. --> + <string name="help">Help!</string> + <!-- Content description for overflow menu button. --> + <string name="overflow_menu_description">overflow menu</string> + <!-- The help message that's displayed in response to pushing the above button. --> + <string name="help_message">Use the keys to enter a standard arithmetic expression. It\'s fine to omit multiplication symbols and trailing parentheses. The result displayed after hitting = is computed to an error of less than one in the last displayed digit. Drag the display to see more digits.\n\nComputations involving infinite values may take forever. Touch a button to terminate computation or wait for the timeout.</string> + <!-- Help message addendum for pager. --> + <string name="help_pager">\n\nSwipe the keyboard to the left to see additional functions.</string> + <!-- About menu entry; leads mostly to (English language!) copyright notice. --> + <string name="about">About & Copyright</string> + </resources> diff --git a/src/com/android/calculator2/Calculator.java b/src/com/android/calculator2/Calculator.java index 5dd04bf..3ec0320 100644 --- a/src/com/android/calculator2/Calculator.java +++ b/src/com/android/calculator2/Calculator.java @@ -14,6 +14,17 @@ * limitations under the License. */ +// TODO: Fix evaluation interface so the evaluator returns entire +// result, and display can properly handle variable width font. +// TODO: Fix font handling and scaling in result display. +// TODO: Fix placement of inverse trig buttons. +// TODO: Add Degree/Radian switch and display. +// TODO: Handle physical keyboard correctly. +// TODO: Fix internationalization, including result. +// TODO: Check and fix accessability issues. +// TODO: Support pasting of at least full result. (Rounding?) +// TODO: Copy/paste in formula. + package com.android.calculator2; import android.animation.Animator; @@ -25,6 +36,9 @@ import android.animation.ObjectAnimator; import android.animation.ValueAnimator; import android.animation.ValueAnimator.AnimatorUpdateListener; import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; import android.graphics.Rect; import android.os.Bundle; import android.support.annotation.NonNull; @@ -32,27 +46,35 @@ import android.support.v4.view.ViewPager; import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; +import android.util.Log; import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; import android.view.View; import android.view.View.OnKeyListener; import android.view.View.OnLongClickListener; import android.view.ViewAnimationUtils; import android.view.ViewGroupOverlay; import android.view.animation.AccelerateDecelerateInterpolator; +import android.webkit.WebView; import android.widget.Button; +import android.widget.PopupMenu; +import android.widget.PopupMenu.OnMenuItemClickListener; import android.widget.TextView; import com.android.calculator2.CalculatorEditText.OnTextSizeChangeListener; -import com.android.calculator2.CalculatorExpressionEvaluator.EvaluateCallback; - -public class Calculator extends Activity - implements OnTextSizeChangeListener, EvaluateCallback, OnLongClickListener { - private static final String NAME = Calculator.class.getName(); +import java.io.ByteArrayInputStream; +import java.io.ObjectInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectOutputStream; +import java.io.ObjectInput; +import java.io.ObjectOutput; +import java.io.IOException; +import java.text.DecimalFormatSymbols; // TODO: May eventually not need this here. - // instance state keys - private static final String KEY_CURRENT_STATE = NAME + "_currentState"; - private static final String KEY_CURRENT_EXPRESSION = NAME + "_currentExpression"; +public class Calculator extends Activity + implements OnTextSizeChangeListener, OnLongClickListener, OnMenuItemClickListener { /** * Constant for an invalid resource id. @@ -60,8 +82,32 @@ public class Calculator extends Activity public static final int INVALID_RES_ID = -1; private enum CalculatorState { - INPUT, EVALUATE, RESULT, ERROR + INPUT, // Result and formula both visible, no evaluation requested, + // Though result may be visible on bottom line. + EVALUATE, // Both visible, evaluation requested, evaluation/animation incomplete. + INIT, // Very temporary state used as alternative to EVALUATE + // during reinitialization. Do not animate on completion. + ANIMATE, // Result computed, animation to enlarge result window in progress. + RESULT, // Result displayed, formula invisible. + // If we are in RESULT state, the formula was evaluated without + // error to initial precision. + ERROR // Error displayed: Formula visible, result shows error message. + // Display similar to INPUT state. } + // Normal transition sequence is + // INPUT -> EVALUATE -> ANIMATE -> RESULT (or ERROR) -> INPUT + // A RESULT -> ERROR transition is possible in rare corner cases, in which + // a higher precision evaluation exposes an error. This is possible, since we + // initially evaluate assuming we were given a well-defined problem. If we + // were actually asked to compute sqrt(<extremely tiny negative number>) we produce 0 + // unless we are asked for enough precision that we can distinguish the argument from zero. + // TODO: Consider further heuristics to reduce the chance of observing this? + // It already seems to be observable only in contrived cases. + // ANIMATE, ERROR, and RESULT are translated to an INIT state if the application + // is restarted in that state. This leads us to recompute and redisplay the result + // ASAP. + // TODO: Possibly save a bit more information, e.g. its initial display string + // or most significant digit position, to speed up restart. private final TextWatcher mFormulaTextWatcher = new TextWatcher() { @Override @@ -75,7 +121,7 @@ public class Calculator extends Activity @Override public void afterTextChanged(Editable editable) { setState(CalculatorState.INPUT); - mEvaluator.evaluate(editable, Calculator.this); + mEvaluator.evaluateAndShowResult(); } }; @@ -96,26 +142,23 @@ public class Calculator extends Activity } }; - private final Editable.Factory mFormulaEditableFactory = new Editable.Factory() { - @Override - public Editable newEditable(CharSequence source) { - final boolean isEdited = mCurrentState == CalculatorState.INPUT - || mCurrentState == CalculatorState.ERROR; - return new CalculatorExpressionBuilder(source, mTokenizer, isEdited); - } - }; + private static final String NAME = Calculator.class.getName(); + private static final String KEY_DISPLAY_STATE = NAME + "_display_state"; + private static final String KEY_EVAL_STATE = NAME + "_eval_state"; + // Associated value is a byte array holding both mCalculatorState + // and the (much more complex) evaluator state. private CalculatorState mCurrentState; - private CalculatorExpressionTokenizer mTokenizer; - private CalculatorExpressionEvaluator mEvaluator; + private Evaluator mEvaluator; private View mDisplayView; private CalculatorEditText mFormulaEditText; - private CalculatorEditText mResultEditText; + private CalculatorResult mResult; private ViewPager mPadViewPager; private View mDeleteButton; private View mEqualButton; private View mClearButton; + private View mOverflowMenuButton; private View mCurrentButton; private Animator mCurrentAnimator; @@ -127,31 +170,57 @@ public class Calculator extends Activity mDisplayView = findViewById(R.id.display); mFormulaEditText = (CalculatorEditText) findViewById(R.id.formula); - mResultEditText = (CalculatorEditText) findViewById(R.id.result); + mResult = (CalculatorResult) findViewById(R.id.result); mPadViewPager = (ViewPager) findViewById(R.id.pad_pager); mDeleteButton = findViewById(R.id.del); mClearButton = findViewById(R.id.clr); - mEqualButton = findViewById(R.id.pad_numeric).findViewById(R.id.eq); if (mEqualButton == null || mEqualButton.getVisibility() != View.VISIBLE) { mEqualButton = findViewById(R.id.pad_operator).findViewById(R.id.eq); } - - mTokenizer = new CalculatorExpressionTokenizer(this); - mEvaluator = new CalculatorExpressionEvaluator(mTokenizer); - - savedInstanceState = savedInstanceState == null ? Bundle.EMPTY : savedInstanceState; - setState(CalculatorState.values()[ - savedInstanceState.getInt(KEY_CURRENT_STATE, CalculatorState.INPUT.ordinal())]); - mFormulaEditText.setText(mTokenizer.getLocalizedExpression( - savedInstanceState.getString(KEY_CURRENT_EXPRESSION, ""))); - mEvaluator.evaluate(mFormulaEditText.getText(), this); - - mFormulaEditText.setEditableFactory(mFormulaEditableFactory); + mOverflowMenuButton = findViewById(R.id.overflow_menu); + + mEvaluator = new Evaluator(this, mResult); + mResult.setEvaluator(mEvaluator); + + if (savedInstanceState != null) { + setState(CalculatorState.values()[ + savedInstanceState.getInt(KEY_DISPLAY_STATE, + CalculatorState.INPUT.ordinal())]); + byte[] state = + savedInstanceState.getByteArray(KEY_EVAL_STATE); + if (state != null) { + try (ObjectInput in = new ObjectInputStream(new ByteArrayInputStream(state))) { + mEvaluator.restoreInstanceState(in); + } catch (Throwable ignored) { + // When in doubt, revert to clean state + mCurrentState = CalculatorState.INPUT; + mEvaluator.clear(); + } + } + } mFormulaEditText.addTextChangedListener(mFormulaTextWatcher); mFormulaEditText.setOnKeyListener(mFormulaOnKeyListener); mFormulaEditText.setOnTextSizeChangeListener(this); mDeleteButton.setOnLongClickListener(this); + if (mCurrentState == CalculatorState.EVALUATE) { + // Odd case. Evaluation probably took a long time. Let user ask for it again + mCurrentState = CalculatorState.INPUT; + // TODO: This can happen if the user rotates the screen. + // Is this rotate-to-abort behavior correct? Revisit after experimentation. + } + if (mCurrentState != CalculatorState.INPUT) { + setState(CalculatorState.INIT); + mEvaluator.evaluateAndShowResult(); + mEvaluator.requireResult(); + } else { + redisplayFormula(); + mEvaluator.evaluateAndShowResult(); + } + // TODO: We're currently not saving and restoring scroll position. + // We probably should. Details may require care to deal with: + // - new display size + // - slow recomputation if we've scrolled far. } @Override @@ -162,17 +231,26 @@ public class Calculator extends Activity } super.onSaveInstanceState(outState); - - outState.putInt(KEY_CURRENT_STATE, mCurrentState.ordinal()); - outState.putString(KEY_CURRENT_EXPRESSION, - mTokenizer.getNormalizedExpression(mFormulaEditText.getText().toString())); + outState.putInt(KEY_DISPLAY_STATE, mCurrentState.ordinal()); + ByteArrayOutputStream byteArrayStream = new ByteArrayOutputStream(); + try (ObjectOutput out = new ObjectOutputStream(byteArrayStream)) { + mEvaluator.saveInstanceState(out); + } catch (IOException e) { + // Impossible; No IO involved. + throw new AssertionError("Impossible IO exception", e); + } + outState.putByteArray(KEY_EVAL_STATE, byteArrayStream.toByteArray()); } private void setState(CalculatorState state) { if (mCurrentState != state) { + if (state == CalculatorState.INPUT) { + restoreDisplayPositions(); + } mCurrentState = state; - if (state == CalculatorState.RESULT || state == CalculatorState.ERROR) { + if (mCurrentState == CalculatorState.RESULT + || mCurrentState == CalculatorState.ERROR) { mDeleteButton.setVisibility(View.GONE); mClearButton.setVisibility(View.VISIBLE); } else { @@ -180,15 +258,15 @@ public class Calculator extends Activity mClearButton.setVisibility(View.GONE); } - if (state == CalculatorState.ERROR) { + if (mCurrentState == CalculatorState.ERROR) { final int errorColor = getResources().getColor(R.color.calculator_error_color); mFormulaEditText.setTextColor(errorColor); - mResultEditText.setTextColor(errorColor); + mResult.setTextColor(errorColor); getWindow().setStatusBarColor(errorColor); } else { mFormulaEditText.setTextColor( getResources().getColor(R.color.display_formula_text_color)); - mResultEditText.setTextColor( + mResult.setTextColor( getResources().getColor(R.color.display_result_text_color)); getWindow().setStatusBarColor( getResources().getColor(R.color.calculator_accent_color)); @@ -219,10 +297,26 @@ public class Calculator extends Activity } } + public void onButtonClick(View view) { mCurrentButton = view; - - switch (view.getId()) { + int id = view.getId(); + + // Always cancel in-progress evaluation. + // If we were waiting for the result, do nothing else. + mEvaluator.cancelAll(); + if (mCurrentState == CalculatorState.EVALUATE + || mCurrentState == CalculatorState.ANIMATE) { + onCancelled(); + return; + } + switch (id) { + case R.id.overflow_menu: + PopupMenu menu = constructPopupMenu(); + if (menu != null) { + menu.show(); + } + break; case R.id.eq: onEquals(); break; @@ -232,20 +326,33 @@ public class Calculator extends Activity case R.id.clr: onClear(); break; - case R.id.fun_cos: - case R.id.fun_ln: - case R.id.fun_log: - case R.id.fun_sin: - case R.id.fun_tan: - // Add left parenthesis after functions. - mFormulaEditText.append(((Button) view).getText() + "("); - break; default: - mFormulaEditText.append(((Button) view).getText()); + if (mCurrentState == CalculatorState.ERROR) { + setState(CalculatorState.INPUT); + } + if (mCurrentState == CalculatorState.RESULT) { + if (KeyMaps.isBinary(id) || KeyMaps.isSuffix(id)) { + mEvaluator.collapse(); + } else { + mEvaluator.clear(); + } + } + if (!mEvaluator.append(id)) { + // TODO: Some user visible feedback? + } + // TODO: Could do this more incrementally. + redisplayFormula(); + setState(CalculatorState.INPUT); + mResult.clear(); + mEvaluator.evaluateAndShowResult(); break; } } + void redisplayFormula() { + mFormulaEditText.setText(mEvaluator.getExpr().toString(this)); + } + @Override public boolean onLongClick(View view) { mCurrentButton = view; @@ -257,20 +364,28 @@ public class Calculator extends Activity return false; } - @Override - public void onEvaluate(String expr, String result, int errorResourceId) { + // Initial evaluation completed successfully. Initiate display. + public void onEvaluate(int initDisplayPrec, String truncatedWholeNumber) { if (mCurrentState == CalculatorState.INPUT) { - mResultEditText.setText(result); - } else if (errorResourceId != INVALID_RES_ID) { - onError(errorResourceId); - } else if (!TextUtils.isEmpty(result)) { - onResult(result); - } else if (mCurrentState == CalculatorState.EVALUATE) { - // The current expression cannot be evaluated -> return to the input state. - setState(CalculatorState.INPUT); + // Just update small result display. + mResult.displayResult(initDisplayPrec, truncatedWholeNumber); + } else { // in EVALUATE or INIT state + mResult.displayResult(initDisplayPrec, truncatedWholeNumber); + onResult(mCurrentState != CalculatorState.INIT); + setState(CalculatorState.RESULT); } + } - mFormulaEditText.requestFocus(); + public void onCancelled() { + // We should be in EVALUATE state. + // Display is still in input state. + setState(CalculatorState.INPUT); + } + + // Reevaluation completed; ask result to redisplay current value. + public void onReevaluate() + { + mResult.redisplay(); } @Override @@ -302,17 +417,17 @@ public class Calculator extends Activity private void onEquals() { if (mCurrentState == CalculatorState.INPUT) { setState(CalculatorState.EVALUATE); - mEvaluator.evaluate(mFormulaEditText.getText(), this); + mEvaluator.requireResult(); } } private void onDelete() { // Delete works like backspace; remove the last character from the expression. - final Editable formulaText = mFormulaEditText.getEditableText(); - final int formulaLength = formulaText.length(); - if (formulaLength > 0) { - formulaText.delete(formulaLength - 1, formulaLength); - } + mEvaluator.cancelAll(); + mEvaluator.getExpr().delete(); + redisplayFormula(); + mResult.clear(); + mEvaluator.evaluateAndShowResult(); } private void reveal(View sourceView, int colorRes, AnimatorListener listener) { @@ -370,94 +485,221 @@ public class Calculator extends Activity } private void onClear() { - if (TextUtils.isEmpty(mFormulaEditText.getText())) { + if (mEvaluator.getExpr().isEmpty()) { return; } - + mResult.clear(); + mEvaluator.clear(); reveal(mCurrentButton, R.color.calculator_accent_color, new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { - mFormulaEditText.getEditableText().clear(); + redisplayFormula(); } }); } - private void onError(final int errorResourceId) { + // Evaluation encountered en error. Display the error. + void onError(final int errorResourceId) { if (mCurrentState != CalculatorState.EVALUATE) { // Only animate error on evaluate. - mResultEditText.setText(errorResourceId); return; } + setState(CalculatorState.ANIMATE); reveal(mCurrentButton, R.color.calculator_error_color, new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { setState(CalculatorState.ERROR); - mResultEditText.setText(errorResourceId); + mResult.displayError(errorResourceId); } }); } - private void onResult(final String result) { + + // Animate movement of result into the top formula slot. + // Result window now remains translated in the top slot while the result is displayed. + // (We convert it back to formula use only when the user provides new input.) + // Historical note: In the Lollipop version, this invisibly and instantaeously moved + // formula and result displays back at the end of the animation. We no longer do that, + // so that we can continue to properly support scrolling of the result. + // We assume the result already contains the text to be expanded. + private void onResult(boolean animate) { // Calculate the values needed to perform the scale and translation animations, // accounting for how the scale will affect the final position of the text. + // We want to fix the character size in the display to avoid weird effects + // when we scroll. final float resultScale = - mFormulaEditText.getVariableTextSize(result) / mResultEditText.getTextSize(); + mFormulaEditText.getVariableTextSize(mResult.getText().toString()) + / mResult.getTextSize() - 0.1f; + // FIXME: This doesn't work correctly. The -0.1 is a fudge factor to + // improve things slightly. Remove when fixed. final float resultTranslationX = (1.0f - resultScale) * - (mResultEditText.getWidth() / 2.0f - mResultEditText.getPaddingEnd()); + (mResult.getWidth() / 2.0f - mResult.getPaddingEnd()); final float resultTranslationY = (1.0f - resultScale) * - (mResultEditText.getHeight() / 2.0f - mResultEditText.getPaddingBottom()) + - (mFormulaEditText.getBottom() - mResultEditText.getBottom()) + - (mResultEditText.getPaddingBottom() - mFormulaEditText.getPaddingBottom()); + (mResult.getHeight() / 2.0f - mResult.getPaddingBottom()) + + (mFormulaEditText.getBottom() - mResult.getBottom()) + + (mResult.getPaddingBottom() - mFormulaEditText.getPaddingBottom()); final float formulaTranslationY = -mFormulaEditText.getBottom(); - // Use a value animator to fade to the final text color over the course of the animation. - final int resultTextColor = mResultEditText.getCurrentTextColor(); - final int formulaTextColor = mFormulaEditText.getCurrentTextColor(); - final ValueAnimator textColorAnimator = - ValueAnimator.ofObject(new ArgbEvaluator(), resultTextColor, formulaTextColor); - textColorAnimator.addUpdateListener(new AnimatorUpdateListener() { - @Override - public void onAnimationUpdate(ValueAnimator valueAnimator) { - mResultEditText.setTextColor((int) valueAnimator.getAnimatedValue()); - } - }); + // TODO: Reintroduce textColorAnimator? + // The initial and final colors seemed to be the same in L. + // With the new model, the result logically changes back to a formula + // only when we switch back to INPUT state, so it's unclear that animating + // a color change here makes sense. + if (animate) { + final AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether( + ObjectAnimator.ofFloat(mResult, View.SCALE_X, resultScale), + ObjectAnimator.ofFloat(mResult, View.SCALE_Y, resultScale), + ObjectAnimator.ofFloat(mResult, View.TRANSLATION_X, resultTranslationX), + ObjectAnimator.ofFloat(mResult, View.TRANSLATION_Y, resultTranslationY), + ObjectAnimator.ofFloat(mFormulaEditText, View.TRANSLATION_Y, + formulaTranslationY)); + animatorSet.setDuration( + getResources().getInteger(android.R.integer.config_longAnimTime)); + animatorSet.setInterpolator(new AccelerateDecelerateInterpolator()); + animatorSet.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + // Result should already be displayed; no need to do anything. + } + + @Override + public void onAnimationEnd(Animator animation) { + setState(CalculatorState.RESULT); + mCurrentAnimator = null; + } + }); + + mCurrentAnimator = animatorSet; + animatorSet.start(); + } else /* No animation desired; get there fast, e.g. when restarting */ { + mResult.setScaleX(resultScale); + mResult.setScaleY(resultScale); + mResult.setTranslationX(resultTranslationX); + mResult.setTranslationY(resultTranslationY); + mFormulaEditText.setTranslationY(formulaTranslationY); + } + } - final AnimatorSet animatorSet = new AnimatorSet(); - animatorSet.playTogether( - textColorAnimator, - ObjectAnimator.ofFloat(mResultEditText, View.SCALE_X, resultScale), - ObjectAnimator.ofFloat(mResultEditText, View.SCALE_Y, resultScale), - ObjectAnimator.ofFloat(mResultEditText, View.TRANSLATION_X, resultTranslationX), - ObjectAnimator.ofFloat(mResultEditText, View.TRANSLATION_Y, resultTranslationY), - ObjectAnimator.ofFloat(mFormulaEditText, View.TRANSLATION_Y, formulaTranslationY)); - animatorSet.setDuration(getResources().getInteger(android.R.integer.config_longAnimTime)); - animatorSet.setInterpolator(new AccelerateDecelerateInterpolator()); - animatorSet.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationStart(Animator animation) { - mResultEditText.setText(result); - } + // Restore positions of the formula and result displays back to their original, + // pre-animation state. + private void restoreDisplayPositions() { + // Clear result. + mResult.setText(""); + // Reset all of the values modified during the animation. + mResult.setScaleX(1.0f); + mResult.setScaleY(1.0f); + mResult.setTranslationX(0.0f); + mResult.setTranslationY(0.0f); + mFormulaEditText.setTranslationY(0.0f); - @Override - public void onAnimationEnd(Animator animation) { - // Reset all of the values modified during the animation. - mResultEditText.setTextColor(resultTextColor); - mResultEditText.setScaleX(1.0f); - mResultEditText.setScaleY(1.0f); - mResultEditText.setTranslationX(0.0f); - mResultEditText.setTranslationY(0.0f); - mFormulaEditText.setTranslationY(0.0f); - - // Finally update the formula to use the current result. - mFormulaEditText.setText(result); - setState(CalculatorState.RESULT); + mFormulaEditText.requestFocus(); + } + + // Overflow menu handling. + private PopupMenu constructPopupMenu() { + final PopupMenu popupMenu = new PopupMenu(this, mOverflowMenuButton); + mOverflowMenuButton.setOnTouchListener(popupMenu.getDragToOpenListener()); + final Menu menu = popupMenu.getMenu(); + popupMenu.inflate(R.menu.menu); + popupMenu.setOnMenuItemClickListener(this); + onPrepareOptionsMenu(menu); + return popupMenu; + } - mCurrentAnimator = null; + @Override + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_help: + displayHelpMessage(); + return true; + case R.id.menu_about: + displayAboutPage(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + private void displayHelpMessage() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + if (mPadViewPager != null) { + builder.setMessage(getResources().getString(R.string.help_message) + + getResources().getString(R.string.help_pager)); + } else { + builder.setMessage(R.string.help_message); + } + builder.setNegativeButton(R.string.dismiss, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int which) { } + }) + .show(); + } + + private void displayAboutPage() { + WebView wv = new WebView(this); + wv.loadUrl("file:///android_asset/about.txt"); + new AlertDialog.Builder(this) + .setView(wv) + .setNegativeButton(R.string.dismiss, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int which) { } + }) + .show(); + } + + // TODO: Probably delete the following method and all of its callers before release. + // Definitely delete most of its callers. + private static final String LOG_TAG = "Calculator"; + + static void log(String message) { + Log.v(LOG_TAG, message); + } + + // EVERYTHING BELOW HERE was preserved from the KitKat version of the + // calculator, since we expect to need it again once functionality is a bit more + // more complete. But it has not yet been wired in correctly, and + // IS CURRENTLY UNUSED. + + // Is s a valid constant? + // TODO: Possibly generalize to scientific notation, hexadecimal, etc. + static boolean isConstant(CharSequence s) { + boolean sawDecimal = false; + boolean sawDigit = false; + final char decimalPt = DecimalFormatSymbols.getInstance().getDecimalSeparator(); + int len = s.length(); + int i = 0; + while (i < len && Character.isWhitespace(s.charAt(i))) ++i; + if (i < len && s.charAt(i) == '-') ++i; + for (; i < len; ++i) { + char c = s.charAt(i); + if (c == '.' || c == decimalPt) { + if (sawDecimal) return false; + sawDecimal = true; + } else if (Character.isDigit(c)) { + sawDigit = true; + } else { + break; } - }); + } + while (i < len && Character.isWhitespace(s.charAt(i))) ++i; + return i == len && sawDigit; + } - mCurrentAnimator = animatorSet; - animatorSet.start(); + // Paste a valid character sequence representing a constant. + void paste(CharSequence s) { + mEvaluator.cancelAll(); + if (mCurrentState == CalculatorState.RESULT) { + mEvaluator.clear(); + } + int len = s.length(); + for (int i = 0; i < len; ++i) { + char c = s.charAt(i); + if (!Character.isWhitespace(c)) { + mEvaluator.append(KeyMaps.keyForChar(c)); + } + } } + } diff --git a/src/com/android/calculator2/CalculatorEditText.java b/src/com/android/calculator2/CalculatorEditText.java index 5a0d8ba..380aa81 100644 --- a/src/com/android/calculator2/CalculatorEditText.java +++ b/src/com/android/calculator2/CalculatorEditText.java @@ -33,6 +33,10 @@ import android.view.MotionEvent; import android.widget.EditText; import android.widget.TextView; +/** + * EditText adapted for Calculator display. + */ + public class CalculatorEditText extends EditText { private final static ActionMode.Callback NO_SELECTION_ACTION_MODE_CALLBACK = @@ -117,6 +121,8 @@ public class CalculatorEditText extends EditText { setTextSize(TypedValue.COMPLEX_UNIT_PX, getVariableTextSize(getText().toString())); } + public int getWidthConstraint() { return mWidthConstraint; } + @Override public Parcelable onSaveInstanceState() { super.onSaveInstanceState(); diff --git a/src/com/android/calculator2/CalculatorExpr.java b/src/com/android/calculator2/CalculatorExpr.java new file mode 100644 index 0000000..0815a6d --- /dev/null +++ b/src/com/android/calculator2/CalculatorExpr.java @@ -0,0 +1,826 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.calculator2; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.math.BigInteger; +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +import com.hp.creals.CR; +import com.hp.creals.UnaryCRFunction; +import com.hp.creals.PrecisionOverflowError; +import com.hp.creals.AbortedError; + +import android.content.Context; + +// A mathematical expression represented as a sequence of "tokens". +// Many tokes are represented by button ids for the corresponding operator. +// Parsed only when we evaluate the expression using the "eval" method. +class CalculatorExpr { + private ArrayList<Token> mExpr; // The actual representation + // as a list of tokens. Constant + // tokens are always nonempty. + + private static enum TokenKind { CONSTANT, OPERATOR, PRE_EVAL }; + private static TokenKind[] tokenKindValues = TokenKind.values(); + private final static BigInteger BIG_MILLION = BigInteger.valueOf(1000000); + private final static BigInteger BIG_BILLION = BigInteger.valueOf(1000000000); + + private static abstract class Token { + abstract TokenKind kind(); + abstract void write(DataOutput out) throws IOException; + // Implementation writes kind as Byte followed by + // data read by constructor. + abstract String toString(Context context); + // We need the context to convert button ids to strings. + } + + // An operator token + private static class Operator extends Token { + final int mId; // We use the button resource id + Operator(int resId) { + mId = resId; + } + Operator(DataInput in) throws IOException { + mId = in.readInt(); + } + @Override + void write(DataOutput out) throws IOException { + out.writeByte(TokenKind.OPERATOR.ordinal()); + out.writeInt(mId); + } + @Override + public String toString(Context context) { + return KeyMaps.toString(mId, context); + } + @Override + TokenKind kind() { return TokenKind.OPERATOR; } + } + + // A (possibly incomplete) numerical constant + private static class Constant extends Token implements Cloneable { + private boolean mSawDecimal; + String mWhole; // part before decimal point + private String mFraction; // part after decimal point + + Constant() { + mWhole = ""; + mFraction = ""; + mSawDecimal = false; + }; + + Constant(DataInput in) throws IOException { + mWhole = in.readUTF(); + mSawDecimal = in.readBoolean(); + mFraction = in.readUTF(); + } + + @Override + void write(DataOutput out) throws IOException { + out.writeByte(TokenKind.CONSTANT.ordinal()); + out.writeUTF(mWhole); + out.writeBoolean(mSawDecimal); + out.writeUTF(mFraction); + } + + // Given a button press, append corresponding digit. + // We assume id is a digit or decimal point. + // Just return false if this was the second (or later) decimal point + // in this constant. + boolean add(int id) { + if (id == R.id.dec_point) { + if (mSawDecimal) return false; + mSawDecimal = true; + return true; + } + int val = KeyMaps.digVal(id); + if (mSawDecimal) { + mFraction += val; + } else { + mWhole += val; + } + return true; + } + + // Undo the last add. + // Assumes the constant is nonempty. + void delete() { + if (!mFraction.isEmpty()) { + mFraction = mFraction.substring(0, mFraction.length() - 1); + } else if (mSawDecimal) { + mSawDecimal = false; + } else { + mWhole = mWhole.substring(0, mWhole.length() - 1); + } + } + + boolean isEmpty() { + return (mSawDecimal == false && mWhole.isEmpty()); + } + + boolean isInt() { + return !mSawDecimal || mFraction.isEmpty() + || new BigInteger(mFraction).equals(BigInteger.ZERO); + } + + @Override + public String toString() { + String result = mWhole; + if (mSawDecimal) { + result += '.'; + result += mFraction; + } + return result; + } + + @Override + String toString(Context context) { + return toString(); + } + + @Override + TokenKind kind() { return TokenKind.CONSTANT; } + + // Override clone to make it public + @Override + public Object clone() { + Constant res = new Constant(); + res.mWhole = mWhole; + res.mFraction = mFraction; + res.mSawDecimal = mSawDecimal; + return res; + } + } + + // Hash maps used to detect duplicate subexpressions when + // we write out CalculatorExprs and read them back in. + private static final ThreadLocal<IdentityHashMap<CR,Integer>>outMap = + new ThreadLocal<IdentityHashMap<CR,Integer>>(); + // Maps expressions to indices on output + private static final ThreadLocal<HashMap<Integer,PreEval>>inMap = + new ThreadLocal<HashMap<Integer,PreEval>>(); + // Maps expressions to indices on output + private static final ThreadLocal<Integer> exprIndex = + new ThreadLocal<Integer>(); + + static void initExprOutput() { + outMap.set(new IdentityHashMap<CR,Integer>()); + exprIndex.set(Integer.valueOf(0)); + } + + static void initExprInput() { + inMap.set(new HashMap<Integer,PreEval>()); + } + + // We treat previously evaluated subexpressions as tokens + // These are inserted when either: + // - We continue an expression after evaluating some of it. + // - TODO: When we copy/paste expressions. + // The representation includes three different representations + // of the expression: + // 1) The CR value for use in computation. + // 2) The integer value for use in the computations, + // if the expression evaluates to an integer. + // 3a) The corresponding CalculatorExpr, together with + // 3b) The context (currently just deg/rad mode) used to evaluate + // the expression. + // 4) A short string representation that is used to + // Display the expression. + // + // (3) is present only so that we can persist the object. + // (4) is stored explicitly to avoid waiting for recomputation in the UI + // thread. + private static class PreEval extends Token { + final CR mValue; + final BigInteger mIntValue; + private final CalculatorExpr mExpr; + private final EvalContext mContext; + private final String mShortRep; + PreEval(CR val, BigInteger intVal, CalculatorExpr expr, EvalContext ec, + String shortRep) { + mValue = val; + mIntValue = intVal; + mExpr = expr; + mContext = ec; + mShortRep = shortRep; + } + // In writing out PreEvals, we are careful to avoid writing + // out duplicates. We assume that two expressions are + // duplicates if they have the same mVal. This avoids a + // potential exponential blow up in certain off cases and + // redundant evaluation after reading them back in. + // The parameter hash map maps expressions we've seen + // before to their index. + @Override + void write(DataOutput out) throws IOException { + out.writeByte(TokenKind.PRE_EVAL.ordinal()); + Integer index = outMap.get().get(mValue); + if (index == null) { + int nextIndex = exprIndex.get() + 1; + exprIndex.set(nextIndex); + outMap.get().put(mValue, nextIndex); + out.writeInt(nextIndex); + mExpr.write(out); + mContext.write(out); + out.writeUTF(mShortRep); + } else { + // Just write out the index + out.writeInt(index); + } + } + PreEval(DataInput in) throws IOException { + int index = in.readInt(); + PreEval prev = inMap.get().get(index); + if (prev == null) { + mExpr = new CalculatorExpr(in); + mContext = new EvalContext(in); + // Recompute other fields + // We currently do this in the UI thread, but we + // only create PreEval expressions that were + // previously successfully evaluated, and thus + // don't diverge. We also only evaluate to a + // constructive real, which involves substantial + // work only in fairly contrived circumstances. + // TODO: Deal better with slow evaluations. + EvalRet res = mExpr.evalExpr(0,mContext); + mValue = res.mVal; + mIntValue = res.mIntVal; + mShortRep = in.readUTF(); + inMap.get().put(index, this); + } else { + mValue = prev.mValue; + mIntValue = prev.mIntValue; + mExpr = prev.mExpr; + mContext = prev.mContext; + mShortRep = prev.mShortRep; + } + } + @Override + String toString(Context context) { + return mShortRep; + } + @Override + TokenKind kind() { return TokenKind.PRE_EVAL; } + } + + static Token newToken(DataInput in) throws IOException { + TokenKind kind = tokenKindValues[in.readByte()]; + switch(kind) { + case CONSTANT: + return new Constant(in); + case OPERATOR: + return new Operator(in); + case PRE_EVAL: + return new PreEval(in); + default: throw new IOException("Bad save file format"); + } + } + + CalculatorExpr() { + mExpr = new ArrayList<Token>(); + } + + private CalculatorExpr(ArrayList<Token> expr) { + mExpr = expr; + } + + CalculatorExpr(DataInput in) throws IOException { + mExpr = new ArrayList<Token>(); + int size = in.readInt(); + for (int i = 0; i < size; ++i) { + mExpr.add(newToken(in)); + } + } + + void write(DataOutput out) throws IOException { + int size = mExpr.size(); + out.writeInt(size); + for (int i = 0; i < size; ++i) { + mExpr.get(i).write(out); + } + } + + private boolean hasTrailingBinary() { + int s = mExpr.size(); + if (s == 0) return false; + Token t = mExpr.get(s-1); + if (!(t instanceof Operator)) return false; + Operator o = (Operator)t; + return (KeyMaps.isBinary(o.mId)); + } + + // Append press of button with given id to expression. + // Returns false and does nothing if this would clearly + // result in a syntax error. + boolean add(int id) { + int s = mExpr.size(); + int d = KeyMaps.digVal(id); + boolean binary = KeyMaps.isBinary(id); + if (s == 0 && binary && id != R.id.op_sub) return false; + if (binary && hasTrailingBinary() + && (id != R.id.op_sub || isOperator(s-1, R.id.op_sub))) { + return false; + } + boolean isConstPiece = (d != KeyMaps.NOT_DIGIT || id == R.id.dec_point); + if (isConstPiece) { + if (s == 0) { + mExpr.add(new Constant()); + s++; + } else { + Token last = mExpr.get(s-1); + if(!(last instanceof Constant)) { + if (!(last instanceof Operator)) { + return false; + } + int lastOp = ((Operator)last).mId; + if (lastOp == R.id.const_e || lastOp == R.id.const_pi + || lastOp == R.id.op_fact + || lastOp == R.id.rparen) { + // Constant cannot possibly follow; reject immediately + return false; + } + mExpr.add(new Constant()); + s++; + } + } + return ((Constant)(mExpr.get(s-1))).add(id); + } else { + mExpr.add(new Operator(id)); + return true; + } + } + + // Append the contents of the argument expression. + // It is assumed that the argument expression will not change, + // and thus its pieces can be reused directly. + // TODO: We probably only need this for expressions consisting of + // a single PreEval "token", and may want to check that. + void append(CalculatorExpr expr2) { + int s2 = expr2.mExpr.size(); + for (int i = 0; i < s2; ++i) { + mExpr.add(expr2.mExpr.get(i)); + } + } + + // Undo the last key addition, if any. + void delete() { + int s = mExpr.size(); + if (s == 0) return; + Token last = mExpr.get(s-1); + if (last instanceof Constant) { + Constant c = (Constant)last; + c.delete(); + if (!c.isEmpty()) return; + } + mExpr.remove(s-1); + } + + void clear() { + mExpr.clear(); + } + + boolean isEmpty() { + return mExpr.isEmpty(); + } + + // Returns a logical deep copy of the CalculatorExpr. + // Operator and PreEval tokens are immutable, and thus + // aren't really copied. + public Object clone() { + CalculatorExpr res = new CalculatorExpr(); + for (Token t: mExpr) { + if (t instanceof Constant) { + res.mExpr.add((Token)(((Constant)t).clone())); + } else { + res.mExpr.add(t); + } + } + return res; + } + + // Am I just a constant? + boolean isConstant() { + if (mExpr.size() != 1) return false; + return mExpr.get(0) instanceof Constant; + } + + // Return a new expression consisting of a single PreEval token + // representing the current expression. + // The caller supplies the value, degree mode, and short + // string representation, which must have been previously computed. + // Thus this is guaranteed to terminate reasonably quickly. + CalculatorExpr abbreviate(CR val, BigInteger intVal, + boolean dm, String sr) { + CalculatorExpr result = new CalculatorExpr(); + Token t = new PreEval(val, intVal, + new CalculatorExpr( + (ArrayList<Token>)mExpr.clone()), + new EvalContext(dm), sr); + result.mExpr.add(t); + return result; + } + + // Internal evaluation functions return an EvalRet triple. + // We compute integer (BigInteger) results when possible, both as + // a performance optimization, and to detect errors exactly when we can. + private class EvalRet { + int mPos; // Next position (expression index) to be parsed + final CR mVal; // Constructive Real result of evaluating subexpression + final BigInteger mIntVal; // Exact Integer value or null if not integer + EvalRet(int p, CR v, BigInteger i) { + mPos = p; + mVal = v; + mIntVal = i; + } + } + + // And take a context argument: + private static class EvalContext { + // Memory register contents are not included here, + // since we now make that an explicit part of the expression + // If we add any other kinds of evaluation modes, they go here. + boolean mDegreeMode; + EvalContext(boolean degreeMode) { + mDegreeMode = degreeMode; + } + EvalContext(DataInput in) throws IOException { + mDegreeMode = in.readBoolean(); + } + void write(DataOutput out) throws IOException { + out.writeBoolean(mDegreeMode); + } + } + + private final CR RADIANS_PER_DEGREE = CR.PI.divide(CR.valueOf(180)); + + private final CR DEGREES_PER_RADIAN = CR.valueOf(180).divide(CR.PI); + + private CR toRadians(CR x, EvalContext ec) { + if (ec.mDegreeMode) { + return x.multiply(RADIANS_PER_DEGREE); + } else { + return x; + } + } + + private CR fromRadians(CR x, EvalContext ec) { + if (ec.mDegreeMode) { + return x.multiply(DEGREES_PER_RADIAN); + } else { + return x; + } + } + + // The following methods can all throw IndexOutOfBoundsException + // in the event of a syntax error. We expect that to be caught in + // eval below. + + private boolean isOperator(int i, int op) { + if (i >= mExpr.size()) return false; + Token t = mExpr.get(i); + if (!(t instanceof Operator)) return false; + return ((Operator)(t)).mId == op; + } + + static class SyntaxError extends Error { + public SyntaxError() { + super(); + } + public SyntaxError(String s) { + super(s); + } + } + + // The following functions all evaluate some kind of expression + // starting at position i in mExpr in a specified evaluation context. + // They return both the expression value (as constructive real and, + // if applicable, as BigInteger) and the position of the next token + // that was not used as part of the evaluation. + private EvalRet evalUnary(int i, EvalContext ec) + throws ArithmeticException { + Token t = mExpr.get(i); + CR value; + if (t instanceof Constant) { + Constant c = (Constant)t; + value = CR.valueOf(c.toString(),10); + return new EvalRet(i+1, value, + c.isInt()? new BigInteger(c.mWhole) : null); + } + if (t instanceof PreEval) { + PreEval p = (PreEval)t; + return new EvalRet(i+1, p.mValue, p.mIntValue); + } + EvalRet argVal; + switch(((Operator)(t)).mId) { + case R.id.const_pi: + return new EvalRet(i+1, CR.PI, null); + case R.id.const_e: + return new EvalRet(i+1, CR.valueOf(1).exp(), null); + case R.id.op_sqrt: + // Seems to have highest precedence + // Does not add implicit paren + argVal = evalUnary(i+1, ec); + return new EvalRet(argVal.mPos, argVal.mVal.sqrt(), null); + case R.id.lparen: + argVal = evalExpr(i+1, ec); + if (isOperator(argVal.mPos, R.id.rparen)) argVal.mPos++; + return new EvalRet(argVal.mPos, argVal.mVal, argVal.mIntVal); + case R.id.fun_sin: + argVal = evalExpr(i+1, ec); + if (isOperator(argVal.mPos, R.id.rparen)) argVal.mPos++; + return new EvalRet(argVal.mPos, + toRadians(argVal.mVal,ec).sin(), null); + case R.id.fun_cos: + argVal = evalExpr(i+1, ec); + if (isOperator(argVal.mPos, R.id.rparen)) argVal.mPos++; + return new EvalRet(argVal.mPos, + toRadians(argVal.mVal,ec).cos(), null); + case R.id.fun_tan: + argVal = evalExpr(i+1, ec); + if (isOperator(argVal.mPos, R.id.rparen)) argVal.mPos++; + CR argCR = toRadians(argVal.mVal, ec); + return new EvalRet(argVal.mPos, + argCR.sin().divide(argCR.cos()), null); + case R.id.fun_ln: + argVal = evalExpr(i+1, ec); + if (isOperator(argVal.mPos, R.id.rparen)) argVal.mPos++; + return new EvalRet(argVal.mPos, argVal.mVal.ln(), null); + case R.id.fun_log: + argVal = evalExpr(i+1, ec); + if (isOperator(argVal.mPos, R.id.rparen)) argVal.mPos++; + // FIXME: Heuristically test argument sign + return new EvalRet(argVal.mPos, + argVal.mVal.ln().divide(CR.valueOf(10).ln()), + null); + case R.id.fun_arcsin: + argVal = evalExpr(i+1, ec); + if (isOperator(argVal.mPos, R.id.rparen)) argVal.mPos++; + // FIXME: Heuristically test argument in range + return new EvalRet(argVal.mPos, + fromRadians(UnaryCRFunction + .asinFunction.execute(argVal.mVal),ec), + null); + case R.id.fun_arccos: + argVal = evalExpr(i+1, ec); + if (isOperator(argVal.mPos, R.id.rparen)) argVal.mPos++; + // FIXME: Heuristically test argument in range + return new EvalRet(argVal.mPos, + fromRadians(UnaryCRFunction + .acosFunction.execute(argVal.mVal),ec), + null); + case R.id.fun_arctan: + argVal = evalExpr(i+1, ec); + if (isOperator(argVal.mPos, R.id.rparen)) argVal.mPos++; + return new EvalRet(argVal.mPos, + fromRadians(UnaryCRFunction + .atanFunction.execute(argVal.mVal),ec), + null); + default: + throw new SyntaxError("Unrecognized token in expression"); + } + } + + // Generalized factorial. + // Compute n * (n - step) * (n - 2 * step) * ... + // This can be used to compute factorial a bit faster, especially + // if BigInteger uses sub-quadratic multiplication. + private static BigInteger genFactorial(long n, long step) { + if (n > 4 * step) { + BigInteger prod1 = genFactorial(n, 2 * step); + BigInteger prod2 = genFactorial(n - step, 2 * step); + return prod1.multiply(prod2); + } else { + BigInteger res = BigInteger.valueOf(n); + for (long i = n - step; i > 1; i -= step) { + res = res.multiply(BigInteger.valueOf(i)); + } + return res; + } + } + + // Compute an integral power of a constructive real. + // Unlike the "general" case using logarithms, this handles a negative + // base. + private static CR pow(CR base, BigInteger exp) { + if (exp.compareTo(BigInteger.ZERO) < 0) { + return pow(base, exp.negate()).inverse(); + } + if (exp.equals(BigInteger.ONE)) return base; + if (exp.and(BigInteger.ONE).intValue() == 1) { + return pow(base, exp.subtract(BigInteger.ONE)).multiply(base); + } + if (exp.equals(BigInteger.ZERO)) { + return CR.valueOf(1); + } + CR tmp = pow(base, exp.shiftRight(1)); + return tmp.multiply(tmp); + } + + private EvalRet evalFactorial(int i, EvalContext ec) { + EvalRet tmp = evalUnary(i, ec); + int cpos = tmp.mPos; + CR cval = tmp.mVal; + BigInteger ival = tmp.mIntVal; + while (isOperator(cpos, R.id.op_fact)) { + if (ival == null) { + // Assume it was an integer, but we + // didn't figure it out. + // Calculator2 may have used the Gamma function. + ival = cval.BigIntegerValue(); + } + if (ival.compareTo(BigInteger.ZERO) <= 0 + || ival.compareTo(BIG_BILLION) > 0) { + throw new ArithmeticException("Bad factorial argument"); + } + ival = genFactorial(ival.longValue(), 1); + ++cpos; + } + if (ival != null) cval = CR.valueOf(ival); + return new EvalRet(cpos, cval, ival); + } + + private EvalRet evalFactor(int i, EvalContext ec) + throws ArithmeticException { + final EvalRet result1 = evalFactorial(i, ec); + int cpos = result1.mPos; // current position + CR cval = result1.mVal; // value so far + BigInteger ival = result1.mIntVal; // int value so far + if (isOperator(cpos, R.id.op_pow)) { + final EvalRet exp = evalSignedFactor(cpos+1, ec); + cpos = exp.mPos; + if (ival != null && ival.equals(BigInteger.ONE)) { + // 1^x = 1 + return new EvalRet(cpos, cval, ival); + } + if (exp.mIntVal != null) { + if (ival != null + && exp.mIntVal.compareTo(BigInteger.ZERO) >= 0 + && exp.mIntVal.compareTo(BIG_MILLION) < 0 + && ival.abs().compareTo(BIG_MILLION) < 0) { + // Use pure integer exponentiation + ival = ival.pow(exp.mIntVal.intValue()); + cval = CR.valueOf(ival); + } else { + // Integer exponent, cval may be negative; + // use repeated squaring. Unsafe to use ln(). + ival = null; + cval = pow(cval, exp.mIntVal); + } + } else { + ival = null; + cval = cval.ln().multiply(exp.mVal).exp(); + } + } + return new EvalRet(cpos, cval, ival); + } + + private EvalRet evalSignedFactor(int i, EvalContext ec) + throws ArithmeticException { + final boolean negative = isOperator(i, R.id.op_sub); + int cpos = negative? i + 1 : i; + EvalRet tmp = evalFactor(cpos, ec); + cpos = tmp.mPos; + CR cval = negative? tmp.mVal.negate() : tmp.mVal; + BigInteger ival = (negative && tmp.mIntVal != null)? + tmp.mIntVal.negate() + : tmp.mIntVal; + return new EvalRet(cpos, cval, ival); + } + + private boolean canStartFactor(int i) { + if (i >= mExpr.size()) return false; + Token t = mExpr.get(i); + if (!(t instanceof Operator)) return true; + int id = ((Operator)(t)).mId; + if (KeyMaps.isBinary(id)) return false; + switch (id) { + case R.id.op_fact: + case R.id.rparen: + return false; + default: + return true; + } + } + + private EvalRet evalTerm(int i, EvalContext ec) + throws ArithmeticException { + EvalRet tmp = evalSignedFactor(i, ec); + boolean is_mul = false; + boolean is_div = false; + int cpos = tmp.mPos; // Current position in expression. + CR cval = tmp.mVal; // Current value. + BigInteger ival = tmp.mIntVal; + while ((is_mul = isOperator(cpos, R.id.op_mul)) + || (is_div = isOperator(cpos, R.id.op_div)) + || canStartFactor(cpos)) { + if (is_mul || is_div) ++cpos; + tmp = evalTerm(cpos, ec); + if (is_div) { + if (ival != null && tmp.mIntVal != null + && ival.mod(tmp.mIntVal) == BigInteger.ZERO) { + ival = ival.divide(tmp.mIntVal); + cval = CR.valueOf(ival); + } else { + cval = cval.divide(tmp.mVal); + ival = null; + } + } else { + if (ival != null && tmp.mIntVal != null) { + ival = ival.multiply(tmp.mIntVal); + cval = CR.valueOf(ival); + } else { + cval = cval.multiply(tmp.mVal); + ival = null; + } + } + cpos = tmp.mPos; + is_mul = is_div = false; + } + return new EvalRet(cpos, cval, ival); + } + + private EvalRet evalExpr(int i, EvalContext ec) throws ArithmeticException { + EvalRet tmp = evalTerm(i, ec); + boolean is_plus; + int cpos = tmp.mPos; + CR cval = tmp.mVal; + BigInteger ival = tmp.mIntVal; + while ((is_plus = isOperator(cpos, R.id.op_add)) + || isOperator(cpos, R.id.op_sub)) { + tmp = evalTerm(cpos+1, ec); + if (is_plus) { + if (ival != null && tmp.mIntVal != null) { + ival = ival.add(tmp.mIntVal); + cval = CR.valueOf(ival); + } else { + cval = cval.add(tmp.mVal); + ival = null; + } + } else { + if (ival != null && tmp.mIntVal != null) { + ival = ival.subtract(tmp.mIntVal); + cval = CR.valueOf(ival); + } else { + cval = cval.subtract(tmp.mVal); + ival = null; + } + } + cpos = tmp.mPos; + } + return new EvalRet(cpos, cval, ival); + } + + // Externally visible evaluation result. + public class EvalResult { + EvalResult (CR val, BigInteger intVal) { + mVal = val; + mIntVal = intVal; + } + final CR mVal; + final BigInteger mIntVal; + } + + // Evaluate the entire expression, returning null in the event + // of an error. + // Not called from the UI thread, but should not be called + // concurrently with modifications to the expression. + EvalResult eval(boolean degreeMode) throws SyntaxError, + ArithmeticException, PrecisionOverflowError + { + try { + EvalContext ec = new EvalContext(degreeMode); + EvalRet res = evalExpr(0, ec); + if (res.mPos != mExpr.size()) return null; + return new EvalResult(res.mVal, res.mIntVal); + } catch (IndexOutOfBoundsException e) { + throw new SyntaxError("Unexpected expression end"); + } + } + + // Produce a string representation of the expression itself + String toString(Context context) { + StringBuilder sb = new StringBuilder(); + for (Token t: mExpr) { + sb.append(t.toString(context)); + } + return sb.toString(); + } +} diff --git a/src/com/android/calculator2/CalculatorExpressionBuilder.java b/src/com/android/calculator2/CalculatorExpressionBuilder.java deleted file mode 100644 index 7fb47be..0000000 --- a/src/com/android/calculator2/CalculatorExpressionBuilder.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright (C) 2014 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.calculator2; - -import android.content.Context; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; - -public class CalculatorExpressionBuilder extends SpannableStringBuilder { - - private final CalculatorExpressionTokenizer mTokenizer; - private boolean mIsEdited; - - public CalculatorExpressionBuilder( - CharSequence text, CalculatorExpressionTokenizer tokenizer, boolean isEdited) { - super(text); - - mTokenizer = tokenizer; - mIsEdited = isEdited; - } - - @Override - public SpannableStringBuilder replace(int start, int end, CharSequence tb, int tbstart, - int tbend) { - if (start != length() || end != length()) { - mIsEdited = true; - return super.replace(start, end, tb, tbstart, tbend); - } - - String appendExpr = - mTokenizer.getNormalizedExpression(tb.subSequence(tbstart, tbend).toString()); - if (appendExpr.length() == 1) { - final String expr = mTokenizer.getNormalizedExpression(toString()); - switch (appendExpr.charAt(0)) { - case '.': - // don't allow two decimals in the same number - final int index = expr.lastIndexOf('.'); - if (index != -1 && TextUtils.isDigitsOnly(expr.substring(index + 1, start))) { - appendExpr = ""; - } - break; - case '+': - case '*': - case '/': - // don't allow leading operator - if (start == 0) { - appendExpr = ""; - break; - } - - // don't allow multiple successive operators - while (start > 0 && "+-*/".indexOf(expr.charAt(start - 1)) != -1) { - --start; - } - // fall through - case '-': - // don't allow -- or +- - if (start > 0 && "+-".indexOf(expr.charAt(start - 1)) != -1) { - --start; - } - - // mark as edited since operators can always be appended - mIsEdited = true; - break; - default: - break; - } - } - - // since this is the first edit replace the entire string - if (!mIsEdited && appendExpr.length() > 0) { - start = 0; - mIsEdited = true; - } - - appendExpr = mTokenizer.getLocalizedExpression(appendExpr); - return super.replace(start, end, appendExpr, 0, appendExpr.length()); - } -} diff --git a/src/com/android/calculator2/CalculatorExpressionEvaluator.java b/src/com/android/calculator2/CalculatorExpressionEvaluator.java deleted file mode 100644 index 26cd404..0000000 --- a/src/com/android/calculator2/CalculatorExpressionEvaluator.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (C) 2014 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.calculator2; - -import org.javia.arity.Symbols; -import org.javia.arity.SyntaxException; -import org.javia.arity.Util; - -public class CalculatorExpressionEvaluator { - - private static final int MAX_DIGITS = 12; - private static final int ROUNDING_DIGITS = 2; - - private final Symbols mSymbols; - private final CalculatorExpressionTokenizer mTokenizer; - - public CalculatorExpressionEvaluator(CalculatorExpressionTokenizer tokenizer) { - mSymbols = new Symbols(); - mTokenizer = tokenizer; - } - - public void evaluate(CharSequence expr, EvaluateCallback callback) { - evaluate(expr.toString(), callback); - } - - public void evaluate(String expr, EvaluateCallback callback) { - expr = mTokenizer.getNormalizedExpression(expr); - - // remove any trailing operators - while (expr.length() > 0 && "+-/*".indexOf(expr.charAt(expr.length() - 1)) != -1) { - expr = expr.substring(0, expr.length() - 1); - } - - try { - if (expr.length() == 0 || Double.valueOf(expr) != null) { - callback.onEvaluate(expr, null, Calculator.INVALID_RES_ID); - return; - } - } catch (NumberFormatException e) { - // expr is not a simple number - } - - try { - double result = mSymbols.eval(expr); - if (Double.isNaN(result)) { - callback.onEvaluate(expr, null, R.string.error_nan); - } else { - // The arity library uses floating point arithmetic when evaluating the expression - // leading to precision errors in the result. The method doubleToString hides these - // errors; rounding the result by dropping N digits of precision. - final String resultString = mTokenizer.getLocalizedExpression( - Util.doubleToString(result, MAX_DIGITS, ROUNDING_DIGITS)); - callback.onEvaluate(expr, resultString, Calculator.INVALID_RES_ID); - } - } catch (SyntaxException e) { - callback.onEvaluate(expr, null, R.string.error_syntax); - } - } - - public interface EvaluateCallback { - public void onEvaluate(String expr, String result, int errorResourceId); - } -} diff --git a/src/com/android/calculator2/CalculatorExpressionTokenizer.java b/src/com/android/calculator2/CalculatorExpressionTokenizer.java deleted file mode 100644 index b9c91e2..0000000 --- a/src/com/android/calculator2/CalculatorExpressionTokenizer.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (C) 2014 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the 'License'); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an 'AS IS' BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.calculator2; - -import android.content.Context; - -import java.util.HashMap; -import java.util.Map; -import java.util.Map.Entry; - -public class CalculatorExpressionTokenizer { - - private final Map<String, String> mReplacementMap; - - public CalculatorExpressionTokenizer(Context context) { - mReplacementMap = new HashMap<>(); - - mReplacementMap.put(".", context.getString(R.string.dec_point)); - - mReplacementMap.put("0", context.getString(R.string.digit_0)); - mReplacementMap.put("1", context.getString(R.string.digit_1)); - mReplacementMap.put("2", context.getString(R.string.digit_2)); - mReplacementMap.put("3", context.getString(R.string.digit_3)); - mReplacementMap.put("4", context.getString(R.string.digit_4)); - mReplacementMap.put("5", context.getString(R.string.digit_5)); - mReplacementMap.put("6", context.getString(R.string.digit_6)); - mReplacementMap.put("7", context.getString(R.string.digit_7)); - mReplacementMap.put("8", context.getString(R.string.digit_8)); - mReplacementMap.put("9", context.getString(R.string.digit_9)); - - mReplacementMap.put("/", context.getString(R.string.op_div)); - mReplacementMap.put("*", context.getString(R.string.op_mul)); - mReplacementMap.put("-", context.getString(R.string.op_sub)); - - mReplacementMap.put("cos", context.getString(R.string.fun_cos)); - mReplacementMap.put("ln", context.getString(R.string.fun_ln)); - mReplacementMap.put("log", context.getString(R.string.fun_log)); - mReplacementMap.put("sin", context.getString(R.string.fun_sin)); - mReplacementMap.put("tan", context.getString(R.string.fun_tan)); - - mReplacementMap.put("Infinity", context.getString(R.string.inf)); - } - - public String getNormalizedExpression(String expr) { - for (Entry<String, String> replacementEntry : mReplacementMap.entrySet()) { - expr = expr.replace(replacementEntry.getValue(), replacementEntry.getKey()); - } - return expr; - } - - public String getLocalizedExpression(String expr) { - for (Entry<String, String> replacementEntry : mReplacementMap.entrySet()) { - expr = expr.replace(replacementEntry.getKey(), replacementEntry.getValue()); - } - return expr; - } -} diff --git a/src/com/android/calculator2/CalculatorResult.java b/src/com/android/calculator2/CalculatorResult.java new file mode 100644 index 0000000..6c727e0 --- /dev/null +++ b/src/com/android/calculator2/CalculatorResult.java @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.calculator2; + +import android.widget.TextView; +import android.graphics.Typeface; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Color; +import android.widget.OverScroller; +import android.view.GestureDetector; +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.text.Editable; +import android.text.Spanned; +import android.text.SpannableString; +import android.text.style.ForegroundColorSpan; + +import android.support.v4.view.ViewCompat; + + +// A text widget that is "infinitely" scrollable to the right, +// and obtains the text to display via a callback to Logic. +public class CalculatorResult extends CalculatorEditText { + final static int MAX_RIGHT_SCROLL = 100000000; + final static int INVALID = MAX_RIGHT_SCROLL + 10000; + // A larger value is unlikely to avoid running out of space + final OverScroller mScroller; + final GestureDetector mGestureDetector; + class MyTouchListener implements View.OnTouchListener { + @Override + public boolean onTouch(View v, MotionEvent event) { + boolean res = mGestureDetector.onTouchEvent(event); + return res; + } + } + final MyTouchListener mTouchListener = new MyTouchListener(); + private Evaluator mEvaluator; + private boolean mScrollable = false; + // A scrollable result is currently displayed. + private int mCurrentPos;// Position of right of display relative + // to decimal point, in pixels. + // Large positive values mean the decimal + // point is scrolled off the left of the + // display. Zero means decimal point is + // barely displayed on the right. + private int mLastPos; // Position already reflected in display. + private int mMinPos; // Maximum position before all digits + // digits disappear of the right. + private int mCharWidth; // Use monospaced font for now. + // This shouldn't be much harder with a variable + // width font, except it may be even less smooth + // FIXME: This is not really a fixed width font anymore. + private Paint mPaint; // Paint object matching display. + + public CalculatorResult(Context context, AttributeSet attrs) { + super(context, attrs); + mScroller = new OverScroller(context); + mGestureDetector = new GestureDetector(context, + new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, + float velocityX, float velocityY) { + if (!mScroller.isFinished()) { + mCurrentPos = mScroller.getFinalX(); + } + mScroller.forceFinished(true); + CalculatorResult.this.cancelLongPress(); // Ignore scrolls of error string, etc. + if (!mScrollable) return true; + mScroller.fling(mCurrentPos, 0, - (int) velocityX, + 0 /* horizontal only */, mMinPos, + MAX_RIGHT_SCROLL, 0, 0); + ViewCompat.postInvalidateOnAnimation(CalculatorResult.this); + return true; + } + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, + float distanceX, float distanceY) { + // TODO: Should we be dealing with any edge effects here? + if (!mScroller.isFinished()) { + mCurrentPos = mScroller.getFinalX(); + } + mScroller.forceFinished(true); + CalculatorResult.this.cancelLongPress(); + if (!mScrollable) return true; + int duration = (int)(e2.getEventTime() - e1.getEventTime()); + if (duration < 1 || duration > 100) duration = 10; + mScroller.startScroll(mCurrentPos, 0, (int)distanceX, 0, + (int)duration); + ViewCompat.postInvalidateOnAnimation(CalculatorResult.this); + return true; + } + }); + setOnTouchListener(mTouchListener); + setHorizontallyScrolling(false); // do it ourselves + setCursorVisible(false); + setTypeface(Typeface.MONOSPACE); + mPaint = getPaint(); + mCharWidth = (int) mPaint.measureText("5"); + } + + void setEvaluator(Evaluator evaluator) { + mEvaluator = evaluator; + } + + // Display a new result, given initial displayed + // precision and the string representing the whole part of + // the number to be displayed. + // We pass the string, instead of just the length, so we have + // one less place to fix in case we ever decide to use a variable + // width font. + void displayResult(int initPrec, String truncatedWholePart) { + mLastPos = INVALID; + mCurrentPos = initPrec * mCharWidth; + mMinPos = - (int) Math.ceil(mPaint.measureText(truncatedWholePart)); + redisplay(); + } + + // May be called from non-UI thread, but after initialization. + int getCharWidth() { + return mCharWidth; + } + + void displayError(int resourceId) { + mScrollable = false; + setText(resourceId); + } + + // Return entire result (within reason) up to current displayed precision. + public CharSequence getFullText() { + if (!mScrollable) return getText(); + int currentCharPos = mCurrentPos/mCharWidth; + return mEvaluator.getString(currentCharPos, 1000000); + } + + int getMaxChars() { + int result = getWidthConstraint() / mCharWidth; + // FIXME: We can apparently finish evaluating before + // onMeasure in CalculatorEditText has been called, in + // which case we get 0 or -1 as the width constraint. + // Perhaps guess conservatively here and reevaluate + // in InitialResult.onPostExecute? + if (result <= 0) { + return 8; + } else { + return result; + } + } + + void clear() { + setText(""); + } + + void redisplay() { + int currentCharPos = mCurrentPos/mCharWidth; + int maxChars = getMaxChars(); + String result = mEvaluator.getString(currentCharPos, maxChars); + int epos = result.indexOf('e'); + // TODO: Internationalization for decimal point? + if (epos > 0 && result.indexOf('.') == -1) { + // Gray out exponent if used as position indicator + SpannableString formattedResult = new SpannableString(result); + formattedResult.setSpan(new ForegroundColorSpan(Color.GRAY), + epos, result.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + setText(formattedResult); + } else { + setText(result); + } + mScrollable = true; + } + + @Override + public void computeScroll() { + if (!mScrollable) return; + if (mScroller.computeScrollOffset()) { + mCurrentPos = mScroller.getCurrX(); + if (mCurrentPos != mLastPos) { + mLastPos = mCurrentPos; + redisplay(); + } + if (!mScroller.isFinished()) { + ViewCompat.postInvalidateOnAnimation(this); + } + } + } + +} diff --git a/src/com/android/calculator2/Evaluator.java b/src/com/android/calculator2/Evaluator.java new file mode 100644 index 0000000..2007371 --- /dev/null +++ b/src/com/android/calculator2/Evaluator.java @@ -0,0 +1,768 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// TODO: Replace explicit BigInteger arithmetic with BoundedRational. +// Let UI query the required number of digits to display exactly. + +// +// This implements the calculator evaluation logic. +// An evaluation is started with a call to evaluateAndShowResult(). +// This starts an asynchronous computation, which requests display +// of the initial result, when available. When initial evaluation is +// complete, it calls the calculator onEvaluate() method. +// This occurs in a separate event, and may happen quite a bit +// later. Once a result has been computed, and before the underlying +// expression is modified, the getString method may be used to produce +// Strings that represent approximations to various precisions. +// +// Actual expressions being evaluated are represented as CalculatorExprs, +// which are just slightly preprocessed sequences of keypresses. +// +// The Evaluator owns the expression being edited and associated +// state needed for evaluating it. It provides functionality for +// saving and restoring this state. However the current +// CalculatorExpr is exposed to the client, and may be directly modified +// after cancelling any in-progress computations by invoking the +// cancelAll() method. +// +// When evaluation is requested by the user, we invoke the eval +// method on the CalculatorExpr from a background AsyncTask. +// A subsequent getString() callback returns immediately, though it may +// return a result containing placeholder '?' characters. +// In that case we start a background task, which invokes the +// onReevaluate() callback when it completes. +// In both cases, the background task +// computes the appropriate result digits by evaluating +// the constructive real (CR) returned by CalculatorExpr.eval() +// to the required precision. +// +// We cache the best approximation we have already computed. +// We compute generously to allow for +// some scrolling without recomputation and to minimize the chance of +// digits flipping from "0000" to "9999". The best known +// result approximation is maintained as a string by mCache (and +// in a different format by the CR representation of the result). +// When we are in danger of not having digits to display in response +// to further scrolling, we initiate a background computation to higher +// precision. If we actually do fall behind, we display placeholder +// characters, e.g. '?', and schedule a display update when the computation +// completes. +// The code is designed to ensure that the error in the displayed +// result (excluding any '?' characters) is always strictly less than 1 in +// the last displayed digit. Typically we actually display a prefix +// of a result that has this property and additionally is computed to +// a significantly higher precision. Thus we almost always round correctly +// towards zero. (Fully correct rounding towards zero is not computable.) + +package com.android.calculator2; + +import android.text.TextUtils; +import android.view.KeyEvent; +import android.widget.EditText; +import android.content.Context; +import android.content.res.Resources; +import android.os.AsyncTask; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.os.Handler; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.math.BigInteger; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; +import java.text.DecimalFormatSymbols; + +import com.hp.creals.CR; +import com.hp.creals.PrecisionOverflowError; +import com.hp.creals.AbortedError; + +class Evaluator { + private final Calculator mCalculator; + private final CalculatorResult mResult; // The result display View + private CalculatorExpr mExpr; // Current calculator expression + // The following are valid only if an evaluation + // completed successfully. + private CR mVal; // value of mExpr as constructive real + private BigInteger mIntVal; // value of mExpr as int or null + private int mLastDigs; // Last digit argument passed to getString() + // for this result, or the initial preferred + // precision. + private boolean mDegreeMode; // Currently in degree (not radian) mode + private final Handler mTimeoutHandler; + + static final char MINUS = '\u2212'; + + static final BigInteger BIG_MILLION = BigInteger.valueOf(1000000); + + + private final char decimalPt = + DecimalFormatSymbols.getInstance().getDecimalSeparator(); + + private final static int MAX_DIGITS = 100; // Max digits displayed at once. + private final static int EXTRA_DIGITS = 20; + // Extra computed digits to minimize probably we will have + // to change our minds about digits we already displayed. + // (The correct digits are technically not computable using our + // representation: An off by one error in the last digits + // can affect earlier ones, even though the display is + // always within one in the lsd. This is only visible + // for results that end in EXTRA_DIGITS 9s or 0s, but are + // not integers.) + // We do use these extra digits to display while we are + // computing the correct answer. Thus they may be + // temporarily visible. + private final static int PRECOMPUTE_DIGITS = 20; + // Extra digits computed to minimize + // reevaluations during scrolling. + + // We cache the result as a string to accelerate scrolling. + // The cache is filled in by the UI thread, but this may + // happen asynchronously, much later than the request. + private String mCache; // Current best known result, which includes + private int mCacheDigs = 0; // mCacheDigs digits to the right of the + // decimal point. Always positive. + // mCache is valid when non-null + // unless the expression has been + // changed since the last evaluation call. + private int mCacheDigsReq; // Number of digits that have been + // requested. Only touched by UI + // thread. + private final int INVALID_MSD = Integer.MIN_VALUE; + private int mMsd = INVALID_MSD; // Position of most significant digit + // in current cached result, if determined. + // This is just the index in mCache + // holding the msd. + private final int MAX_MSD_PREC = 100; + // The largest number of digits to the right + // of the decimal point to which we will + // evaluate to compute proper scientific + // notation for values close to zero. + + private AsyncReevaluator mCurrentReevaluator; + // The one and only un-cancelled and currently running reevaluator. + // Touched only by UI thread. + + private AsyncDisplayResult mEvaluator; + // Currently running expression evaluator, if any. + + Evaluator(Calculator calculator, + CalculatorResult resultDisplay) { + mCalculator = calculator; + mResult = resultDisplay; + mExpr = new CalculatorExpr(); + mTimeoutHandler = new Handler(); + } + + // Result of asynchronous reevaluation + class ReevalResult { + ReevalResult(String s, int p) { + mNewCache = s; + mNewCacheDigs = p; + } + final String mNewCache; + final int mNewCacheDigs; + } + + // Compute new cache contents accurate to prec digits to the right + // of the decimal point. Ensure that redisplay() is called after + // doing so. If the evaluation fails for reasons other than a + // timeout, ensure that DisplayError() is called. + class AsyncReevaluator extends AsyncTask<Integer, Void, ReevalResult> { + @Override + protected ReevalResult doInBackground(Integer... prec) { + try { + int eval_prec = prec[0].intValue(); + return new ReevalResult(mVal.toString(eval_prec), eval_prec); + } catch(ArithmeticException e) { + return null; + } catch(PrecisionOverflowError e) { + return null; + } catch(AbortedError e) { + // Should only happen if the task was cancelled, + // in which case we don't look at the result. + return null; + } + } + @Override + protected void onPostExecute(ReevalResult result) { + if (result == null) { + // This should only be possible in the extremely rare + // case of encountering a domain error while reevaluating. + mCalculator.onError(R.string.error_nan); + } else { + if (result.mNewCacheDigs < mCacheDigs) { + throw new Error("Unexpected onPostExecute timing"); + } + mCache = result.mNewCache; + mCacheDigs = result.mNewCacheDigs; + mCalculator.onReevaluate(); + } + mCurrentReevaluator = null; + } + // On cancellation we do nothing; invoker should have + // left no trace of us. + } + + // Result of initial asynchronous computation + private static class InitialResult { + InitialResult(CR val, BigInteger intVal, String s, int p, int idp) { + mErrorResourceId = Calculator.INVALID_RES_ID; + mVal = val; + mIntVal = intVal; + mNewCache = s; + mNewCacheDigs = p; + mInitDisplayPrec = idp; + } + InitialResult(int errorResourceId) { + mErrorResourceId = errorResourceId; + mVal = CR.valueOf(0); + mIntVal = BigInteger.valueOf(0); + mNewCache = "BAD"; + mNewCacheDigs = 0; + mInitDisplayPrec = 0; + } + boolean isError() { + return mErrorResourceId != Calculator.INVALID_RES_ID; + } + final int mErrorResourceId; + final CR mVal; + final BigInteger mIntVal; + final String mNewCache; // Null iff it can't be computed. + final int mNewCacheDigs; + final int mInitDisplayPrec; + } + + private void displayCancelledMessage() { + AlertDialog.Builder builder = new AlertDialog.Builder(mCalculator); + builder.setMessage(R.string.cancelled); + builder.setPositiveButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int which) { } + }); + builder.create(); + } + + private final long MAX_TIMEOUT = 60000; + // Milliseconds. + // Longer is unlikely to help unless + // we get more heap space. + private long mTimeout = 2000; // Timeout for requested evaluations, + // in milliseconds. + // This is currently not saved and restored + // with the state; we initially + // reenable the timeout when the + // calculator is restarted. + // We'll call that a feature; others + // might argue it's a bug. + private final long mQuickTimeout = 50; + // Timeout for unrequested, speculative + // evaluations, in milliseconds. + + private void displayTimeoutMessage() { + AlertDialog.Builder builder = new AlertDialog.Builder(mCalculator); + builder.setMessage(R.string.timeout); + builder.setNegativeButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int which) { } + }); + builder.setPositiveButton(R.string.ok_remove_timeout, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int which) { + mTimeout = MAX_TIMEOUT; + } + }); + builder.create().show(); + } + + final Runnable mTimeoutRunnable = new Runnable () { + public void run () { + if (cancelAll()) { + displayTimeoutMessage(); + } + } + }; + + final Runnable mQuickTimeoutRunnable = new Runnable () { + public void run () { + // Quietly cancel; we didn't really need it. + cancelAll(); + } + }; + + // Compute initial cache contents and result when we're good and ready. + // We leave the expression display up, with scrolling + // disabled, until this computation completes. + // Can result in an error display if something goes wrong. + // By default we set a timeout to catch runaway computations. + class AsyncDisplayResult extends AsyncTask<Void, Void, InitialResult> { + private boolean mDm; // degrees + private boolean mRequired; // Result was requested by user. + AsyncDisplayResult(boolean dm, boolean required) { + mDm = dm; + mRequired = required; + } + @Override + protected void onPreExecute() { + long timeout = mRequired? mTimeout : mQuickTimeout; + if (timeout != 0) { + mTimeoutHandler.postDelayed( + (mRequired? mTimeoutRunnable : mQuickTimeoutRunnable), + timeout); + } + } + @Override + protected InitialResult doInBackground(Void... nothing) { + try { + CalculatorExpr.EvalResult res = mExpr.eval(mDm); + if (res == null) return null; + int prec = 3; // Enough for short representation + String initCache = res.mVal.toString(prec); + int msd = getMsdPos(initCache); + if (res.mIntVal == null && msd == INVALID_MSD) { + prec = MAX_MSD_PREC; + initCache = res.mVal.toString(prec); + msd = getMsdPos(initCache); + } + int initDisplayPrec = getPreferredPrec(initCache, msd, + res.mIntVal != null); + int newPrec = initDisplayPrec + EXTRA_DIGITS; + if (newPrec > prec) { + prec = newPrec; + initCache = res.mVal.toString(prec); + } + return new InitialResult(res.mVal, res.mIntVal, + initCache, prec, initDisplayPrec); + } catch (CalculatorExpr.SyntaxError e) { + return new InitialResult(R.string.error_syntax); + } catch(ArithmeticException e) { + return new InitialResult(R.string.error_nan); + } catch(PrecisionOverflowError e) { + // Extremely unlikely unless we're actually dividing by + // zero or the like. + return new InitialResult(R.string.error_overflow); + } catch(AbortedError e) { + return new InitialResult(R.string.error_aborted); + } + } + @Override + protected void onPostExecute(InitialResult result) { + mEvaluator = null; + mTimeoutHandler.removeCallbacks(mTimeoutRunnable); + if (result.isError()) { + mCalculator.onError(result.mErrorResourceId); + return; + } + mVal = result.mVal; + mIntVal = result.mIntVal; + mCache = result.mNewCache; + mCacheDigs = result.mNewCacheDigs; + mLastDigs = result.mInitDisplayPrec; + int dotPos = mCache.indexOf('.'); + String truncatedWholePart = mCache.substring(0, dotPos); + mCalculator.onEvaluate(result.mInitDisplayPrec,truncatedWholePart); + } + @Override + protected void onCancelled(InitialResult result) { + mTimeoutHandler.removeCallbacks(mTimeoutRunnable); + displayCancelledMessage(); + mCalculator.onCancelled(); + // Just drop the evaluation; Leave expression displayed. + return; + } + } + + + // Start an evaluation to prec, and ensure that the + // display is redrawn when it completes. + private void ensureCachePrec(int prec) { + if (mCache != null && mCacheDigs >= prec + || mCacheDigsReq >= prec) return; + if (mCurrentReevaluator != null) { + // Ensure we only have one evaluation running at a time. + mCurrentReevaluator.cancel(true); + mCurrentReevaluator = null; + } + mCurrentReevaluator = new AsyncReevaluator(); + mCacheDigsReq = prec + PRECOMPUTE_DIGITS; + mCurrentReevaluator.execute(mCacheDigsReq); + } + + // Retrieve the preferred precision for the currently + // displayed result, given the number of characters we + // have room for and the current string approximation for + // the result. + // May be called in non-UI thread. + int getPreferredPrec(String cache, int msd, boolean isInt) { + int lineLength = mResult.getMaxChars(); + int wholeSize = cache.indexOf('.'); + if (isInt && wholeSize <= lineLength) { + // Prefer to display as integer, without decimal point + return -1; + } + if (msd > wholeSize && msd <= wholeSize + 4) { + // Display number without scientific notation. + // Treat leading zero as msd. + msd = wholeSize - 1; + } + return Math.min(msd, MAX_MSD_PREC) - wholeSize + lineLength - 2; + } + + // Get a short representation of the value represented by + // the string cache (presumed to contain at least 5 characters) + // and possibly the exact integer i. + private String getShortString(String cache, BigInteger i) { + if (i != null && i.abs().compareTo(BIG_MILLION) < 0) { + return(i.toString()); + } else { + String res = cache.substring(0,5); + // Avoid a trailing period; doesn't work with ellipsis + if (res.charAt(3) != '.') { + res = res.substring(0,4); + } + return res + mCalculator.getResources() + .getString(R.string.ellipsis); + } + } + + // Return the most significant digit position in the given string + // or INVALID_MSD. + private int getMsdPos(String s) { + int len = s.length(); + int nonzeroPos = -1; + for (int i = 0; i < len; ++i) { + char c = s.charAt(i); + if (c != '-' && c != '.' && c != '0') { + nonzeroPos = i; + break; + } + } + if (nonzeroPos >= 0 && + (nonzeroPos < len - 1 || s.charAt(nonzeroPos) != '1')) { + return nonzeroPos; + } else { + // Unknown, or could change on reevaluation + return INVALID_MSD; + } + + } + + // Return most significant digit position in the cache, if determined, + // INVALID_MSD ow. + // If unknown, and we've computed less than DESIRED_PREC, + // schedule reevaluation and redisplay, with higher precision. + int getMsd() { + if (mMsd != INVALID_MSD) return mMsd; + if (mIntVal != null && mIntVal.compareTo(BigInteger.ZERO) == 0) { + return INVALID_MSD; // None exists + } + int res = INVALID_MSD; + if (mCache != null) { + res = getMsdPos(mCache); + } + if (res == INVALID_MSD && mEvaluator == null + && mCurrentReevaluator == null && mCacheDigs < MAX_MSD_PREC) { + // We assert that mCache is not null, since there is no + // evaluator running. + ensureCachePrec(MAX_MSD_PREC); + // Could reevaluate more incrementally, but we suspect that if + // we have to reevaluate at all, the result is probably zero. + } + return res; + } + + // Return a string with n placeholder characters. + private String getPadding(int n) { + StringBuilder padding = new StringBuilder(); + char something = + mCalculator.getResources() + .getString(R.string.guessed_digit).charAt(0); + for (int i = 0; i < n; ++i) { + padding.append(something); + } + return padding.toString(); + } + + // Return the number of zero characters at the beginning of s + private int leadingZeroes(String s) { + int res = 0; + int len = s.length(); + for (res = 0; res < len && s.charAt(res) == '0'; ++res) {} + return res; + } + + // TODO: The following should be refactored, particularly since + // maxDigs should probably depend on the width of characters in + // the result. + // And we should try to at leas factor out the code to add the exponent. + // + // Return result to digs digits to the right of the decimal point + // (minus any space occupied by exponent included in the result). + // The result should be no longer than maxDigs. + // The result is returned immediately, based on the + // current cache contents, but it may contain question + // marks for unknown digits. It may also use uncertain + // digits within EXTRA_DIGITS. If either of those occurred, + // schedule a reevaluation and redisplay operation. + // digs may be negative to only retrieve digits to the left + // of the decimal point. (digs = 0 means we include + // the decimal point, but nothing to the right. Digs = -1 + // means we drop the decimal point and start at the ones + // position. Should not be invoked if mVal is null. + String getString(int digs, int maxDigs) { + mLastDigs = digs; + // Make sure we eventually get a complete answer + ensureCachePrec(digs + EXTRA_DIGITS); + if (mCache == null) { + // Nothing to do now; seems to happen on rare occasion + // with weird user input timing; will be fixed later. + return getPadding(1); + } + // Compute an appropriate substring of mCache. + // We avoid returning a huge string to minimize string + // allocation during scrolling. + // Pad as needed. + boolean truncated = false; // Leading digits dropped. + int len = mCache.length(); + // Don't scroll left past leftmost digit in mCache. + int integralDigits = len - mCacheDigs; + // includes 1 for dec. pt + if (mCache.charAt(0) == '-') --integralDigits; + if (digs < -integralDigits + 1) digs = -integralDigits + 1; + int offset = mCacheDigs - digs; // trailing digits to drop + int deficit = 0; // The number of digits we're short + if (offset < 0) { + offset = 0; + deficit = Math.min(digs - mCacheDigs, maxDigs); + } + int endIndx = len - offset; + if (endIndx < 1) return " "; + int startIndx = (endIndx + deficit <= maxDigs) ? + 0 + : endIndx + deficit - maxDigs; + String res; + if (startIndx != 0) { + truncated = true; + } + res = mCache.substring(startIndx, endIndx); + if (deficit > 0) { + res = res + getPadding(deficit); + // Since we always compute past the decimal point, + // this never fills in the spot where the decimal point + // should go, and the rest of this can treat the + // made-up symbols as though they were digits. + } + // Include exponent if necessary. + // Replace least significant digits as necessary. + if (res.indexOf('.') == -1 && digs != 1) { + // No decimal point displayed, and it's not just + // to the right of the last digit. + // Add an exponent to let the user track which + // digits are currently displayed. + // This is a bit tricky, since the number of displayed + // digits affects the displayed exponent, which can + // affect the room we have for mantissa digits. + // We occasionally display one digit too few. + // This is sometimes unavoidable, but we could + // avoid it in more cases. + int exp = (digs > 0)? -digs : -digs - 1; + // accounts for decimal point + int msd = getMsd(); + boolean hasPoint = false; + final int minFractionDigits = 6; + if (msd < endIndx - minFractionDigits && msd >= startIndx) { + // Leading digit is in display window + // Use standard calculator scientific notation + // with one digit to the left of the decimal point. + // Insert decimal point and delete leading zeroes. + int hasMinus = mCache.charAt(0) == '-'? 1 : 0; + int resLen = res.length(); + int resZeroes = leadingZeroes(res); + String fraction = + res.substring(msd+1 - startIndx, + resLen - 1 - hasMinus); + res = (hasMinus != 0? "-" : "") + + mCache.substring(msd, msd+1) + "." + + fraction; + exp += resLen - resZeroes - 1 - hasMinus; + // Decimal point moved across original res, except for + // leading digit and zeroes, and possibly minus sign. + truncated = false; // in spite of dropping leading 0s + hasPoint = true; + } + if (exp != 0 || truncated) { + String expAsString = Integer.toString(exp); + int expDigits = expAsString.length(); + int resLen = res.length(); + int dropDigits = resLen + expDigits + 1 - maxDigs; + if (dropDigits < 0) { + dropDigits = 0; + } else { + if (!hasPoint) { + exp += dropDigits; + // Adjust for digits we are about to drop + // to drop to make room for exponent. + // This can affect the room we have for the + // mantissa. We adjust only for positive exponents, + // when it could otherwise result in a truncated + // displayed result. + if (exp > 0 && dropDigits > 0 && + Integer.toString(exp).length() > expDigits) { + // ++expDigits; (dead code) + ++dropDigits; + ++exp; + // This cannot increase the length a second time. + } + } + res = res.substring(0, resLen - dropDigits); + } + res = res + "e" + exp; + } // else don't add zero exponent + } + if (truncated) { + res = mCalculator.getResources().getString(R.string.ellipsis) + + res.substring(1, res.length()); + } + // TODO: This and/or CR formatting needs to deal with + // internationalization. + // Probably use java.text.DecimalFormatSymbols directly + return res; + } + + private void clearCache() { + mCache = null; + mCacheDigs = mCacheDigsReq = 0; + mMsd = INVALID_MSD; + } + + void clear() { + mExpr.clear(); + clearCache(); + } + + // Begin evaluation of result and display when ready + void evaluateAndShowResult() { + if (mEvaluator == null) { + clearCache(); + mEvaluator = new AsyncDisplayResult(mDegreeMode, false); + mEvaluator.execute(); + } // else already in progress + } + + // Ensure that we either display a result or complain. + // Does not invalidate a previously computed cache. + void requireResult() { + if (mCache == null) { + // Restart evaluator in requested mode, i.e. with + // longer timeout. + cancelAll(); + mEvaluator = new AsyncDisplayResult(mDegreeMode, true); + mEvaluator.execute(); + } else { + // Notify immediately. + int dotPos = mCache.indexOf('.'); + String truncatedWholePart = mCache.substring(0, dotPos); + mCalculator.onEvaluate(mLastDigs,truncatedWholePart); + } + } + + // Cancel all current background tasks. + // Return true if we cancelled an initial evaluation, + // leaving the expression displayed. + boolean cancelAll() { + if (mCurrentReevaluator != null) { + Calculator.log("Cancelling reevaluator"); + mCurrentReevaluator.cancel(true); + mCacheDigsReq = mCacheDigs; + // Backgound computation touches only constructive reals. + // OK not to wait. + mCurrentReevaluator = null; + } + if (mEvaluator != null) { + Calculator.log("Cancelling evaluator"); + mEvaluator.cancel(true); + // There seems to be no good way to wait for cancellation + // to complete, and the evaluation continues to look at + // mExpr, which we will again modify. + // Give ourselves a new copy to work on instead. + mExpr = (CalculatorExpr)mExpr.clone(); + // Approximation of constructive reals should be thread-safe, + // so we can let that continue until it notices the cancellation. + mEvaluator = null; + return true; + } + return false; + } + + void restoreInstanceState(DataInput in) { + try { + CalculatorExpr.initExprInput(); + mDegreeMode = in.readBoolean(); + mExpr = new CalculatorExpr(in); + } catch (IOException e) { + Calculator.log("" + e); + } + } + + void saveInstanceState(DataOutput out) { + try { + CalculatorExpr.initExprOutput(); + out.writeBoolean(mDegreeMode); + mExpr.write(out); + } catch (IOException e) { + Calculator.log("" + e); + } + } + + // Append a button press to the current expression. + // Return false if we rejected the addition due to obvious + // syntax issues, and the expression is unchanged. + // Return true otherwise. + boolean append(int id) { + return mExpr.add(id); + } + + // Abbreviate the current expression to a pre-evaluated + // expression node, which will display as a short number. + // This should not be called unless the expression was + // previously evaluated and produced a non-error result. + // Pre-evaluated expressions can never represent an + // expression for which evaluation to a constructive real + // diverges. Subsequent re-evaluation will also not diverge, + // though it may generate errors of various kinds. + // E.g. sqrt(-10^-1000) + void collapse () { + CalculatorExpr abbrvExpr = mExpr.abbreviate( + mVal, mIntVal, mDegreeMode, + getShortString(mCache, mIntVal)); + clear(); + mExpr.append(abbrvExpr); + } + + // Retrieve the main expression being edited. + // It is the callee's reponsibility to call cancelAll to cancel + // ongoing concurrent computations before modifying the result. + // TODO: Perhaps add functionality so we can keep this private? + CalculatorExpr getExpr() { + return mExpr; + } + +} diff --git a/src/com/android/calculator2/KeyMaps.java b/src/com/android/calculator2/KeyMaps.java new file mode 100644 index 0000000..48557c2 --- /dev/null +++ b/src/com/android/calculator2/KeyMaps.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.calculator2; + +import android.content.res.Resources; +import android.content.Context; +import android.view.View; +import java.text.DecimalFormatSymbols; + +public class KeyMaps { + // Map key id to corresponding (internationalized) display string + public static String toString(int id, Context context) { + Resources res = context.getResources(); + switch(id) { + case R.id.const_pi: return res.getString(R.string.const_pi); + case R.id.const_e: return res.getString(R.string.const_e); + case R.id.op_sqrt: return res.getString(R.string.op_sqrt); + case R.id.op_fact: return res.getString(R.string.op_fact); + case R.id.fun_sin: return res.getString(R.string.fun_sin) + + res.getString(R.string.lparen); + case R.id.fun_cos: return res.getString(R.string.fun_cos) + + res.getString(R.string.lparen); + case R.id.fun_tan: return res.getString(R.string.fun_tan) + + res.getString(R.string.lparen); + case R.id.fun_arcsin: return res.getString(R.string.fun_arcsin) + + res.getString(R.string.lparen); + case R.id.fun_arccos: return res.getString(R.string.fun_arccos) + + res.getString(R.string.lparen); + case R.id.fun_arctan: return res.getString(R.string.fun_arctan) + + res.getString(R.string.lparen); + case R.id.fun_ln: return res.getString(R.string.fun_ln) + + res.getString(R.string.lparen); + case R.id.fun_log: return res.getString(R.string.fun_log) + + res.getString(R.string.lparen); + case R.id.lparen: return res.getString(R.string.lparen); + case R.id.rparen: return res.getString(R.string.rparen); + case R.id.op_pow: return res.getString(R.string.op_pow); + case R.id.op_mul: return res.getString(R.string.op_mul); + case R.id.op_div: return res.getString(R.string.op_div); + case R.id.op_add: return res.getString(R.string.op_add); + case R.id.op_sub: return res.getString(R.string.op_sub); + case R.id.dec_point: return res.getString(R.string.dec_point); + case R.id.digit_0: return res.getString(R.string.digit_0); + case R.id.digit_1: return res.getString(R.string.digit_1); + case R.id.digit_2: return res.getString(R.string.digit_2); + case R.id.digit_3: return res.getString(R.string.digit_3); + case R.id.digit_4: return res.getString(R.string.digit_4); + case R.id.digit_5: return res.getString(R.string.digit_5); + case R.id.digit_6: return res.getString(R.string.digit_6); + case R.id.digit_7: return res.getString(R.string.digit_7); + case R.id.digit_8: return res.getString(R.string.digit_8); + case R.id.digit_9: return res.getString(R.string.digit_9); + default: return "?oops?"; + } + } + + // Does a button id correspond to a binary operator? + public static boolean isBinary(int id) { + switch(id) { + case R.id.op_pow: + case R.id.op_mul: + case R.id.op_div: + case R.id.op_add: + case R.id.op_sub: + return true; + default: + return false; + } + } + + // Does a button id correspond to a suffix operator? + public static boolean isSuffix(int id) { + return id == R.id.op_fact; + } + + public static final int NOT_DIGIT = 10; + + // Map key id to digit or NOT_DIGIT + public static int digVal(int id) { + switch (id) { + case R.id.digit_0: + return 0; + case R.id.digit_1: + return 1; + case R.id.digit_2: + return 2; + case R.id.digit_3: + return 3; + case R.id.digit_4: + return 4; + case R.id.digit_5: + return 5; + case R.id.digit_6: + return 6; + case R.id.digit_7: + return 7; + case R.id.digit_8: + return 8; + case R.id.digit_9: + return 9; + default: + return NOT_DIGIT; + } + } + + // Map digit to corresponding key. Inverse of above. + public static int keyForDigVal(int v) { + switch(v) { + case 0: + return R.id.digit_0; + case 1: + return R.id.digit_1; + case 2: + return R.id.digit_2; + case 3: + return R.id.digit_3; + case 4: + return R.id.digit_4; + case 5: + return R.id.digit_5; + case 6: + return R.id.digit_6; + case 7: + return R.id.digit_7; + case 8: + return R.id.digit_8; + case 9: + return R.id.digit_9; + default: + return View.NO_ID; + } + } + + final static char decimalPt = + DecimalFormatSymbols.getInstance().getDecimalSeparator(); + + // Return the button id corresponding to the supplied character + // or NO_ID + // TODO: Should probably also check on characters used as button + // labels. But those don't really seem to be internationalized. + public static int keyForChar(char c) { + if (Character.isDigit(c)) { + int i = Character.digit(c, 10); + return KeyMaps.keyForDigVal(i); + } + if (c == decimalPt) return R.id.dec_point; + switch (c) { + case '.': + return R.id.dec_point; + case '-': + return R.id.op_sub; + case '+': + return R.id.op_add; + case '*': + return R.id.op_mul; + case '/': + return R.id.op_div; + case 'e': + return R.id.const_e; + case '^': + return R.id.op_pow; + default: + return View.NO_ID; + } + } +} |
