diff options
Diffstat (limited to 'taler-kotlin-common')
6 files changed, 520 insertions, 33 deletions
diff --git a/taler-kotlin-common/build.gradle b/taler-kotlin-common/build.gradle index 1d45a54..1c53839 100644 --- a/taler-kotlin-common/build.gradle +++ b/taler-kotlin-common/build.gradle @@ -60,4 +60,9 @@ dependencies { // JSON parsing and serialization implementation "com.fasterxml.jackson.module:jackson-module-kotlin:2.10.2" + + lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha' + + testImplementation 'junit:junit:4.13' + testImplementation 'org.json:json:20190722' } diff --git a/taler-kotlin-common/src/main/java/net/taler/common/Amount.kt b/taler-kotlin-common/src/main/java/net/taler/common/Amount.kt index 0389db1..48bd643 100644 --- a/taler-kotlin-common/src/main/java/net/taler/common/Amount.kt +++ b/taler-kotlin-common/src/main/java/net/taler/common/Amount.kt @@ -16,47 +16,185 @@ package net.taler.common +import android.annotation.SuppressLint +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.databind.deser.std.StdDeserializer import org.json.JSONObject +import java.lang.Math.floorDiv +import kotlin.math.pow +import kotlin.math.roundToInt -data class Amount(val currency: String, val amount: String) { +class AmountDeserializer : StdDeserializer<Amount>(Amount::class.java) { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Amount { + val node = p.codec.readValue(p, String::class.java) + try { + return Amount.fromJSONString(node) + } catch (e: AmountParserException) { + throw JsonMappingException(p, "Error parsing Amount", e) + } + } +} + +class AmountParserException(msg: String? = null, cause: Throwable? = null) : Exception(msg, cause) +class AmountOverflowException(msg: String? = null, cause: Throwable? = null) : Exception(msg, cause) + +@JsonDeserialize(using = AmountDeserializer::class) +data class Amount( + /** + * name of the currency using either a three-character ISO 4217 currency code, + * or a regional currency identifier starting with a "*" followed by at most 10 characters. + * ISO 4217 exponents in the name are not supported, + * although the "fraction" is corresponds to an ISO 4217 exponent of 6. + */ + val currency: String, + + /** + * The integer part may be at most 2^52. + * Note that "1" here would correspond to 1 EUR or 1 USD, depending on currency, not 1 cent. + */ + val value: Long, + + /** + * Unsigned 32 bit fractional value to be added to value representing + * an additional currency fraction, in units of one hundred millionth (1e-8) + * of the base currency value. For example, a fraction + * of 50_000_000 would correspond to 50 cents. + */ + val fraction: Int +) { companion object { - private const val FRACTIONAL_BASE = 1e8 - private val SIGNED_REGEX = Regex("""([+\-])(\w+):([0-9.]+)""") - fun fromString(strAmount: String): Amount { - val components = strAmount.split(":") - return Amount(components[0], components[1]) + private const val FRACTIONAL_BASE: Int = 100000000 // 1e8 + + @Suppress("unused") + private val REGEX = Regex("""^[-_*A-Za-z0-9]{1,12}:([0-9]+)\.?([0-9]+)?$""") + private val REGEX_CURRENCY = Regex("""^[-_*A-Za-z0-9]{1,12}$""") + private val MAX_VALUE = 2.0.pow(52) + private const val MAX_FRACTION_LENGTH = 8 + private const val MAX_FRACTION = 99_999_999 + + @Throws(AmountParserException::class) + @SuppressLint("CheckedExceptions") + fun zero(currency: String): Amount { + return Amount(checkCurrency(currency), 0, 0) + } + + @Throws(AmountParserException::class) + @SuppressLint("CheckedExceptions") + fun fromJSONString(str: String): Amount { + val split = str.split(":") + if (split.size != 2) throw AmountParserException("Invalid Amount Format") + // currency + val currency = checkCurrency(split[0]) + // value + val valueSplit = split[1].split(".") + val value = checkValue(valueSplit[0].toLongOrNull()) + // fraction + val fraction: Int = if (valueSplit.size > 1) { + val fractionStr = valueSplit[1] + if (fractionStr.length > MAX_FRACTION_LENGTH) + throw AmountParserException("Fraction $fractionStr too long") + val fraction = "0.$fractionStr".toDoubleOrNull() + ?.times(FRACTIONAL_BASE) + ?.roundToInt() + checkFraction(fraction) + } else 0 + return Amount(currency, value, fraction) + } + + @Throws(AmountParserException::class) + @SuppressLint("CheckedExceptions") + fun fromJsonObject(json: JSONObject): Amount { + val currency = checkCurrency(json.optString("currency")) + val value = checkValue(json.optString("value").toLongOrNull()) + val fraction = checkFraction(json.optString("fraction").toIntOrNull()) + return Amount(currency, value, fraction) + } + + @Throws(AmountParserException::class) + private fun checkCurrency(currency: String): String { + if (!REGEX_CURRENCY.matches(currency)) + throw AmountParserException("Invalid currency: $currency") + return currency + } + + @Throws(AmountParserException::class) + private fun checkValue(value: Long?): Long { + if (value == null || value > MAX_VALUE) + throw AmountParserException("Value $value greater than $MAX_VALUE") + return value } - fun fromStringSigned(strAmount: String): Amount? { - val groups = SIGNED_REGEX.matchEntire(strAmount)?.groupValues ?: emptyList() - if (groups.size < 4) return null - var amount = groups[3].toDoubleOrNull() ?: return null - if (groups[1] == "-") amount *= -1 - val currency = groups[2] - val amountStr = amount.toString() - // only display as many digits as required to precisely render the balance - return Amount(currency, amountStr.removeSuffix(".0")) + @Throws(AmountParserException::class) + private fun checkFraction(fraction: Int?): Int { + if (fraction == null || fraction > MAX_FRACTION) + throw AmountParserException("Fraction $fraction greater than $MAX_FRACTION") + return fraction + } + + } + + val amountStr: String + get() = if (fraction == 0) "$value" else { + var f = fraction + var fractionStr = "" + while (f > 0) { + fractionStr += f / (FRACTIONAL_BASE / 10) + f = (f * 10) % FRACTIONAL_BASE + } + "$value.$fractionStr" } - fun fromJson(jsonAmount: JSONObject): Amount { - val amountCurrency = jsonAmount.getString("currency") - val amountValue = jsonAmount.getString("value") - val amountFraction = jsonAmount.getString("fraction") - val amountIntValue = Integer.parseInt(amountValue) - val amountIntFraction = Integer.parseInt(amountFraction) - return Amount( - amountCurrency, - (amountIntValue + amountIntFraction / FRACTIONAL_BASE).toString() - ) + @Throws(AmountOverflowException::class) + operator fun plus(other: Amount): Amount { + check(currency == other.currency) { "Can only subtract from same currency" } + val resultValue = value + other.value + floorDiv(fraction + other.fraction, FRACTIONAL_BASE) + if (resultValue > MAX_VALUE) + throw AmountOverflowException() + val resultFraction = (fraction + other.fraction) % FRACTIONAL_BASE + return Amount(currency, resultValue, resultFraction) + } + + @Throws(AmountOverflowException::class) + operator fun times(factor: Int): Amount { + var result = this + for (i in 1 until factor) result += this + return result + } + + @Throws(AmountOverflowException::class) + operator fun minus(other: Amount): Amount { + check(currency == other.currency) { "Can only subtract from same currency" } + var resultValue = value + var resultFraction = fraction + if (resultFraction < other.fraction) { + if (resultValue < 1L) + throw AmountOverflowException() + resultValue-- + resultFraction += FRACTIONAL_BASE } + check(resultFraction >= other.fraction) + resultFraction -= other.fraction + if (resultValue < other.value) + throw AmountOverflowException() + resultValue -= other.value + return Amount(currency, resultValue, resultFraction) } fun isZero(): Boolean { - return amount.toDouble() == 0.0 + return value == 0L && fraction == 0 } - override fun toString() = "$amount $currency" + fun toJSONString(): String { + return "$currency:$amountStr" + } + + override fun toString(): String { + return "$amountStr $currency" + } } diff --git a/taler-kotlin-common/src/main/java/net/taler/common/AndroidUtils.kt b/taler-kotlin-common/src/main/java/net/taler/common/AndroidUtils.kt index fc04da5..5bc5721 100644 --- a/taler-kotlin-common/src/main/java/net/taler/common/AndroidUtils.kt +++ b/taler-kotlin-common/src/main/java/net/taler/common/AndroidUtils.kt @@ -21,7 +21,6 @@ import android.content.Context.CONNECTIVITY_SERVICE import android.net.ConnectivityManager import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET import android.os.Build.VERSION.SDK_INT -import android.text.format.DateUtils import android.text.format.DateUtils.DAY_IN_MILLIS import android.text.format.DateUtils.FORMAT_ABBREV_MONTH import android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE @@ -29,6 +28,8 @@ import android.text.format.DateUtils.FORMAT_NO_YEAR import android.text.format.DateUtils.FORMAT_SHOW_DATE import android.text.format.DateUtils.FORMAT_SHOW_TIME import android.text.format.DateUtils.MINUTE_IN_MILLIS +import android.text.format.DateUtils.formatDateTime +import android.text.format.DateUtils.getRelativeTimeSpanString import android.view.View import android.view.View.INVISIBLE import android.view.View.VISIBLE @@ -78,6 +79,6 @@ fun Long.toRelativeTime(context: Context): CharSequence { val now = System.currentTimeMillis() return if (now - this > DAY_IN_MILLIS * 2) { val flags = FORMAT_SHOW_TIME or FORMAT_SHOW_DATE or FORMAT_ABBREV_MONTH or FORMAT_NO_YEAR - DateUtils.formatDateTime(context, this, flags) - } else DateUtils.getRelativeTimeSpanString(this, now, MINUTE_IN_MILLIS, FORMAT_ABBREV_RELATIVE) + formatDateTime(context, this, flags) + } else getRelativeTimeSpanString(this, now, MINUTE_IN_MILLIS, FORMAT_ABBREV_RELATIVE) } diff --git a/taler-kotlin-common/src/main/java/net/taler/common/ContractTerms.kt b/taler-kotlin-common/src/main/java/net/taler/common/ContractTerms.kt index 1e70b6f..cd417ef 100644 --- a/taler-kotlin-common/src/main/java/net/taler/common/ContractTerms.kt +++ b/taler-kotlin-common/src/main/java/net/taler/common/ContractTerms.kt @@ -18,12 +18,20 @@ package net.taler.common import androidx.annotation.RequiresApi import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY import com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL import com.fasterxml.jackson.annotation.JsonProperty import net.taler.common.TalerUtils.getLocalizedString +@JsonIgnoreProperties(ignoreUnknown = true) +data class ContractTerms( + val summary: String, + val products: List<ContractProduct>, + val amount: Amount +) + @JsonInclude(NON_NULL) abstract class Product { @get:JsonProperty("product_id") @@ -32,7 +40,7 @@ abstract class Product { @get:JsonProperty("description_i18n") abstract val descriptionI18n: Map<String, String>? - abstract val price: String + abstract val price: Amount @get:JsonProperty("delivery_location") abstract val location: String? @@ -48,11 +56,16 @@ data class ContractProduct( override val productId: String?, override val description: String, override val descriptionI18n: Map<String, String>?, - override val price: String, + override val price: Amount, override val location: String?, override val image: String?, val quantity: Int -) : Product() +) : Product() { + @get:JsonIgnore + val totalPrice: Amount by lazy { + price * quantity + } +} @JsonInclude(NON_EMPTY) class Timestamp( diff --git a/taler-kotlin-common/src/main/java/net/taler/common/SignedAmount.kt b/taler-kotlin-common/src/main/java/net/taler/common/SignedAmount.kt new file mode 100644 index 0000000..03a0d6e --- /dev/null +++ b/taler-kotlin-common/src/main/java/net/taler/common/SignedAmount.kt @@ -0,0 +1,40 @@ +/* + * This file is part of GNU Taler + * (C) 2020 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 android.annotation.SuppressLint + +data class SignedAmount( + val positive: Boolean, + val amount: Amount +) { + + companion object { + @Throws(AmountParserException::class) + @SuppressLint("CheckedExceptions") + fun fromJSONString(str: String): SignedAmount = when (str.substring(0, 1)) { + "-" -> SignedAmount(false, Amount.fromJSONString(str.substring(1))) + "+" -> SignedAmount(true, Amount.fromJSONString(str.substring(1))) + else -> SignedAmount(true, Amount.fromJSONString(str)) + } + } + + override fun toString(): String { + return if (positive) "$amount" else "-$amount" + } + +}
\ No newline at end of file diff --git a/taler-kotlin-common/src/test/java/net/taler/common/AmountTest.kt b/taler-kotlin-common/src/test/java/net/taler/common/AmountTest.kt new file mode 100644 index 0000000..c09da3c --- /dev/null +++ b/taler-kotlin-common/src/test/java/net/taler/common/AmountTest.kt @@ -0,0 +1,290 @@ +/* + * This file is part of GNU Taler + * (C) 2020 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 com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.fasterxml.jackson.module.kotlin.readValue +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class AmountTest { + + @Test + fun `test fromJSONString() works`() { + var str = "TESTKUDOS:23.42" + var amount = Amount.fromJSONString(str) + assertEquals(str, amount.toJSONString()) + 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) + assertEquals(str, amount.toJSONString()) + 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) + assertEquals(str, amount.toJSONString()) + assertEquals("EUR", amount.currency) + assertEquals(1500000000, amount.value) + assertEquals(3, amount.fraction) + assertEquals("1500000000.00000003 EUR", amount.toString()) + } + + @Test + fun `test fromJSONString() accepts max values, rejects above`() { + val maxValue = 4503599627370496 + val str = "TESTKUDOS123:$maxValue.99999999" + val amount = Amount.fromJSONString(str) + 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") { + Amount.fromJSONString("TESTKUDOS1234:$maxValue.99999999") + } + + // max value + 1 not accepted + assertThrows<AmountParserException>("max value + 1 was accepted") { + Amount.fromJSONString("TESTKUDOS123:${maxValue + 1}.99999999") + } + + // max fraction + 1 not accepted + assertThrows<AmountParserException>("max fraction + 1 was accepted") { + Amount.fromJSONString("TESTKUDOS123:$maxValue.999999990") + } + } + + @Test + fun `test JSON deserialization()`() { + val mapper = ObjectMapper().registerModule(KotlinModule()) + var str = "TESTKUDOS:23.42" + var amount: Amount = mapper.readValue("\"$str\"") + assertEquals(str, amount.toJSONString()) + 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 = mapper.readValue("\"$str\"") + assertEquals(str, amount.toJSONString()) + assertEquals("EUR", amount.currency) + assertEquals(500000000, amount.value) + assertEquals(1, amount.fraction) + assertEquals("500000000.00000001 EUR", amount.toString()) + + str = "EUR:1500000000.00000003" + amount = mapper.readValue("\"$str\"") + assertEquals(str, amount.toJSONString()) + assertEquals("EUR", amount.currency) + assertEquals(1500000000, amount.value) + assertEquals(3, amount.fraction) + assertEquals("1500000000.00000003 EUR", amount.toString()) + } + + @Test + fun `test fromJSONString() rejections`() { + assertThrows<AmountParserException> { + Amount.fromJSONString("TESTKUDOS:0,5") + } + assertThrows<AmountParserException> { + Amount.fromJSONString("+TESTKUDOS:0.5") + } + assertThrows<AmountParserException> { + Amount.fromJSONString("0.5") + } + assertThrows<AmountParserException> { + Amount.fromJSONString(":0.5") + } + assertThrows<AmountParserException> { + Amount.fromJSONString("EUR::0.5") + } + assertThrows<AmountParserException> { + Amount.fromJSONString("EUR:.5") + } + } + + @Test + fun `test fromJsonObject() works`() { + val map = mapOf( + "currency" to "TESTKUDOS", + "value" to "23", + "fraction" to "42000000" + ) + + val amount = Amount.fromJsonObject(JSONObject(map)) + assertEquals("TESTKUDOS:23.42", amount.toJSONString()) + assertEquals("TESTKUDOS", amount.currency) + assertEquals(23, amount.value) + assertEquals(42000000, amount.fraction) + assertEquals("23.42 TESTKUDOS", amount.toString()) + } + + @Test + fun `test fromJsonObject() accepts max values, rejects above`() { + val maxValue = 4503599627370496 + val maxFraction = 99999999 + var map = mapOf( + "currency" to "TESTKUDOS123", + "value" to "$maxValue", + "fraction" to "$maxFraction" + ) + + val amount = Amount.fromJsonObject(JSONObject(map)) + assertEquals("TESTKUDOS123:$maxValue.$maxFraction", amount.toJSONString()) + assertEquals("TESTKUDOS123", amount.currency) + assertEquals(maxValue, amount.value) + assertEquals(maxFraction, amount.fraction) + assertEquals("$maxValue.$maxFraction TESTKUDOS123", amount.toString()) + + // longer currency not accepted + assertThrows<AmountParserException>("longer currency was accepted") { + map = mapOf( + "currency" to "TESTKUDOS1234", + "value" to "$maxValue", + "fraction" to "$maxFraction" + ) + Amount.fromJsonObject(JSONObject(map)) + } + + // max value + 1 not accepted + assertThrows<AmountParserException>("max value + 1 was accepted") { + map = mapOf( + "currency" to "TESTKUDOS123", + "value" to "${maxValue + 1}", + "fraction" to "$maxFraction" + ) + Amount.fromJsonObject(JSONObject(map)) + } + + // max fraction + 1 not accepted + assertThrows<AmountParserException>("max fraction + 1 was accepted") { + map = mapOf( + "currency" to "TESTKUDOS123", + "value" to "$maxValue", + "fraction" to "${maxFraction + 1}" + ) + Amount.fromJsonObject(JSONObject(map)) + } + } + + @Test + fun `test addition`() { + assertEquals( + Amount.fromJSONString("EUR:2"), + Amount.fromJSONString("EUR:1") + Amount.fromJSONString("EUR:1") + ) + assertEquals( + Amount.fromJSONString("EUR:3"), + Amount.fromJSONString("EUR:1.5") + Amount.fromJSONString("EUR:1.5") + ) + assertEquals( + Amount.fromJSONString("EUR:500000000.00000002"), + Amount.fromJSONString("EUR:500000000.00000001") + Amount.fromJSONString("EUR:0.00000001") + ) + assertThrows<AmountOverflowException>("addition didn't overflow") { + Amount.fromJSONString("EUR:4503599627370496.99999999") + Amount.fromJSONString("EUR:0.00000001") + } + assertThrows<AmountOverflowException>("addition didn't overflow") { + Amount.fromJSONString("EUR:4000000000000000") + Amount.fromJSONString("EUR:4000000000000000") + } + } + + @Test + fun `test times`() { + assertEquals( + Amount.fromJSONString("EUR:2"), + Amount.fromJSONString("EUR:2") * 1 + ) + assertEquals( + Amount.fromJSONString("EUR:2"), + Amount.fromJSONString("EUR:1") * 2 + ) + assertEquals( + Amount.fromJSONString("EUR:4.5"), + Amount.fromJSONString("EUR:1.5") * 3 + ) + assertEquals( + Amount.fromJSONString("EUR:1500000000.00000003"), + Amount.fromJSONString("EUR:500000000.00000001") * 3 + ) + assertThrows<AmountOverflowException>("times didn't overflow") { + Amount.fromJSONString("EUR:4000000000000000") * 2 + } + } + + @Test + fun `test subtraction`() { + assertEquals( + Amount.fromJSONString("EUR:0"), + Amount.fromJSONString("EUR:1") - Amount.fromJSONString("EUR:1") + ) + assertEquals( + Amount.fromJSONString("EUR:1.5"), + Amount.fromJSONString("EUR:3") - Amount.fromJSONString("EUR:1.5") + ) + assertEquals( + Amount.fromJSONString("EUR:500000000.00000001"), + Amount.fromJSONString("EUR:500000000.00000002") - Amount.fromJSONString("EUR:0.00000001") + ) + assertThrows<AmountOverflowException>("subtraction didn't underflow") { + Amount.fromJSONString("EUR:23.42") - Amount.fromJSONString("EUR:42.23") + } + assertThrows<AmountOverflowException>("subtraction didn't underflow") { + Amount.fromJSONString("EUR:0.5") - Amount.fromJSONString("EUR:0.50000001") + } + } + + @Test + fun `test isZero()`() { + assertTrue(Amount.zero("EUR").isZero()) + assertTrue(Amount.fromJSONString("EUR:0").isZero()) + assertTrue(Amount.fromJSONString("EUR:0.0").isZero()) + assertTrue(Amount.fromJSONString("EUR:0.00000").isZero()) + assertTrue((Amount.fromJSONString("EUR:1.001") - Amount.fromJSONString("EUR:1.001")).isZero()) + + assertFalse(Amount.fromJSONString("EUR:0.00000001").isZero()) + assertFalse(Amount.fromJSONString("EUR:1.0").isZero()) + assertFalse(Amount.fromJSONString("EUR:0001.0").isZero()) + } + + private inline fun <reified T : Throwable> assertThrows( + msg: String? = null, + function: () -> Any + ) { + try { + function.invoke() + fail(msg) + } catch (e: Exception) { + assertTrue(e is T) + } + } + +} |