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/AndroidManifest.xml9
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/Amount.kt281
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt47
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/Bech32.kt282
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt12
-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/CyptoUtils.kt71
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/Event.kt4
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/TalerUri.kt62
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/TalerUtils.kt4
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/Time.kt105
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/Version.kt70
-rw-r--r--taler-kotlin-android/src/main/res/values/colors.xml3
-rw-r--r--taler-kotlin-android/src/test/java/net/taler/common/AmountTest.kt372
-rw-r--r--taler-kotlin-android/src/test/java/net/taler/common/ContractTermsTest.kt9
-rw-r--r--taler-kotlin-android/src/test/java/net/taler/common/TalerUriTest.kt65
-rw-r--r--taler-kotlin-android/src/test/java/net/taler/common/TestUtils.kt (renamed from taler-kotlin-android/src/main/java/net/taler/lib/android/Serialization.kt)25
-rw-r--r--taler-kotlin-android/src/test/java/net/taler/common/TimeTest.kt46
-rw-r--r--taler-kotlin-android/src/test/java/net/taler/common/VersionTest.kt65
19 files changed, 1526 insertions, 42 deletions
diff --git a/taler-kotlin-android/src/main/AndroidManifest.xml b/taler-kotlin-android/src/main/AndroidManifest.xml
index f74aae6..bc489b1 100644
--- a/taler-kotlin-android/src/main/AndroidManifest.xml
+++ b/taler-kotlin-android/src/main/AndroidManifest.xml
@@ -14,14 +14,7 @@
~ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-->
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:tools="http://schemas.android.com/tools"
- package="net.taler.common">
-
- <uses-sdk tools:overrideLibrary="com.google.zxing.client.android" />
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
- <uses-permission android:name="android.permission.INTERNET" />
- <uses-permission android:name="android.permission.NFC" />
-
</manifest>
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
new file mode 100644
index 0000000..3e3bd0a
--- /dev/null
+++ b/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt
@@ -0,0 +1,281 @@
+/*
+ * 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.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
+
+public class AmountParserException(msg: String? = null, cause: Throwable? = null) :
+ Exception(msg, cause)
+
+public class AmountOverflowException(msg: String? = null, cause: Throwable? = null) :
+ Exception(msg, cause)
+
+@Serializable(with = KotlinXAmountSerializer::class)
+public 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,
+
+ /**
+ * Currency specification for amount
+ */
+ val spec: CurrencySpecification? = null,
+) : Comparable<Amount> {
+
+ public companion object {
+
+ private const val FRACTIONAL_BASE: Int = 100000000 // 1e8
+
+ private val REGEX_CURRENCY = Regex("""^[-_*A-Za-z0-9]{1,12}$""")
+ public val MAX_VALUE: Long = 2.0.pow(52).toLong()
+ private const val MAX_FRACTION_LENGTH = 8
+ public const val MAX_FRACTION: Int = 99_999_999
+
+ public fun zero(currency: String): Amount {
+ return Amount(checkCurrency(currency), 0, 0)
+ }
+
+ public fun fromJSONString(str: String): Amount {
+ val split = str.split(":")
+ if (split.size != 2) throw AmountParserException("Invalid Amount Format")
+ return fromString(split[0], split[1])
+ }
+
+ public fun fromString(currency: String, str: String): Amount {
+ // value
+ val valueSplit = str.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")
+ checkFraction(fractionStr.getFraction())
+ } else 0
+ return Amount(checkCurrency(currency), value, fraction)
+ }
+
+ public fun isValidAmountStr(str: String): Boolean {
+ if (str.count { it == '.' } > 1) return false
+ val split = str.split(".")
+ try {
+ checkValue(split[0].toLongOrNull())
+ } catch (e: AmountParserException) {
+ return false
+ }
+ // also check fraction, if it exists
+ if (split.size > 1) {
+ val fractionStr = split[1]
+ if (fractionStr.length > MAX_FRACTION_LENGTH) return false
+ val fraction = fractionStr.getFraction() ?: return false
+ return fraction <= MAX_FRACTION
+ }
+ return true
+ }
+
+ private fun String.getFraction(): Int? {
+ return "0.$this".toDoubleOrNull()
+ ?.times(FRACTIONAL_BASE)
+ ?.roundToInt()
+ }
+
+ public fun min(currency: String): Amount = Amount(currency, 0, 1)
+ public fun max(currency: String): Amount = Amount(currency, MAX_VALUE, MAX_FRACTION)
+
+
+ internal fun checkCurrency(currency: String): String {
+ if (!REGEX_CURRENCY.matches(currency))
+ throw AmountParserException("Invalid currency: $currency")
+ return currency
+ }
+
+ internal fun checkValue(value: Long?): Long {
+ if (value == null || value > MAX_VALUE)
+ throw AmountParserException("Value $value greater than $MAX_VALUE")
+ return value
+ }
+
+ internal fun checkFraction(fraction: Int?): Int {
+ if (fraction == null || fraction > MAX_FRACTION)
+ throw AmountParserException("Fraction $fraction greater than $MAX_FRACTION")
+ return fraction
+ }
+
+ }
+
+ public 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"
+ }
+
+ public operator fun plus(other: Amount): Amount {
+ check(currency == other.currency) { "Can only subtract from same currency" }
+ val resultValue =
+ value + other.value + floor((fraction + other.fraction).toDouble() / FRACTIONAL_BASE).toLong()
+ if (resultValue > MAX_VALUE)
+ throw AmountOverflowException()
+ val resultFraction = (fraction + other.fraction) % FRACTIONAL_BASE
+ return Amount(currency, resultValue, resultFraction)
+ }
+
+ public operator fun times(factor: Int): Amount {
+ // TODO consider replacing with a faster implementation
+ if (factor == 0) return zero(currency)
+ var result = this
+ for (i in 1 until factor) result += this
+ return result
+ }
+
+ public fun withCurrency(currency: String): 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
+ 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)
+ }
+
+ public fun isZero(): Boolean {
+ return value == 0L && fraction == 0
+ }
+
+ public fun toJSONString(): String {
+ return "$currency:$amountStr"
+ }
+
+ 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 {
+ check(currency == other.currency) { "Can only compare amounts with the same currency" }
+ when {
+ value == other.value -> {
+ if (fraction < other.fraction) return -1
+ if (fraction > other.fraction) return 1
+ return 0
+ }
+ value < other.value -> return -1
+ else -> return 1
+ }
+ }
+
+}
+
+@Suppress("EXPERIMENTAL_API_USAGE")
+@Serializer(forClass = Amount::class)
+internal object KotlinXAmountSerializer : KSerializer<Amount> {
+ override fun serialize(encoder: Encoder, value: Amount) {
+ encoder.encodeString(value.toJSONString())
+ }
+
+ override fun deserialize(decoder: Decoder): Amount {
+ return Amount.fromJSONString(decoder.decodeString())
+ }
+}
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 4ac2e73..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
@@ -16,12 +16,14 @@
package net.taler.common
+import android.Manifest.permission.ACCESS_NETWORK_STATE
import android.content.ActivityNotFoundException
import android.content.Context
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
@@ -40,16 +42,14 @@ import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.inputmethod.InputMethodManager
+import androidx.annotation.RequiresPermission
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat.getSystemService
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.navigation.NavDirections
import androidx.navigation.fragment.findNavController
-import com.github.pedrovgs.lynx.LynxActivity
-import com.github.pedrovgs.lynx.LynxConfig
import net.taler.lib.android.ErrorBottomSheet
-import net.taler.lib.common.Version
fun View.fadeIn(endAction: () -> Unit = {}) {
if (visibility == VISIBLE && alpha == 1f) return
@@ -85,6 +85,7 @@ fun assertUiThread() {
val <T> T.exhaustive: T
get() = this
+@RequiresPermission(ACCESS_NETWORK_STATE)
fun Context.isOnline(): Boolean {
val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
return if (SDK_INT < 29) {
@@ -96,24 +97,23 @@ fun Context.isOnline(): Boolean {
}
}
-fun Context.showLogViewer(logFilter: String? = null) {
- val lynxActivityIntent = LynxActivity.getIntent(this, LynxConfig().apply {
- maxNumberOfTracesToShow = 1500 // higher numbers seem to break share functionality
- samplingRate = Int.MAX_VALUE // no updates please
- logFilter?.let { filter = it }
- })
- startActivity(lynxActivityIntent)
-}
-
fun FragmentActivity.showError(mainText: String, detailText: String = "") = ErrorBottomSheet
- .newInstance(mainText, detailText)
- .show(supportFragmentManager, "ERROR_BOTTOM_SHEET")
+ .newInstance(mainText, detailText)
+ .show(supportFragmentManager, "ERROR_BOTTOM_SHEET")
fun FragmentActivity.showError(@StringRes mainId: Int, detailText: String = "") {
showError(getString(mainId), detailText)
}
-fun Fragment.startActivitySafe(intent: Intent) {
+fun Fragment.showError(mainText: String, detailText: String = "") = ErrorBottomSheet
+ .newInstance(mainText, detailText)
+ .show(parentFragmentManager, "ERROR_BOTTOM_SHEET")
+
+fun Fragment.showError(@StringRes mainId: Int, detailText: String = "") {
+ showError(getString(mainId), detailText)
+}
+
+fun Context.startActivitySafe(intent: Intent) {
try {
startActivity(intent)
} catch (e: ActivityNotFoundException) {
@@ -121,6 +121,23 @@ fun Fragment.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/Bech32.kt b/taler-kotlin-android/src/main/java/net/taler/common/Bech32.kt
new file mode 100644
index 0000000..4b77f85
--- /dev/null
+++ b/taler-kotlin-android/src/main/java/net/taler/common/Bech32.kt
@@ -0,0 +1,282 @@
+/*
+ * 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/>
+ */
+
+// Copyright (c) 2020 Figure Technologies Inc.
+// The contents of this file were derived from an implementation
+// by the btcsuite developers https://github.com/btcsuite/btcutil.
+
+// Copyright (c) 2017 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+// modified version of https://gist.github.com/iramiller/4ebfcdfbc332a9722c4a4abeb4e16454
+
+package net.taler.common
+
+import java.util.Locale.ROOT
+import kotlin.experimental.and
+import kotlin.experimental.or
+
+infix fun Int.min(b: Int): Int = b.takeIf { this > b } ?: this
+infix fun UByte.shl(bitCount: Int) = ((this.toInt() shl bitCount) and 0xff).toUByte()
+infix fun UByte.shr(bitCount: Int) = (this.toInt() shr bitCount).toUByte()
+
+/**
+ * Bech32 Data encoding instance containing data for encoding as well as a human readable prefix
+ */
+data class Bech32Data(val hrp: String, val fiveBitData: ByteArray) {
+
+ /**
+ * The encapsulated data as typical 8bit bytes.
+ */
+ val data = Bech32.convertBits(fiveBitData, 5, 8, false)
+
+ /**
+ * Address is the Bech32 encoded value of the data prefixed with the human readable portion and
+ * protected by an appended checksum.
+ */
+ val address = Bech32.encode(hrp, fiveBitData)
+
+ /**
+ * Checksum for encapsulated data + hrp
+ */
+ val checksum = Bech32.checksum(this.hrp, this.fiveBitData.toTypedArray())
+
+ /**
+ * The Bech32 Address toString prints state information for debugging purposes.
+ * @see address() for the bech32 encoded address string output.
+ */
+ override fun toString(): String {
+ return "bech32 : ${this.address}\nhuman: ${this.hrp} \nbytes"
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Bech32Data
+
+ if (hrp != other.hrp) return false
+ if (!fiveBitData.contentEquals(other.fiveBitData)) return false
+ if (!data.contentEquals(other.data)) return false
+ if (address != other.address) return false
+ if (!checksum.contentEquals(other.checksum)) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = hrp.hashCode()
+ result = 31 * result + fiveBitData.contentHashCode()
+ result = 31 * result + data.contentHashCode()
+ result = 31 * result + address.hashCode()
+ result = 31 * result + checksum.contentHashCode()
+ return result
+ }
+}
+
+/**
+ * BIP173 compliant processing functions for handling Bech32 encoding for addresses
+ */
+class Bech32 {
+
+ companion object {
+ const val CHECKSUM_SIZE = 6
+ private const val MIN_VALID_LENGTH = 8
+ private const val MAX_VALID_LENGTH = 90
+ const val MIN_VALID_CODEPOINT = 33
+ private const val MAX_VALID_CODEPOINT = 126
+
+ const val charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
+ private val gen = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)
+
+ fun generateFakeSegwitAddress(reservePub: String?, addr: String): List<String> {
+ if (reservePub == null || reservePub.isEmpty()) return listOf()
+ val pub = CyptoUtils.decodeCrock(reservePub)
+ if (pub.size != 32) return listOf()
+
+ val firstRnd = pub.copyOfRange(0, 4)
+ val secondRnd = pub.copyOfRange(0, 4)
+
+ firstRnd[0] = firstRnd[0].and(0b0111_1111)
+ secondRnd[0] = secondRnd[0].or(0b1000_0000.toByte())
+
+ val firstPart = ByteArray(20)
+ firstRnd.copyInto(firstPart, 0, 0, 4)
+ pub.copyInto(firstPart, 4, 0, 16)
+
+ val secondPart = ByteArray(20)
+ secondRnd.copyInto(secondPart, 0, 0, 4)
+ pub.copyInto(secondPart, 4, 16, 32)
+
+ val zero = ByteArray(1)
+ zero[0] = 0
+ val hrp = when {
+ addr[0] == 'b' && addr[1] == 'c' && addr[2] == 'r' && addr[3] == 't' -> "bcrt"
+ addr[0] == 't' && addr[1] == 'b' -> "tb"
+ addr[0] == 'b' && addr[1] == 'c' -> "bc"
+ else -> throw Error("unknown bitcoin net")
+ }
+
+ return listOf(
+ Bech32Data(hrp, zero + convertBits(firstPart, 8, 5, true)).address,
+ Bech32Data(hrp, zero + convertBits(secondPart, 8, 5, true)).address,
+ )
+ }
+
+ /**
+ * Decodes a Bech32 String
+ */
+ fun decode(bech32: String): Bech32Data {
+ require(bech32.length in MIN_VALID_LENGTH..MAX_VALID_LENGTH) { "invalid bech32 string length" }
+ require(bech32.toCharArray()
+ .none { c -> c.code < MIN_VALID_CODEPOINT || c.code > MAX_VALID_CODEPOINT })
+ {
+ "invalid character in bech32: ${
+ bech32.toCharArray().map { c -> c.code }
+ .filter { c -> c < MIN_VALID_CODEPOINT || c > MAX_VALID_CODEPOINT }
+ }"
+ }
+
+ require(bech32 == bech32.lowercase(ROOT) || bech32 == bech32.uppercase(ROOT))
+ { "bech32 must be either all upper or lower case" }
+ require(bech32.substring(1).dropLast(CHECKSUM_SIZE)
+ .contains('1')) { "invalid index of '1'" }
+
+ val hrp = bech32.substringBeforeLast('1').lowercase(ROOT)
+ val dataString = bech32.substringAfterLast('1').lowercase(ROOT)
+
+ require(dataString.toCharArray()
+ .all { c -> charset.contains(c) }) { "invalid data encoding character in bech32" }
+
+ val dataBytes = dataString.map { c -> charset.indexOf(c).toByte() }.toByteArray()
+ val checkBytes =
+ dataString.takeLast(CHECKSUM_SIZE).map { c -> charset.indexOf(c).toByte() }
+ .toByteArray()
+
+ val actualSum = checksum(hrp, dataBytes.dropLast(CHECKSUM_SIZE).toTypedArray())
+ require(1 == polymod(expandHrp(hrp).plus(dataBytes.map { d -> d.toInt() }))) { "checksum failed: $checkBytes != $actualSum" }
+
+ return Bech32Data(hrp, dataBytes.dropLast(CHECKSUM_SIZE).toByteArray())
+ }
+
+ /**
+ * ConvertBits regroups bytes with toBits set based on reading groups of bits as a continuous stream group by fromBits.
+ * This process is used to convert from base64 (from 8) to base32 (to 5) or the inverse.
+ */
+ fun convertBits(data: ByteArray, fromBits: Int, toBits: Int, pad: Boolean): ByteArray {
+ require(fromBits in 1..8 && toBits in 1..8) { "only bit groups between 1 and 8 are supported" }
+
+ // resulting bytes with each containing the toBits bits from the input set.
+ val regrouped = arrayListOf<Byte>()
+
+ var nextByte = 0.toUByte()
+ var filledBits = 0
+
+ data.forEach { d ->
+ // discard unused bits.
+ var b = (d.toUByte() shl (8 - fromBits))
+
+ // How many bits remain to extract from input data.
+ var remainFromBits = fromBits
+
+ while (remainFromBits > 0) {
+ // How many bits remain to be copied in
+ val remainToBits = toBits - filledBits
+
+ // we extract the remaining bits unless that is more than we need.
+ val toExtract =
+ remainFromBits.takeUnless { remainToBits < remainFromBits } ?: remainToBits
+ check(toExtract >= 0) { "extract should be positive" }
+
+ // move existing bits to the left to make room for bits toExtract, copy in bits to extract
+ nextByte = (nextByte shl toExtract) or (b shr (8 - toExtract))
+
+ // discard extracted bits and update position counters
+ b = b shl toExtract
+ remainFromBits -= toExtract
+ filledBits += toExtract
+
+ // if we have a complete group then reset.
+ if (filledBits == toBits) {
+ regrouped.add(nextByte.toByte())
+ filledBits = 0
+ nextByte = 0.toUByte()
+ }
+ }
+ }
+
+ // pad any unfinished groups as required
+ if (pad && filledBits > 0) {
+ nextByte = nextByte shl (toBits - filledBits)
+ regrouped.add(nextByte.toByte())
+ filledBits = 0
+ nextByte = 0.toUByte()
+ }
+
+ return regrouped.toByteArray()
+ }
+
+ /**
+ * Encodes data 5-bit bytes (data) with a given human readable portion (hrp) into a bech32 string.
+ * @see convertBits for conversion or ideally use the Bech32Data extension functions
+ */
+ fun encode(hrp: String, fiveBitData: ByteArray): String {
+ return (fiveBitData.plus(checksum(hrp, fiveBitData.toTypedArray()))
+ .map { b -> charset[b.toInt()] }).joinToString("", hrp + "1")
+ }
+
+ /**
+ * Calculates a bech32 checksum based on BIP 173 specification
+ */
+ fun checksum(hrp: String, data: Array<Byte>): ByteArray {
+ val values = expandHrp(hrp)
+ .plus(data.map { d -> d.toInt() })
+ .plus(Array(6) { 0 }.toIntArray())
+
+ val poly = polymod(values) xor 1
+
+ return (0..5).map {
+ ((poly shr (5 * (5 - it))) and 31).toByte()
+ }.toByteArray()
+ }
+
+ /**
+ * Expands the human readable prefix per BIP173 for Checksum encoding
+ */
+ private fun expandHrp(hrp: String) =
+ hrp.map { c -> c.code shr 5 }
+ .plus(0)
+ .plus(hrp.map { c -> c.code and 31 })
+ .toIntArray()
+
+ /**
+ * Polynomial division function for checksum calculation. For details see BIP173
+ */
+ fun polymod(values: IntArray): Int {
+ var chk = 1
+ return values.map { num ->
+ val b = chk shr 25
+ chk = ((chk and 0x1ffffff) shl 5) xor num
+ (0..4).map {
+ if (((b shr it) and 1) == 1) {
+ chk = chk xor gen[it]
+ }
+ }
+ }.let { chk }
+ }
+ }
+} \ No newline at end of file
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 fb30692..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
@@ -20,8 +20,6 @@ import android.os.Build
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import net.taler.common.TalerUtils.getLocalizedString
-import net.taler.lib.common.Amount
-import net.taler.lib.common.Timestamp
@Serializable
data class ContractTerms(
@@ -44,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
@@ -62,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/CyptoUtils.kt b/taler-kotlin-android/src/main/java/net/taler/common/CyptoUtils.kt
new file mode 100644
index 0000000..c1fbe8c
--- /dev/null
+++ b/taler-kotlin-android/src/main/java/net/taler/common/CyptoUtils.kt
@@ -0,0 +1,71 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2022 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 kotlin.math.floor
+
+object CyptoUtils {
+ internal fun getValue(c: Char): Int {
+ val a = when (c) {
+ 'o','O' -> '0'
+ 'i','I','l','L' -> '1'
+ 'u','U' -> 'V'
+ else -> c
+ }
+ if (a in '0'..'9') {
+ return a - '0'
+ }
+ val A = if (a in 'a'..'z') a.uppercaseChar() else a
+ var dec = 0
+ if (A in 'A'..'Z') {
+ if ('I' < A) dec++
+ if ('L' < A) dec++
+ if ('O' < A) dec++
+ if ('U' < A) dec++
+ return A - 'A' + 10 - dec
+ }
+ throw Error("encoding error")
+ }
+
+ fun decodeCrock(e: String): ByteArray {
+ val size = e.length
+ var bitpos = 0
+ var bitbuf = 0
+ var readPosition = 0
+ val outLen = floor((size * 5f) / 8).toInt()
+ val out = ByteArray(outLen)
+ var outPos = 0
+ while (readPosition < size || bitpos > 0) {
+ if (readPosition < size) {
+ val v = getValue(e[readPosition++])
+ bitbuf = bitbuf.shl(5).or(v)
+ bitpos += 5
+ }
+ while (bitpos >= 8) {
+ val d = bitbuf.shr(bitpos -8).and(0xff).toByte()
+ out[outPos++] = d
+ bitpos -= 8
+ }
+ if (readPosition == size && bitpos > 0) {
+ bitbuf = bitbuf.shl( 8 - bitpos).and(0xff)
+ bitpos = if (bitbuf == 0) 0 else 8
+ }
+ }
+ return out
+ }
+
+} \ 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/main/java/net/taler/common/TalerUri.kt b/taler-kotlin-android/src/main/java/net/taler/common/TalerUri.kt
new file mode 100644
index 0000000..999408c
--- /dev/null
+++ b/taler-kotlin-android/src/main/java/net/taler/common/TalerUri.kt
@@ -0,0 +1,62 @@
+/*
+ * 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 java.util.Locale
+
+public object TalerUri {
+
+ private const val SCHEME = "taler://"
+ private const val SCHEME_INSECURE = "taler+http://"
+ private const val AUTHORITY_PAY = "pay"
+ private const val AUTHORITY_WITHDRAW = "withdraw"
+ private const val AUTHORITY_REFUND = "refund"
+ private const val AUTHORITY_TIP = "tip"
+
+ public data class WithdrawUriResult(
+ val bankIntegrationApiBaseUrl: String,
+ val withdrawalOperationId: String
+ )
+
+ /**
+ * Parses a withdraw URI and returns a bank status URL or null if the URI was invalid.
+ */
+ public fun parseWithdrawUri(uri: String): WithdrawUriResult? {
+ val (resultScheme, prefix) = when {
+ uri.startsWith(SCHEME, ignoreCase = true) -> {
+ Pair("https://", "${SCHEME}${AUTHORITY_WITHDRAW}/")
+ }
+ uri.startsWith(SCHEME_INSECURE, ignoreCase = true) -> {
+ Pair("http://", "${SCHEME_INSECURE}${AUTHORITY_WITHDRAW}/")
+ }
+ else -> return null
+ }
+ if (!uri.startsWith(prefix, ignoreCase = true)) return null
+ val parts = uri.let {
+ (if (it.endsWith("/")) it.dropLast(1) else it).substring(prefix.length).split('/')
+ }
+ if (parts.size < 2) return null
+ val host = parts[0].lowercase(Locale.ROOT)
+ val pathSegments = parts.slice(1 until parts.size - 1).joinToString("/")
+ val withdrawId = parts.last()
+ if (withdrawId.isBlank()) return null
+ val url = "${resultScheme}${host}/${pathSegments}"
+
+ return WithdrawUriResult(url, withdrawId)
+ }
+
+}
diff --git a/taler-kotlin-android/src/main/java/net/taler/common/TalerUtils.kt b/taler-kotlin-android/src/main/java/net/taler/common/TalerUtils.kt
index bb2e78a..e75e4c8 100644
--- a/taler-kotlin-android/src/main/java/net/taler/common/TalerUtils.kt
+++ b/taler-kotlin-android/src/main/java/net/taler/common/TalerUtils.kt
@@ -29,8 +29,8 @@ object TalerUtils {
// create a priority list of language ranges from system locales
val locales = LocaleListCompat.getDefault()
val priorityList = ArrayList<Locale.LanguageRange>(locales.size())
- for (i in 0 until locales.size()) {
- priorityList.add(Locale.LanguageRange(locales[i].toLanguageTag()))
+ for (i in 0 until locales.size()) locales[i]?.let { locale ->
+ priorityList.add(Locale.LanguageRange(locale.toLanguageTag()))
}
// create a list of locales available in the given map
val availableLocales = map.keys.mapNotNull {
diff --git a/taler-kotlin-android/src/main/java/net/taler/common/Time.kt b/taler-kotlin-android/src/main/java/net/taler/common/Time.kt
new file mode 100644
index 0000000..f280d5f
--- /dev/null
+++ b/taler-kotlin-android/src/main/java/net/taler/common/Time.kt
@@ -0,0 +1,105 @@
+/*
+ * 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 kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.JsonTransformingSerializer
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.longOrNull
+import kotlin.math.max
+
+@Serializable
+data class Timestamp(
+ @SerialName("t_s")
+ @Serializable(NeverSerializer::class)
+ private val s: Long,
+) : Comparable<Timestamp> {
+
+ companion object {
+ private const val NEVER: Long = -1
+ fun now(): Timestamp = fromMillis(System.currentTimeMillis())
+ fun never(): Timestamp = Timestamp(NEVER)
+ fun fromMillis(ms: Long) = Timestamp(ms / 1000L)
+ }
+
+ val ms: Long = s * 1000L
+
+ operator fun minus(other: Timestamp): RelativeTime = when {
+ ms == NEVER -> RelativeTime.fromMillis(RelativeTime.FOREVER)
+ other.ms == NEVER -> throw Error("Invalid argument for timestamp comparison")
+ ms < other.ms -> RelativeTime.fromMillis(0)
+ else -> RelativeTime.fromMillis(ms - other.ms)
+ }
+
+ operator fun minus(other: RelativeTime): Timestamp = when {
+ ms == NEVER -> this
+ other.ms == RelativeTime.FOREVER -> fromMillis(0)
+ else -> fromMillis(max(0, ms - other.ms))
+ }
+
+ override fun compareTo(other: Timestamp): Int {
+ return if (ms == NEVER) {
+ if (other.ms == NEVER) 0
+ else 1
+ } else {
+ if (other.ms == NEVER) -1
+ else ms.compareTo(other.ms)
+ }
+ }
+
+}
+
+@Serializable
+data class RelativeTime(
+ /**
+ * Duration in microseconds or "forever" to represent an infinite duration.
+ * Numeric values are capped at 2^53 - 1 inclusive.
+ */
+ @SerialName("d_us")
+ @Serializable(ForeverSerializer::class)
+ private val s: Long,
+) {
+ val ms: Long = s * 1000L
+
+ companion object {
+ internal const val FOREVER: Long = -1
+ fun forever(): RelativeTime = fromMillis(FOREVER)
+ fun fromMillis(ms: Long) = RelativeTime(ms / 100L)
+ }
+}
+
+internal abstract class MinusOneSerializer(private val keyword: String) :
+ JsonTransformingSerializer<Long>(Long.serializer()) {
+
+ override fun transformDeserialize(element: JsonElement): JsonElement {
+ return if (element.jsonPrimitive.contentOrNull == keyword) return JsonPrimitive(-1)
+ else super.transformDeserialize(element)
+ }
+
+ override fun transformSerialize(element: JsonElement): JsonElement {
+ return if (element.jsonPrimitive.longOrNull == -1L) return JsonPrimitive(keyword)
+ else element
+ }
+}
+
+internal object NeverSerializer : MinusOneSerializer("never")
+internal object ForeverSerializer : MinusOneSerializer("forever")
diff --git a/taler-kotlin-android/src/main/java/net/taler/common/Version.kt b/taler-kotlin-android/src/main/java/net/taler/common/Version.kt
new file mode 100644
index 0000000..f46913e
--- /dev/null
+++ b/taler-kotlin-android/src/main/java/net/taler/common/Version.kt
@@ -0,0 +1,70 @@
+/*
+ * 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 kotlin.math.sign
+
+/**
+ * Semantic versioning, but libtool-style.
+ * See https://www.gnu.org/software/libtool/manual/html_node/Libtool-versioning.html
+ */
+public data class Version(
+ val current: Int,
+ val revision: Int,
+ val age: Int
+) {
+ public companion object {
+ public fun parse(v: String): Version? {
+ val elements = v.split(":")
+ if (elements.size != 3) return null
+ val (currentStr, revisionStr, ageStr) = elements
+ val current = currentStr.toIntOrNull()
+ val revision = revisionStr.toIntOrNull()
+ val age = ageStr.toIntOrNull()
+ if (current == null || revision == null || age == null) return null
+ return Version(current, revision, age)
+ }
+ }
+
+ /**
+ * Compare two libtool-style versions.
+ *
+ * Returns a [VersionMatchResult] or null if the given version was null.
+ */
+ public fun compare(other: Version?): VersionMatchResult? {
+ if (other == null) return null
+ val compatible = current - age <= other.current &&
+ current >= other.current - other.age
+ val currentCmp = sign((current - other.current).toDouble()).toInt()
+ return VersionMatchResult(compatible, currentCmp)
+ }
+
+ /**
+ * Result of comparing two libtool versions.
+ */
+ public data class VersionMatchResult(
+ /**
+ * Is the first version compatible with the second?
+ */
+ val compatible: Boolean,
+ /**
+ * Is the first version older (-1), newer (+1) or identical (0)?
+ */
+ val currentCmp: Int
+ )
+
+}
diff --git a/taler-kotlin-android/src/main/res/values/colors.xml b/taler-kotlin-android/src/main/res/values/colors.xml
index c916442..5eb0587 100644
--- a/taler-kotlin-android/src/main/res/values/colors.xml
+++ b/taler-kotlin-android/src/main/res/values/colors.xml
@@ -21,4 +21,7 @@
<color name="green">#388E3C</color>
<color name="red">#C62828</color>
+ <color name="notice_background">#fff3cd</color>
+ <color name="notice_border">#ffecb5</color>
+ <color name="notice_text">#664d03</color>
</resources>
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
new file mode 100644
index 0000000..1ea4e70
--- /dev/null
+++ b/taler-kotlin-android/src/test/java/net/taler/common/AmountTest.kt
@@ -0,0 +1,372 @@
+/*
+ * 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.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 {
+
+ companion object {
+ fun getRandomAmount() = getRandomAmount(getRandomString(1, Random.nextInt(1, 12)))
+ fun getRandomAmount(currency: String): Amount {
+ val value = Random.nextLong(0, Amount.MAX_VALUE)
+ val fraction = Random.nextInt(0, Amount.MAX_FRACTION)
+ return Amount(currency, value, fraction)
+ }
+ }
+
+ @Test
+ fun testFromJSONString() {
+ 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)
+
+ str = "EUR:500000000.00000001"
+ amount = Amount.fromJSONString(str)
+ assertEquals(str, amount.toJSONString())
+ assertEquals("EUR", amount.currency)
+ assertEquals(500000000, amount.value)
+ assertEquals(1, amount.fraction)
+
+ str = "EUR:1500000000.00000003"
+ amount = Amount.fromJSONString(str)
+ assertEquals(str, amount.toJSONString())
+ assertEquals("EUR", amount.currency)
+ assertEquals(1500000000, amount.value)
+ assertEquals(3, amount.fraction)
+ }
+
+ @Test
+ fun testToString() {
+ 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))
+ }
+ }
+
+ @Test
+ fun testFromJSONStringAcceptsMaxValuesRejectsAbove() {
+ 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)
+
+ // 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 testFromJSONStringRejections() {
+ 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 testAddition() {
+ 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 testTimes() {
+ 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:0"), Amount.fromJSONString("EUR:1.11") * 0)
+ assertEquals(Amount.fromJSONString("EUR:1.11"), Amount.fromJSONString("EUR:1.11") * 1)
+ assertEquals(Amount.fromJSONString("EUR:2.22"), Amount.fromJSONString("EUR:1.11") * 2)
+ assertEquals(Amount.fromJSONString("EUR:3.33"), Amount.fromJSONString("EUR:1.11") * 3)
+ assertEquals(Amount.fromJSONString("EUR:4.44"), Amount.fromJSONString("EUR:1.11") * 4)
+ assertEquals(Amount.fromJSONString("EUR:5.55"), Amount.fromJSONString("EUR:1.11") * 5)
+ 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 testSubtraction() {
+ 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 testIsZero() {
+ 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())
+ }
+
+ @Test
+ fun testComparison() {
+ assertTrue(Amount.fromJSONString("EUR:0") <= Amount.fromJSONString("EUR:0"))
+ assertTrue(Amount.fromJSONString("EUR:0") <= Amount.fromJSONString("EUR:0.00000001"))
+ assertTrue(Amount.fromJSONString("EUR:0") < Amount.fromJSONString("EUR:0.00000001"))
+ assertTrue(Amount.fromJSONString("EUR:0") < Amount.fromJSONString("EUR:1"))
+ assertEquals(Amount.fromJSONString("EUR:0"), Amount.fromJSONString("EUR:0"))
+ assertEquals(Amount.fromJSONString("EUR:42"), Amount.fromJSONString("EUR:42"))
+ assertEquals(
+ Amount.fromJSONString("EUR:42.00000001"),
+ Amount.fromJSONString("EUR:42.00000001")
+ )
+ assertTrue(Amount.fromJSONString("EUR:42.00000001") >= Amount.fromJSONString("EUR:42.00000001"))
+ assertTrue(Amount.fromJSONString("EUR:42.00000002") >= Amount.fromJSONString("EUR:42.00000001"))
+ assertTrue(Amount.fromJSONString("EUR:42.00000002") > Amount.fromJSONString("EUR:42.00000001"))
+ assertTrue(Amount.fromJSONString("EUR:0.00000002") > Amount.fromJSONString("EUR:0.00000001"))
+ assertTrue(Amount.fromJSONString("EUR:0.00000001") > Amount.fromJSONString("EUR:0"))
+ assertTrue(Amount.fromJSONString("EUR:2") > Amount.fromJSONString("EUR:1"))
+
+ assertThrows<IllegalStateException>("could compare amounts with different currencies") {
+ Amount.fromJSONString("EUR:0.5") < Amount.fromJSONString("USD:0.50000001")
+ }
+ }
+
+}
diff --git a/taler-kotlin-android/src/test/java/net/taler/common/ContractTermsTest.kt b/taler-kotlin-android/src/test/java/net/taler/common/ContractTermsTest.kt
index 3a2cdb4..3e1ebc4 100644
--- a/taler-kotlin-android/src/test/java/net/taler/common/ContractTermsTest.kt
+++ b/taler-kotlin-android/src/test/java/net/taler/common/ContractTermsTest.kt
@@ -18,7 +18,6 @@ package net.taler.common
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
-import net.taler.lib.common.Timestamp
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -38,14 +37,14 @@ class ContractTermsTest {
},
"fulfillment_url":"https://shop.test.taler.net/essay/1._The_Free_Software_Definition",
"summary":"Essay: 1. The Free Software Definition",
- "refund_deadline":{"t_ms":"never"},
- "wire_transfer_deadline":{"t_ms":1596128564000},
+ "refund_deadline":{"t_s":"never"},
+ "wire_transfer_deadline":{"t_s":1596128564},
"products":[],
"h_wire":"KV40K023N8EC1F5100TYNS23C4XN68Y1Z3PTJSWFGTMCNYD54KT4S791V2VQ91SZANN86VDAA369M4VEZ0KR6DN71EVRRZA71K681M0",
"wire_method":"x-taler-bank",
"order_id":"2020.212-01M9VKEAPF76C",
- "timestamp":{"t_ms":1596128114000},
- "pay_deadline":{"t_ms":"never"},
+ "timestamp":{"t_s":1596128114},
+ "pay_deadline":{"t_s":"never"},
"max_wire_fee":"TESTKUDOS:1",
"max_fee":"TESTKUDOS:1",
"wire_fee_amortization":3,
diff --git a/taler-kotlin-android/src/test/java/net/taler/common/TalerUriTest.kt b/taler-kotlin-android/src/test/java/net/taler/common/TalerUriTest.kt
new file mode 100644
index 0000000..128f707
--- /dev/null
+++ b/taler-kotlin-android/src/test/java/net/taler/common/TalerUriTest.kt
@@ -0,0 +1,65 @@
+/*
+ * 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 net.taler.common.TalerUri.parseWithdrawUri
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class TalerUriTest {
+
+ @Test
+ fun testParseWithdrawUri() {
+ // correct parsing
+ var uri = "taler://withdraw/bank.example.com/12345"
+ var expected = TalerUri.WithdrawUriResult("https://bank.example.com/", "12345")
+ assertEquals(expected, parseWithdrawUri(uri))
+
+ // correct parsing with insecure http
+ uri = "taler+http://withdraw/bank.example.org/foo"
+ expected = TalerUri.WithdrawUriResult("http://bank.example.org/", "foo")
+ assertEquals(expected, parseWithdrawUri(uri))
+
+ // correct parsing with long path
+ uri = "taler://withdraw/bank.example.com/foo/bar/23/42/1337/1234567890"
+ expected =
+ TalerUri.WithdrawUriResult("https://bank.example.com/foo/bar/23/42/1337", "1234567890")
+ assertEquals(expected, parseWithdrawUri(uri))
+
+ // rejects incorrect scheme
+ uri = "talerx://withdraw/bank.example.com/12345"
+ assertNull(parseWithdrawUri(uri))
+
+ // rejects incorrect authority
+ uri = "taler://withdrawx/bank.example.com/12345"
+ assertNull(parseWithdrawUri(uri))
+
+ // rejects incorrect authority with insecure http
+ uri = "taler+http://withdrawx/bank.example.com/12345"
+ assertNull(parseWithdrawUri(uri))
+
+ // rejects empty withdrawalId
+ uri = "taler://withdraw/bank.example.com//"
+ assertNull(parseWithdrawUri(uri))
+
+ // rejects empty path and withdrawalId
+ uri = "taler://withdraw/bank.example.com////"
+ assertNull(parseWithdrawUri(uri))
+ }
+
+}
diff --git a/taler-kotlin-android/src/main/java/net/taler/lib/android/Serialization.kt b/taler-kotlin-android/src/test/java/net/taler/common/TestUtils.kt
index 7eb4480..b0f191d 100644
--- a/taler-kotlin-android/src/main/java/net/taler/lib/android/Serialization.kt
+++ b/taler-kotlin-android/src/test/java/net/taler/common/TestUtils.kt
@@ -14,8 +14,27 @@
* GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-package net.taler.lib.android
+package net.taler.common
-interface CustomClassDiscriminator {
- val discriminator: String
+import kotlin.random.Random
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+
+private val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
+fun getRandomString(minLength: Int = 1, maxLength: Int = Random.nextInt(0, 1337)) =
+ (minLength..maxLength)
+ .map { Random.nextInt(0, charPool.size) }
+ .map(charPool::get)
+ .joinToString("")
+
+inline fun <reified T : Throwable> assertThrows(
+ msg: String? = null,
+ function: () -> Any
+) {
+ try {
+ function.invoke()
+ fail(msg)
+ } catch (e: Exception) {
+ assertTrue(e is T)
+ }
}
diff --git a/taler-kotlin-android/src/test/java/net/taler/common/TimeTest.kt b/taler-kotlin-android/src/test/java/net/taler/common/TimeTest.kt
new file mode 100644
index 0000000..4049940
--- /dev/null
+++ b/taler-kotlin-android/src/test/java/net/taler/common/TimeTest.kt
@@ -0,0 +1,46 @@
+/*
+ * 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 kotlinx.serialization.json.Json.Default.decodeFromString
+import kotlinx.serialization.json.Json.Default.encodeToString
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import kotlin.random.Random
+
+// TODO test other functionality of Timestamp and Duration
+class TimeTest {
+
+ @Test
+ fun testSerialize() {
+ for (i in 0 until 42) {
+ val t = Random.nextLong()
+ assertEquals("""{"t_s":$t}""", encodeToString(Timestamp.serializer(), Timestamp(t)))
+ }
+ assertEquals("""{"t_s":"never"}""", encodeToString(Timestamp.serializer(), Timestamp.never()))
+ }
+
+ @Test
+ fun testDeserialize() {
+ for (i in 0 until 42) {
+ val t = Random.nextLong()
+ assertEquals(Timestamp(t), decodeFromString(Timestamp.serializer(), """{ "t_s": $t }"""))
+ }
+ assertEquals(Timestamp.never(), decodeFromString(Timestamp.serializer(), """{ "t_s": "never" }"""))
+ }
+
+}
diff --git a/taler-kotlin-android/src/test/java/net/taler/common/VersionTest.kt b/taler-kotlin-android/src/test/java/net/taler/common/VersionTest.kt
new file mode 100644
index 0000000..d8d7149
--- /dev/null
+++ b/taler-kotlin-android/src/test/java/net/taler/common/VersionTest.kt
@@ -0,0 +1,65 @@
+/*
+ * 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 org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+
+class VersionTest {
+
+ @Test
+ fun testParse() {
+ assertNull(Version.parse(""))
+ assertNull(Version.parse("foo"))
+ assertNull(Version.parse("foo:bar:foo"))
+ assertNull(Version.parse("0:0:0:"))
+ assertNull(Version.parse("0:0:"))
+ assertEquals(Version(0, 0, 0), Version.parse("0:0:0"))
+ assertEquals(Version(1, 2, 3), Version.parse("1:2:3"))
+ assertEquals(Version(1337, 42, 23), Version.parse("1337:42:23"))
+ }
+
+ @Test
+ fun testComparision() {
+ assertEquals(
+ Version.VersionMatchResult(true, 0),
+ Version.parse("0:0:0")!!.compare(Version.parse("0:0:0"))
+ )
+ assertEquals(
+ Version.VersionMatchResult(true, -1),
+ Version.parse("0:0:0")!!.compare(Version.parse("1:0:1"))
+ )
+ assertEquals(
+ Version.VersionMatchResult(true, -1),
+ Version.parse("0:0:0")!!.compare(Version.parse("1:5:1"))
+ )
+ assertEquals(
+ Version.VersionMatchResult(false, -1),
+ Version.parse("0:0:0")!!.compare(Version.parse("1:5:0"))
+ )
+ assertEquals(
+ Version.VersionMatchResult(false, 1),
+ Version.parse("1:0:0")!!.compare(Version.parse("0:5:0"))
+ )
+ assertEquals(
+ Version.VersionMatchResult(true, 0),
+ Version.parse("1:0:1")!!.compare(Version.parse("1:5:1"))
+ )
+ }
+
+}