diff options
Diffstat (limited to 'taler-kotlin-android/src')
6 files changed, 267 insertions, 22 deletions
diff --git a/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt b/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt index 750a1de..3e3bd0a 100644 --- a/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt +++ b/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt @@ -16,11 +16,15 @@ package net.taler.common +import android.os.Build import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.Serializer import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.text.NumberFormat import kotlin.math.floor import kotlin.math.pow import kotlin.math.roundToInt @@ -54,6 +58,11 @@ public data class Amount( * of 50_000_000 would correspond to 50 cents. */ val fraction: Int, + + /** + * Currency specification for amount + */ + val spec: CurrencySpecification? = null, ) : Comparable<Amount> { public companion object { @@ -65,12 +74,6 @@ public data class Amount( private const val MAX_FRACTION_LENGTH = 8 public const val MAX_FRACTION: Int = 99_999_999 - public fun fromDouble(currency: String, value: Double): Amount { - val intPart = floor(value).toLong() - val fraPart = floor((value - intPart) * FRACTIONAL_BASE).toInt() - return Amount(currency, intPart, fraPart) - } - public fun zero(currency: String): Amount { return Amount(checkCurrency(currency), 0, 0) } @@ -96,6 +99,7 @@ public data class Amount( } public fun isValidAmountStr(str: String): Boolean { + if (str.count { it == '.' } > 1) return false val split = str.split(".") try { checkValue(split[0].toLongOrNull()) @@ -175,6 +179,8 @@ public data class Amount( return Amount(checkCurrency(currency), this.value, this.fraction) } + fun withSpec(spec: CurrencySpecification?) = copy(spec = spec) + public operator fun minus(other: Amount): Amount { check(currency == other.currency) { "Can only subtract from same currency" } var resultValue = value @@ -201,8 +207,50 @@ public data class Amount( return "$currency:$amountStr" } - override fun toString(): String { - return "$amountStr $currency" + override fun toString() = toString( + showSymbol = true, + negative = false, + ) + + fun toString( + showSymbol: Boolean = true, + negative: Boolean = false, + symbols: DecimalFormatSymbols = DecimalFormat().decimalFormatSymbols, + ): String { + // We clone the object to safely/cleanly modify it + val s = symbols.clone() as DecimalFormatSymbols + val amount = (if (negative) "-$amountStr" else amountStr).toBigDecimal() + + // No currency spec, so we render normally + if (spec == null) { + val format = NumberFormat.getInstance() + format.maximumFractionDigits = MAX_FRACTION_LENGTH + format.minimumFractionDigits = 0 + if (Build.VERSION.SDK_INT >= 34) { + s.groupingSeparator = s.monetaryGroupingSeparator + } + s.decimalSeparator = s.monetaryDecimalSeparator + (format as DecimalFormat).decimalFormatSymbols = s + + val fmt = format.format(amount) + return if (showSymbol) "$fmt $currency" else fmt + } + + // There is currency spec, so we can do things right + val format = NumberFormat.getCurrencyInstance() + format.maximumFractionDigits = spec.numFractionalNormalDigits + format.minimumFractionDigits = spec.numFractionalTrailingZeroDigits + s.currencySymbol = spec.symbol ?: "" + (format as DecimalFormat).decimalFormatSymbols = s + + val fmt = format.format(amount) + return if (showSymbol) { + // If no symbol, then we use the currency string + if (spec.symbol != null) fmt else "$fmt $currency" + } else { + // We should do better than manually removing the symbol here + fmt.replace(s.currencySymbol, "").trim() + } } override fun compareTo(other: Amount): Int { diff --git a/taler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt b/taler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt index 066184c..8f3e5d5 100644 --- a/taler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt +++ b/taler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt @@ -23,6 +23,7 @@ import android.content.Context.CONNECTIVITY_SERVICE import android.content.Intent import android.net.ConnectivityManager import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET +import android.net.Uri import android.os.Build.VERSION.SDK_INT import android.os.Looper import android.text.format.DateUtils.DAY_IN_MILLIS @@ -120,6 +121,23 @@ fun Context.startActivitySafe(intent: Intent) { } } +fun Context.openUri(uri: String, title: String) { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(uri) + } + + startActivitySafe(Intent.createChooser(intent, title)) +} + +fun Context.shareText(text: String) { + val intent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_TEXT, text) + type = "text/plain" + } + + startActivitySafe(Intent.createChooser(intent, null)) +} + fun Fragment.navigate(directions: NavDirections) = findNavController().navigate(directions) fun Long.toRelativeTime(context: Context): CharSequence { diff --git a/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt b/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt index 910cc36..5b614fe 100644 --- a/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt +++ b/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt @@ -42,7 +42,7 @@ abstract class Product { abstract val productId: String? abstract val description: String abstract val descriptionI18n: Map<String, String>? - abstract val price: Amount + abstract val price: Amount? abstract val location: String? abstract val image: String? val localizedDescription: String @@ -60,14 +60,14 @@ data class ContractProduct( override val description: String, @SerialName("description_i18n") override val descriptionI18n: Map<String, String>? = null, - override val price: Amount, + override val price: Amount? = null, @SerialName("delivery_location") override val location: String? = null, override val image: String? = null, - val quantity: Int + val quantity: Int = 1, ) : Product() { - val totalPrice: Amount by lazy { - price * quantity + val totalPrice: Amount? by lazy { + price?.let { price * quantity } } } diff --git a/taler-kotlin-android/src/main/java/net/taler/common/CurrencySpecification.kt b/taler-kotlin-android/src/main/java/net/taler/common/CurrencySpecification.kt new file mode 100644 index 0000000..02113f4 --- /dev/null +++ b/taler-kotlin-android/src/main/java/net/taler/common/CurrencySpecification.kt @@ -0,0 +1,36 @@ +/* + * This file is part of GNU Taler + * (C) 2024 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.common + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CurrencySpecification( + val name: String, + @SerialName("num_fractional_input_digits") + val numFractionalInputDigits: Int, + @SerialName("num_fractional_normal_digits") + val numFractionalNormalDigits: Int, + @SerialName("num_fractional_trailing_zero_digits") + val numFractionalTrailingZeroDigits: Int, + @SerialName("alt_unit_names") + val altUnitNames: Map<Int, String>, +) { + // TODO: add support for alt units + val symbol: String? get() = altUnitNames[0] +}
\ No newline at end of file diff --git a/taler-kotlin-android/src/main/java/net/taler/common/Event.kt b/taler-kotlin-android/src/main/java/net/taler/common/Event.kt index 752e20e..868063c 100644 --- a/taler-kotlin-android/src/main/java/net/taler/common/Event.kt +++ b/taler-kotlin-android/src/main/java/net/taler/common/Event.kt @@ -49,7 +49,7 @@ fun <T> T.toEvent() = Event(this) * [onEvent] is *only* called if the [Event]'s contents has not been consumed. */ class EventObserver<T>(private val onEvent: (T) -> Unit) : Observer<Event<T>> { - override fun onChanged(event: Event<T>?) { - event?.getIfNotConsumed()?.let { onEvent(it) } + override fun onChanged(value: Event<T>) { + value.getIfNotConsumed()?.let { onEvent(it) } } } diff --git a/taler-kotlin-android/src/test/java/net/taler/common/AmountTest.kt b/taler-kotlin-android/src/test/java/net/taler/common/AmountTest.kt index 7072426..1ea4e70 100644 --- a/taler-kotlin-android/src/test/java/net/taler/common/AmountTest.kt +++ b/taler-kotlin-android/src/test/java/net/taler/common/AmountTest.kt @@ -16,10 +16,12 @@ package net.taler.common +import android.os.Build import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import java.text.DecimalFormatSymbols import kotlin.random.Random class AmountTest { @@ -41,7 +43,6 @@ class AmountTest { assertEquals("TESTKUDOS", amount.currency) assertEquals(23, amount.value) assertEquals((0.42 * 1e8).toInt(), amount.fraction) - assertEquals("23.42 TESTKUDOS", amount.toString()) str = "EUR:500000000.00000001" amount = Amount.fromJSONString(str) @@ -49,7 +50,6 @@ class AmountTest { assertEquals("EUR", amount.currency) assertEquals(500000000, amount.value) assertEquals(1, amount.fraction) - assertEquals("500000000.00000001 EUR", amount.toString()) str = "EUR:1500000000.00000003" amount = Amount.fromJSONString(str) @@ -57,14 +57,158 @@ class AmountTest { assertEquals("EUR", amount.currency) assertEquals(1500000000, amount.value) assertEquals(3, amount.fraction) - assertEquals("1500000000.00000003 EUR", amount.toString()) } @Test fun testToString() { - Amount.fromString("BITCOINBTC", "0.00000001").let { amount -> - assertEquals("0.00000001 BITCOINBTC", amount.toString()) - assertEquals("0.00000001", amount.amountStr) + amountToString( + amount = Amount.fromString("KUDOS", "13.71"), + spec = CurrencySpecification( + name = "Test (Taler Demostrator)", + numFractionalInputDigits = 2, + numFractionalNormalDigits = 2, + numFractionalTrailingZeroDigits = 2, + altUnitNames = mapOf(0 to "ク"), + ), + rawStr = "13.71", + fraction = 71000000, + specAmount = "13.71", + noSpecAmount = "13.71", + currency = "KUDOS", + symbol = "ク", + ) + + amountToString( + amount = Amount.fromString("TESTKUDOS", "23.42"), + spec = CurrencySpecification( + name = "Test (Taler Unstable Demostrator)", + numFractionalInputDigits = 0, + numFractionalNormalDigits = 0, + numFractionalTrailingZeroDigits = 0, + altUnitNames = mapOf(0 to "テ"), + ), + rawStr = "23.42", + fraction = 42000000, + specAmount = "23", + noSpecAmount = "23.42", + currency = "TESTKUDOS", + symbol = "テ", + ) + + amountToString( + amount = Amount.fromString("BITCOINBTC", "0.00000001"), + spec = CurrencySpecification( + name = "Bitcoin", + numFractionalInputDigits = 8, + numFractionalNormalDigits = 8, + numFractionalTrailingZeroDigits = 0, + altUnitNames = mapOf( + 0 to "₿", + // TODO: uncomment when units get implemented + // and then write tests for units, please +// -1 to "d₿", +// -2 to "c₿", +// -3 to "m₿", +// -6 to "µ₿", +// -8 to "sat", + ), + ), + rawStr = "0.00000001", + fraction = 1, + specAmount = "0.00000001", + noSpecAmount = "0.00000001", + currency = "BITCOINBTC", + symbol = "₿", + ) + + val specEUR = CurrencySpecification( + name = "EUR", + numFractionalInputDigits = 2, + numFractionalNormalDigits = 2, + numFractionalTrailingZeroDigits = 2, + altUnitNames = mapOf(0 to "€"), + ) + + amountToString( + amount = Amount.fromString("EUR", "1500000000.00000003"), + spec = specEUR, + rawStr = "1500000000.00000003", + fraction = 3, + specAmount = "1,500,000,000.00", + noSpecAmount = "1,500,000,000.00000003", + currency = "EUR", + symbol = "€", + ) + + amountToString( + amount = Amount.fromString("EUR", "500000000.126"), + spec = specEUR, + rawStr = "500000000.126", + fraction = 12600000, + specAmount = "500,000,000.13", + noSpecAmount = "500,000,000.126", + currency = "EUR", + symbol = "€", + ) + + amountToString( + amount = Amount.fromString("NOSYMBOL", "13.24"), + spec = CurrencySpecification( + name = "No symbol!", + numFractionalInputDigits = 2, + numFractionalNormalDigits = 2, + numFractionalTrailingZeroDigits = 2, + altUnitNames = mapOf(), + ), + rawStr = "13.24", + fraction = 24000000, + specAmount = "13.24", + noSpecAmount = "13.24", + currency = "NOSYMBOL", + symbol = "NOSYMBOL", + ) + } + + private fun amountToString( + amount: Amount, + spec: CurrencySpecification, + rawStr: String, + fraction: Int, + specAmount: String, + noSpecAmount: String, + currency: String, + symbol: String, + ) { + val symbols = DecimalFormatSymbols.getInstance() + symbols.decimalSeparator = '.' + symbols.groupingSeparator = ',' + symbols.monetaryDecimalSeparator = '.' + if (Build.VERSION.SDK_INT >= 34) { + symbols.monetaryGroupingSeparator = ',' + } + + // Only the raw amount + assertEquals(rawStr, amount.amountStr) + assertEquals(fraction, amount.fraction) + + // The amount without currency spec + assertEquals("$noSpecAmount $currency", amount.toString(symbols = symbols)) + assertEquals(noSpecAmount, amount.toString(symbols = symbols, showSymbol = false)) + assertEquals("-$noSpecAmount $currency", amount.toString(symbols = symbols, negative = true)) + assertEquals("-$noSpecAmount", amount.toString(symbols = symbols, showSymbol = false, negative = true)) + + // The amount with currency spec + val withSpec = amount.withSpec(spec) + assertEquals(specAmount, withSpec.toString(symbols = symbols, showSymbol = false)) + assertEquals(specAmount, withSpec.toString(symbols = symbols, showSymbol = false)) + assertEquals("-$specAmount", withSpec.toString(symbols = symbols, showSymbol = false, negative = true)) + assertEquals("-$specAmount", withSpec.toString(symbols = symbols, showSymbol = false, negative = true)) + if (spec.symbol != null) { + assertEquals("${symbol}$specAmount", withSpec.toString(symbols = symbols)) + assertEquals("-${symbol}$specAmount", withSpec.toString(symbols = symbols, negative = true)) + } else { + assertEquals("$specAmount $currency", withSpec.toString(symbols = symbols)) + assertEquals("-$specAmount $currency", withSpec.toString(symbols = symbols, negative = true)) } } @@ -76,7 +220,6 @@ class AmountTest { assertEquals(str, amount.toJSONString()) assertEquals("TESTKUDOS123", amount.currency) assertEquals(maxValue, amount.value) - assertEquals("$maxValue.99999999 TESTKUDOS123", amount.toString()) // longer currency not accepted assertThrows<AmountParserException>("longer currency was accepted") { |