diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt index 19e4b0e41e..8e4d5be382 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt @@ -3,17 +3,15 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.boost import android.animation.Animator import android.animation.AnimatorSet import android.animation.ObjectAnimator -import android.text.InputFilter -import android.text.SpannableStringBuilder +import android.text.Editable import android.text.Spanned import android.text.TextWatcher import android.text.method.DigitsKeyListener -import android.view.Gravity import android.view.View -import android.view.inputmethod.EditorInfo -import android.widget.TextView +import androidx.annotation.VisibleForTesting import androidx.appcompat.widget.AppCompatEditText import androidx.core.animation.doOnEnd +import androidx.core.text.isDigitsOnly import com.google.android.material.button.MaterialButton import org.signal.core.util.money.FiatMoney import org.thoughtcrime.securesms.R @@ -23,12 +21,14 @@ import org.thoughtcrime.securesms.components.settings.PreferenceModel import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.util.MappingAdapter import org.thoughtcrime.securesms.util.MappingViewHolder +import org.thoughtcrime.securesms.util.StringUtil import org.thoughtcrime.securesms.util.ViewUtil -import org.thoughtcrime.securesms.util.text.AfterTextChanged -import org.thoughtcrime.securesms.util.visible +import java.lang.Integer.min import java.text.DecimalFormatSymbols import java.text.NumberFormat import java.util.Currency +import java.util.Locale +import java.util.regex.Pattern /** * A Signal Boost is a one-time ephemeral show of support. Each boost level @@ -122,12 +122,8 @@ data class Boost( private val boost4: MaterialButton = itemView.findViewById(R.id.boost_4) private val boost5: MaterialButton = itemView.findViewById(R.id.boost_5) private val boost6: MaterialButton = itemView.findViewById(R.id.boost_6) - private val currencyStart: TextView = itemView.findViewById(R.id.boost_currency_start) - private val currencyEnd: TextView = itemView.findViewById(R.id.boost_currency_end) private val custom: AppCompatEditText = itemView.findViewById(R.id.boost_custom) - private var textChangedWatcher: TextWatcher? = null - private val boostButtons: List get() { return if (ViewUtil.isLtr(context)) { @@ -137,6 +133,8 @@ data class Boost( } } + private var filter: MoneyFilter? = null + init { custom.filters = emptyArray() } @@ -159,33 +157,20 @@ data class Boost( } } - currencyStart.text = model.currency.symbol - currencyEnd.text = model.currency.symbol + if (filter == null || filter?.currency != model.currency) { + custom.removeTextChangedListener(filter) - if (model.currency.defaultFractionDigits > 0) { - custom.inputType = EditorInfo.TYPE_CLASS_NUMBER or EditorInfo.TYPE_NUMBER_FLAG_DECIMAL - custom.filters = arrayOf(DecimalPlacesFilter(model.currency.defaultFractionDigits, custom.keyListener as DigitsKeyListener)) - } else { - custom.inputType = EditorInfo.TYPE_CLASS_NUMBER - custom.filters = arrayOf() + filter = MoneyFilter(model.currency, custom) { + model.onCustomAmountChanged(it) + } + + custom.keyListener = filter + custom.addTextChangedListener(filter) + + custom.setText("") } - custom.removeTextChangedListener(textChangedWatcher) - - textChangedWatcher = AfterTextChanged { - model.onCustomAmountChanged(it.toString()) - } - - custom.addTextChangedListener(textChangedWatcher) - custom.setText("") - custom.setOnFocusChangeListener { _, hasFocus -> - val isCurrencyAtFrontOfNumber = currencyIsAtFrontOfNumber(model.currency) - - currencyStart.visible = isCurrencyAtFrontOfNumber && hasFocus - currencyEnd.visible = !isCurrencyAtFrontOfNumber && hasFocus - - custom.gravity = if (hasFocus) (Gravity.START or Gravity.CENTER_VERTICAL) else Gravity.CENTER model.onCustomAmountFocusChanged(hasFocus) } @@ -196,51 +181,6 @@ data class Boost( custom.clearFocus() } } - - private fun currencyIsAtFrontOfNumber(currency: Currency): Boolean { - val formatter = NumberFormat.getCurrencyInstance().apply { - this.currency = currency - } - return formatter.format(1).startsWith(currency.symbol) - } - } - - /** - * Restricts output of the given Digits filter to the given number of decimal places. - */ - private class DecimalPlacesFilter(private val decimalPlaces: Int, private val digitsKeyListener: DigitsKeyListener) : InputFilter { - - private val decimalSeparator = DecimalFormatSymbols.getInstance().decimalSeparator - private val builder = SpannableStringBuilder() - - override fun filter(source: CharSequence, start: Int, end: Int, dest: Spanned, dstart: Int, dend: Int): CharSequence? { - val keyListenerResult = digitsKeyListener.filter(source, start, end, dest, dstart, dend) - - builder.clear() - builder.clearSpans() - - val toInsert = keyListenerResult ?: source.substring(start, end) - - builder.append(dest) - - if (dstart == dend) { - builder.insert(dstart, toInsert) - } else { - builder.replace(dstart, dend, toInsert) - } - - val separatorIndex = builder.indexOf(decimalSeparator) - return if (separatorIndex > -1) { - val suffix = builder.split(decimalSeparator).last() - if (suffix.length > decimalPlaces) { - dest.subSequence(dstart, dend) - } else { - null - } - } else { - null - } - } } private class HeadingViewHolder(itemView: View) : MappingViewHolder(itemView) { @@ -252,6 +192,121 @@ data class Boost( } } + @VisibleForTesting + class MoneyFilter(val currency: Currency, private val text: AppCompatEditText? = null, private val onCustomAmountChanged: (String) -> Unit = {}) : DigitsKeyListener(false, true), TextWatcher { + + val separator = DecimalFormatSymbols.getInstance().decimalSeparator + val separatorCount = min(1, currency.defaultFractionDigits) + val symbol: String = currency.getSymbol(Locale.getDefault()) + + /** + * From Character.isDigit: + * + * * '\u0030' through '\u0039', ISO-LATIN-1 digits ('0' through '9') + * * '\u0660' through '\u0669', Arabic-Indic digits + * * '\u06F0' through '\u06F9', Extended Arabic-Indic digits + * * '\u0966' through '\u096F', Devanagari digits + * * '\uFF10' through '\uFF19', Fullwidth digits + */ + val digitsGroup: String = "[\\u0030-\\u0039]|[\\u0660-\\u0669]|[\\u06F0-\\u06F9]|[\\u0966-\\u096F]|[\\uFF10-\\uFF19]" + val zeros: String = "\\u0030|\\u0660|\\u06F0|\\u0966|\\uFF10" + + val pattern: Pattern = "($digitsGroup)*([$separator]){0,$separatorCount}($digitsGroup){0,${currency.defaultFractionDigits}}".toPattern() + val symbolPattern: Regex = """\s*${Regex.escape(symbol)}\s*""".toRegex() + val leadingZeroesPattern: Regex = """^($zeros)*""".toRegex() + + override fun filter( + source: CharSequence, + start: Int, + end: Int, + dest: Spanned, + dstart: Int, + dend: Int + ): CharSequence? { + + val result = dest.subSequence(0, dstart).toString() + source.toString() + dest.subSequence(dend, dest.length) + val resultWithoutCurrencyPrefix = StringUtil.stripBidiIndicator(result.removePrefix(symbol).removeSuffix(symbol).trim()) + + if (resultWithoutCurrencyPrefix.length == 1 && !resultWithoutCurrencyPrefix.isDigitsOnly() && resultWithoutCurrencyPrefix != separator.toString()) { + return dest.subSequence(dstart, dend) + } + + val matcher = pattern.matcher(resultWithoutCurrencyPrefix) + + if (!matcher.matches()) { + return dest.subSequence(dstart, dend) + } + + return null + } + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + + override fun afterTextChanged(s: Editable?) { + if (s.isNullOrEmpty()) return + + val hasSymbol = s.startsWith(symbol) || s.endsWith(symbol) + if (hasSymbol && symbolPattern.matchEntire(s.toString()) != null) { + s.clear() + } else if (!hasSymbol) { + val formatter = NumberFormat.getCurrencyInstance() + formatter.currency = currency + + if (s.contains(separator)) { + formatter.minimumFractionDigits = s.split(separator).last().length + } else { + formatter.minimumFractionDigits = 0 + } + + formatter.maximumFractionDigits = currency.defaultFractionDigits + + val value = s.toString().toDoubleOrNull() + + if (value != null) { + val formatted = formatter.format(value) + + text?.removeTextChangedListener(this) + + s.replace(0, s.length, formatted) + if (formatted.endsWith(symbol)) { + val result: MatchResult? = symbolPattern.find(formatted) + if (result != null && result.range.first < s.length) { + text?.setSelection(result.range.first) + } + } + + text?.addTextChangedListener(this) + } + } + + val withoutSymbol = s.removePrefix(symbol).removeSuffix(symbol).trim().toString() + val withoutLeadingZeroes: String = try { + NumberFormat.getInstance().apply { + isGroupingUsed = false + + if (s.contains(separator)) { + minimumFractionDigits = s.split(separator).last().length + } + }.format(withoutSymbol.toBigDecimal()) + (if (withoutSymbol.endsWith(separator)) separator else "") + } catch (e: NumberFormatException) { + withoutSymbol + } + + if (withoutSymbol != withoutLeadingZeroes) { + text?.removeTextChangedListener(this) + + val start = s.indexOf(withoutSymbol) + s.replace(start, start + withoutSymbol.length, withoutLeadingZeroes) + + text?.addTextChangedListener(this) + } + + onCustomAmountChanged(s.removePrefix(symbol).removeSuffix(symbol).trim().toString()) + } + } + companion object { fun register(adapter: MappingAdapter) { adapter.registerFactory(SelectionModel::class.java, MappingAdapter.LayoutFactory({ SelectionViewHolder(it) }, R.layout.boost_preference)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt index 09b79ae5b2..66c5e5d6ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.util.InternetConnectionObserver import org.thoughtcrime.securesms.util.PlatformCurrencyUtil import org.thoughtcrime.securesms.util.StringUtil import org.thoughtcrime.securesms.util.livedata.Store +import java.lang.NumberFormatException import java.math.BigDecimal import java.text.DecimalFormat import java.text.DecimalFormatSymbols @@ -193,8 +194,7 @@ class BoostViewModel( store.update { it.copy( isCustomAmountFocused = false, - selectedBoost = boost, - customAmount = FiatMoney(BigDecimal.ZERO, it.currencySelection) + selectedBoost = boost ) } } @@ -218,9 +218,7 @@ class BoostViewModel( } fun setCustomAmountFocused(isFocused: Boolean) { - store.update { - it.copy(isCustomAmountFocused = isFocused) - } + store.update { it.copy(isCustomAmountFocused = isFocused) } } private data class BoostInfo(val boosts: List, val defaultBoost: Boost?, val boostBadge: Badge, val supportedCurrencies: Set) diff --git a/app/src/main/res/layout/boost_preference.xml b/app/src/main/res/layout/boost_preference.xml index 7c5d6a4dc3..69963d6bd1 100644 --- a/app/src/main/res/layout/boost_preference.xml +++ b/app/src/main/res/layout/boost_preference.xml @@ -112,23 +112,6 @@ app:strokeWidth="1.5dp" tools:text="$100" /> - - - \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostTest__MoneyFilter.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostTest__MoneyFilter.kt new file mode 100644 index 0000000000..1c1cef3e63 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostTest__MoneyFilter.kt @@ -0,0 +1,234 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.boost + +import android.app.Application +import android.text.SpannableStringBuilder +import junit.framework.Assert.assertEquals +import junit.framework.Assert.assertNotNull +import junit.framework.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Currency +import java.util.Locale + +@Suppress("ClassName") +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class BoostTest__MoneyFilter { + + private val usd = Currency.getInstance("USD") + private val yen = Currency.getInstance("JPY") + private val inr = Currency.getInstance("INR") + + @Before + fun setUp() { + Locale.setDefault(Locale.US) + } + + @Test + fun `Given USD, when I enter 5, then I expect $ 5`() { + val testSubject = Boost.MoneyFilter(usd) + val editable = SpannableStringBuilder("5") + + testSubject.afterTextChanged(editable) + + assertEquals("$5", editable.toString()) + } + + @Test + fun `Given USD, when I enter 5dot00, then I expect successful filter`() { + val testSubject = Boost.MoneyFilter(usd) + val editable = SpannableStringBuilder("5.00") + val dest = SpannableStringBuilder() + + testSubject.afterTextChanged(editable) + val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) + + assertNull(filterResult) + } + + @Test + fun `Given USD, when I enter 5dot00, then I expect 5 from text change`() { + var result = "" + val testSubject = Boost.MoneyFilter(usd) { + result = it + } + + val editable = SpannableStringBuilder("5.00") + testSubject.afterTextChanged(editable) + + assertEquals("5.00", result) + } + + @Test + fun `Given USD, when I enter 00005dot00, then I expect 5 from text change`() { + val testSubject = Boost.MoneyFilter(usd) + val editable = SpannableStringBuilder("00005.00") + + testSubject.afterTextChanged(editable) + + assertEquals("$5.00", editable.toString()) + } + + @Test + fun `Given USD, when I enter 5dot000, then I expect successful filter`() { + val testSubject = Boost.MoneyFilter(yen) + val editable = SpannableStringBuilder("5.000") + val dest = SpannableStringBuilder() + + testSubject.afterTextChanged(editable) + val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) + + assertNull(filterResult) + } + + @Test + fun `Given USD, when I enter 5dot, then I expect successful filter`() { + val testSubject = Boost.MoneyFilter(usd) + val editable = SpannableStringBuilder("5.") + val dest = SpannableStringBuilder() + + testSubject.afterTextChanged(editable) + val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) + + assertNull(filterResult) + } + + @Test + fun `Given JPY, when I enter 5, then I expect yen 5`() { + val testSubject = Boost.MoneyFilter(yen) + val editable = SpannableStringBuilder("5") + + testSubject.afterTextChanged(editable) + + assertEquals("¥5", editable.toString()) + } + + @Test + fun `Given JPY, when I enter 5, then I expect 5 from text change`() { + var result = "" + val testSubject = Boost.MoneyFilter(yen) { + result = it + } + + val editable = SpannableStringBuilder("5") + + testSubject.afterTextChanged(editable) + + assertEquals("5", result) + } + + @Test + fun `Given JPY, when I enter 5, then I expect successful filter`() { + val testSubject = Boost.MoneyFilter(yen) + val editable = SpannableStringBuilder("5") + val dest = SpannableStringBuilder() + + testSubject.afterTextChanged(editable) + val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) + + assertNull(filterResult) + } + + @Test + fun `Given JPY, when I enter 5dot, then I expect unsuccessful filter`() { + val testSubject = Boost.MoneyFilter(yen) + val editable = SpannableStringBuilder("¥5.") + val dest = SpannableStringBuilder() + + testSubject.afterTextChanged(editable) + val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) + + assertNotNull(filterResult) + } + + @Test + fun `Given MR and INR, when I enter 5dot55, then I expect localized`() { + Locale.setDefault(Locale.forLanguageTag("mr")) + + val testSubject = Boost.MoneyFilter(inr) + val editable = SpannableStringBuilder("5.55") + + testSubject.afterTextChanged(editable) + + assertEquals("₹५.५५", editable.toString()) + } + + @Test + fun `Given MR and INR, when I enter dot, then I expect it to be retained in output`() { + Locale.setDefault(Locale.forLanguageTag("mr")) + + val testSubject = Boost.MoneyFilter(inr) + val editable = SpannableStringBuilder("₹५.") + + testSubject.afterTextChanged(editable) + + assertEquals("₹५.", editable.toString()) + } + + @Test + fun `Given RTL indicator, when I enter five, then I expect successful match`() { + val testSubject = Boost.MoneyFilter(yen) + val editable = SpannableStringBuilder("\u200F5") + val dest = SpannableStringBuilder() + + testSubject.afterTextChanged(editable) + val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) + + assertNull(filterResult) + } + + @Test + fun `Given USD, when I enter 1dot05, then I expect 1dot05`() { + var result = "" + val testSubject = Boost.MoneyFilter(usd) { + result = it + } + + val editable = SpannableStringBuilder("$1.05") + testSubject.afterTextChanged(editable) + + assertEquals("1.05", result) + } + + @Test + fun `Given USD, when I enter 0dot05, then I expect 0dot05`() { + var result = "" + val testSubject = Boost.MoneyFilter(usd) { + result = it + } + + val editable = SpannableStringBuilder("$0.05") + testSubject.afterTextChanged(editable) + + assertEquals("0.05", result) + } + + @Test + fun `Given USD, when I enter dot1, then I expect 0dot1`() { + var result = "" + val testSubject = Boost.MoneyFilter(usd) { + result = it + } + + val editable = SpannableStringBuilder("$.1") + testSubject.afterTextChanged(editable) + + assertEquals("0.1", result) + } + + @Test + fun `Given USD, when I enter dot0, then I expect 0dot0`() { + var result = "" + val testSubject = Boost.MoneyFilter(usd) { + result = it + } + + val editable = SpannableStringBuilder(".0") + testSubject.afterTextChanged(editable) + + assertEquals("0.0", result) + } +}