summaryrefslogtreecommitdiff
path: root/taler-kotlin-android/src
diff options
context:
space:
mode:
Diffstat (limited to 'taler-kotlin-android/src')
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/Amount.kt64
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt18
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt10
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/CurrencySpecification.kt36
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/Event.kt4
-rw-r--r--taler-kotlin-android/src/test/java/net/taler/common/AmountTest.kt157
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") {