diff options
Diffstat (limited to 'taler-kotlin-android/src/main/java/net/taler/common')
11 files changed, 948 insertions, 26 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 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 + ) + +} |