diff options
Diffstat (limited to 'taler-kotlin-android/src/main/java')
8 files changed, 262 insertions, 128 deletions
diff --git a/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt b/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt index 18fb6cb..3e3bd0a 100644 --- a/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt +++ b/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt @@ -16,17 +16,24 @@ 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) +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( @@ -50,25 +57,23 @@ public data class Amount( * of the base currency value. For example, a fraction * of 50_000_000 would correspond to 50 cents. */ - val fraction: Int + val fraction: Int, + + /** + * Currency specification for amount + */ + val spec: CurrencySpecification? = null, ) : Comparable<Amount> { public companion object { private const val FRACTIONAL_BASE: Int = 100000000 // 1e8 - public val SEGWIT_MIN = Amount("BTC", 0, 294) 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 fromDouble(currency: String, value: Double): Amount { - val intPart = Math.floor(value).toLong() - val fraPart = Math.floor((value - intPart) * FRACTIONAL_BASE).toInt() - return Amount(currency, intPart, fraPart) - } - public fun zero(currency: String): Amount { return Amount(checkCurrency(currency), 0, 0) } @@ -88,14 +93,35 @@ public data class Amount( val fractionStr = valueSplit[1] if (fractionStr.length > MAX_FRACTION_LENGTH) throw AmountParserException("Fraction $fractionStr too long") - val fraction = "0.$fractionStr".toDoubleOrNull() - ?.times(FRACTIONAL_BASE) - ?.roundToInt() - checkFraction(fraction) + 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) @@ -133,7 +159,8 @@ public data class Amount( 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() + 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 @@ -152,6 +179,8 @@ public data class Amount( return Amount(checkCurrency(currency), this.value, this.fraction) } + fun withSpec(spec: CurrencySpecification?) = copy(spec = spec) + public operator fun minus(other: Amount): Amount { check(currency == other.currency) { "Can only subtract from same currency" } var resultValue = value @@ -178,8 +207,50 @@ public data class Amount( return "$currency:$amountStr" } - override fun toString(): String { - return "$amountStr $currency" + override fun toString() = toString( + showSymbol = true, + negative = false, + ) + + fun toString( + showSymbol: Boolean = true, + negative: Boolean = false, + symbols: DecimalFormatSymbols = DecimalFormat().decimalFormatSymbols, + ): String { + // We clone the object to safely/cleanly modify it + val s = symbols.clone() as DecimalFormatSymbols + val amount = (if (negative) "-$amountStr" else amountStr).toBigDecimal() + + // No currency spec, so we render normally + if (spec == null) { + val format = NumberFormat.getInstance() + format.maximumFractionDigits = MAX_FRACTION_LENGTH + format.minimumFractionDigits = 0 + if (Build.VERSION.SDK_INT >= 34) { + s.groupingSeparator = s.monetaryGroupingSeparator + } + s.decimalSeparator = s.monetaryDecimalSeparator + (format as DecimalFormat).decimalFormatSymbols = s + + val fmt = format.format(amount) + return if (showSymbol) "$fmt $currency" else fmt + } + + // There is currency spec, so we can do things right + val format = NumberFormat.getCurrencyInstance() + format.maximumFractionDigits = spec.numFractionalNormalDigits + format.minimumFractionDigits = spec.numFractionalTrailingZeroDigits + s.currencySymbol = spec.symbol ?: "" + (format as DecimalFormat).decimalFormatSymbols = s + + val fmt = format.format(amount) + return if (showSymbol) { + // If no symbol, then we use the currency string + if (spec.symbol != null) fmt else "$fmt $currency" + } else { + // We should do better than manually removing the symbol here + fmt.replace(s.currencySymbol, "").trim() + } } override fun compareTo(other: Amount): Int { diff --git a/taler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt b/taler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt index 7dde872..8f3e5d5 100644 --- a/taler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt +++ b/taler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt @@ -23,6 +23,7 @@ import android.content.Context.CONNECTIVITY_SERVICE import android.content.Intent import android.net.ConnectivityManager import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET +import android.net.Uri import android.os.Build.VERSION.SDK_INT import android.os.Looper import android.text.format.DateUtils.DAY_IN_MILLIS @@ -97,14 +98,22 @@ fun Context.isOnline(): Boolean { } 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) { @@ -112,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 index 32885df..4b77f85 100644 --- a/taler-kotlin-android/src/main/java/net/taler/common/Bech32.kt +++ b/taler-kotlin-android/src/main/java/net/taler/common/Bech32.kt @@ -13,6 +13,7 @@ * 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. @@ -23,27 +24,17 @@ // modified version of https://gist.github.com/iramiller/4ebfcdfbc332a9722c4a4abeb4e16454 -import net.taler.common.CyptoUtils +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() /** - * Given an array of bytes, associate an HRP and return a Bech32Data instance. - */ -fun ByteArray.toBech32Data(hrp: String) = - Bech32Data(hrp, Bech32.convertBits(this, 8, 5, true)) - -/** - * Using a string in bech32 encoded address format, parses out and returns a Bech32Data instance - */ -fun String.toBech32Data() = Bech32.decode(this) - -/** * Bech32 Data encoding instance containing data for encoding as well as a human readable prefix */ data class Bech32Data(val hrp: String, val fiveBitData: ByteArray) { @@ -71,6 +62,30 @@ data class Bech32Data(val hrp: String, val fiveBitData: ByteArray) { 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 + } } /** @@ -80,32 +95,32 @@ class Bech32 { companion object { const val CHECKSUM_SIZE = 6 - const val MIN_VALID_LENGTH = 8 - const val MAX_VALID_LENGTH = 90 + private const val MIN_VALID_LENGTH = 8 + private const val MAX_VALID_LENGTH = 90 const val MIN_VALID_CODEPOINT = 33 - const val MAX_VALID_CODEPOINT = 126 + private const val MAX_VALID_CODEPOINT = 126 const val charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" - val gen = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) + private val gen = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) fun generateFakeSegwitAddress(reservePub: String?, addr: String): List<String> { - if (reservePub == null || reservePub.isEmpty()) return listOf<String>() + if (reservePub == null || reservePub.isEmpty()) return listOf() val pub = CyptoUtils.decodeCrock(reservePub) if (pub.size != 32) return listOf() - val first_rnd = pub.copyOfRange(0,4) - val second_rnd = pub.copyOfRange(0,4) + val firstRnd = pub.copyOfRange(0, 4) + val secondRnd = pub.copyOfRange(0, 4) - first_rnd[0] = first_rnd[0].and(0b0111_1111); - second_rnd[0] = second_rnd[0].or(0b1000_0000.toByte()); + firstRnd[0] = firstRnd[0].and(0b0111_1111) + secondRnd[0] = secondRnd[0].or(0b1000_0000.toByte()) - val first_part = ByteArray(20); - first_rnd.copyInto( first_part, 0, 0, 4) - pub.copyInto( first_part, 4, 0, 16) + val firstPart = ByteArray(20) + firstRnd.copyInto(firstPart, 0, 0, 4) + pub.copyInto(firstPart, 4, 0, 16) - val second_part = ByteArray(20); - second_rnd.copyInto( second_part, 0, 0, 4) - pub.copyInto( second_part, 4, 16, 32) + val secondPart = ByteArray(20) + secondRnd.copyInto(secondPart, 0, 0, 4) + pub.copyInto(secondPart, 4, 16, 32) val zero = ByteArray(1) zero[0] = 0 @@ -117,8 +132,8 @@ class Bech32 { } return listOf( - Bech32Data(hrp, zero + convertBits(first_part, 8, 5, true)).address, - Bech32Data(hrp, zero + convertBits(second_part, 8, 5, true)).address, + Bech32Data(hrp, zero + convertBits(firstPart, 8, 5, true)).address, + Bech32Data(hrp, zero + convertBits(secondPart, 8, 5, true)).address, ) } @@ -126,22 +141,31 @@ class Bech32 { * Decodes a Bech32 String */ fun decode(bech32: String): Bech32Data { - require(bech32.length >= MIN_VALID_LENGTH && bech32.length <= MAX_VALID_LENGTH) { "invalid bech32 string length" } - require(bech32.toCharArray().none { c -> c.toInt() < MIN_VALID_CODEPOINT || c.toInt() > MAX_VALID_CODEPOINT }) - { "invalid character in bech32: ${bech32.toCharArray().map { c -> c.toInt() } - .filter { c -> c.toInt() < MIN_VALID_CODEPOINT || c.toInt() > MAX_VALID_CODEPOINT }}" } + 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.equals(bech32.toLowerCase()) || bech32.equals(bech32.toUpperCase())) + 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'" } + require(bech32.substring(1).dropLast(CHECKSUM_SIZE) + .contains('1')) { "invalid index of '1'" } - val hrp = bech32.substringBeforeLast('1').toLowerCase() - val dataString = bech32.substringAfterLast('1').toLowerCase() + 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"} + 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 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" } @@ -154,10 +178,10 @@ class Bech32 { * 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"} + 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. - var regrouped = arrayListOf<Byte>() + val regrouped = arrayListOf<Byte>() var nextByte = 0.toUByte() var filledBits = 0 @@ -174,8 +198,9 @@ class Bech32 { 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"} + 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)) @@ -218,24 +243,24 @@ class Bech32 { * Calculates a bech32 checksum based on BIP 173 specification */ fun checksum(hrp: String, data: Array<Byte>): ByteArray { - var values = expandHrp(hrp) + val values = expandHrp(hrp) .plus(data.map { d -> d.toInt() }) - .plus(Array<Int>(6){ _ -> 0}.toIntArray()) + .plus(Array(6) { 0 }.toIntArray()) - var poly = polymod(values) xor 1 + val poly = polymod(values) xor 1 return (0..5).map { - ((poly shr (5 * (5-it))) and 31).toByte() + ((poly shr (5 * (5 - it))) and 31).toByte() }.toByteArray() } /** * Expands the human readable prefix per BIP173 for Checksum encoding */ - fun expandHrp(hrp: String) = - hrp.map { c -> c.toInt() shr 5 } + private fun expandHrp(hrp: String) = + hrp.map { c -> c.code shr 5 } .plus(0) - .plus(hrp.map { c -> c.toInt() and 31 }) + .plus(hrp.map { c -> c.code and 31 }) .toIntArray() /** @@ -243,9 +268,9 @@ class Bech32 { */ fun polymod(values: IntArray): Int { var chk = 1 - return values.map { - var b = chk shr 25 - chk = ((chk and 0x1ffffff) shl 5) xor it + 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] diff --git a/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt b/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt index 910cc36..5b614fe 100644 --- a/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt +++ b/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt @@ -42,7 +42,7 @@ abstract class Product { abstract val productId: String? abstract val description: String abstract val descriptionI18n: Map<String, String>? - abstract val price: Amount + abstract val price: Amount? abstract val location: String? abstract val image: String? val localizedDescription: String @@ -60,14 +60,14 @@ data class ContractProduct( override val description: String, @SerialName("description_i18n") override val descriptionI18n: Map<String, String>? = null, - override val price: Amount, + override val price: Amount? = null, @SerialName("delivery_location") override val location: String? = null, override val image: String? = null, - val quantity: Int + val quantity: Int = 1, ) : Product() { - val totalPrice: Amount by lazy { - price * quantity + val totalPrice: Amount? by lazy { + price?.let { price * quantity } } } diff --git a/taler-kotlin-android/src/main/java/net/taler/common/CurrencySpecification.kt b/taler-kotlin-android/src/main/java/net/taler/common/CurrencySpecification.kt new file mode 100644 index 0000000..02113f4 --- /dev/null +++ b/taler-kotlin-android/src/main/java/net/taler/common/CurrencySpecification.kt @@ -0,0 +1,36 @@ +/* + * This file is part of GNU Taler + * (C) 2024 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.common + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CurrencySpecification( + val name: String, + @SerialName("num_fractional_input_digits") + val numFractionalInputDigits: Int, + @SerialName("num_fractional_normal_digits") + val numFractionalNormalDigits: Int, + @SerialName("num_fractional_trailing_zero_digits") + val numFractionalTrailingZeroDigits: Int, + @SerialName("alt_unit_names") + val altUnitNames: Map<Int, String>, +) { + // TODO: add support for alt units + val symbol: String? get() = altUnitNames[0] +}
\ No newline at end of file diff --git a/taler-kotlin-android/src/main/java/net/taler/common/Event.kt b/taler-kotlin-android/src/main/java/net/taler/common/Event.kt index 752e20e..868063c 100644 --- a/taler-kotlin-android/src/main/java/net/taler/common/Event.kt +++ b/taler-kotlin-android/src/main/java/net/taler/common/Event.kt @@ -49,7 +49,7 @@ fun <T> T.toEvent() = Event(this) * [onEvent] is *only* called if the [Event]'s contents has not been consumed. */ class EventObserver<T>(private val onEvent: (T) -> Unit) : Observer<Event<T>> { - override fun onChanged(event: Event<T>?) { - event?.getIfNotConsumed()?.let { onEvent(it) } + override fun onChanged(value: Event<T>) { + value.getIfNotConsumed()?.let { onEvent(it) } } } diff --git a/taler-kotlin-android/src/main/java/net/taler/common/TalerUri.kt b/taler-kotlin-android/src/main/java/net/taler/common/TalerUri.kt index a1b7225..999408c 100644 --- a/taler-kotlin-android/src/main/java/net/taler/common/TalerUri.kt +++ b/taler-kotlin-android/src/main/java/net/taler/common/TalerUri.kt @@ -45,7 +45,7 @@ public object TalerUri { } else -> return null } - if (!uri.startsWith(prefix)) 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('/') } 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 index 61bbce8..f280d5f 100644 --- a/taler-kotlin-android/src/main/java/net/taler/common/Time.kt +++ b/taler-kotlin-android/src/main/java/net/taler/common/Time.kt @@ -29,50 +29,31 @@ import kotlin.math.max @Serializable data class Timestamp( - @SerialName("t_ms") - @Serializable(NeverSerializer::class) - val old_ms: Long? = null, @SerialName("t_s") @Serializable(NeverSerializer::class) - private val s: Long? = null, + private val s: Long, ) : Comparable<Timestamp> { - constructor(ms: Long) : this(ms, null) - companion object { private const val NEVER: Long = -1 - fun now(): Timestamp = Timestamp(System.currentTimeMillis()) + fun now(): Timestamp = fromMillis(System.currentTimeMillis()) fun never(): Timestamp = Timestamp(NEVER) + fun fromMillis(ms: Long) = Timestamp(ms / 1000L) } - val ms: Long = if (s != null) { - s * 1000L - } else if (old_ms !== null) { - old_ms - } else { - throw Exception("timestamp didn't have t_s or t_ms") - } - - - /** - * Returns a copy of this [Timestamp] rounded to seconds. - */ - fun truncateSeconds(): Timestamp { - if (ms == NEVER) return Timestamp(ms) - return Timestamp((ms / 1000L) * 1000L) - } + val ms: Long = s * 1000L - operator fun minus(other: Timestamp): Duration = when { - ms == NEVER -> Duration(Duration.FOREVER) - other.ms == NEVER -> throw Error("Invalid argument for timestamp comparision") - ms < other.ms -> Duration(0) - else -> Duration(ms - other.ms) + 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: Duration): Timestamp = when { + operator fun minus(other: RelativeTime): Timestamp = when { ms == NEVER -> this - other.ms == Duration.FOREVER -> Timestamp(0) - else -> Timestamp(max(0, ms - other.ms)) + other.ms == RelativeTime.FOREVER -> fromMillis(0) + else -> fromMillis(max(0, ms - other.ms)) } override fun compareTo(other: Timestamp): Int { @@ -88,26 +69,21 @@ data class Timestamp( } @Serializable -data class Duration( - @SerialName("d_ms") - @Serializable(ForeverSerializer::class) val old_ms: Long? = null, - @SerialName("d_s") +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? = null, + private val s: Long, ) { - val ms: Long = if (s != null) { - s * 1000L - } else if (old_ms !== null) { - old_ms - } else { - throw Exception("duration didn't have d_s or d_ms") - } - - constructor(ms: Long) : this(ms, null) + val ms: Long = s * 1000L companion object { internal const val FOREVER: Long = -1 - fun forever(): Duration = Duration(FOREVER) + fun forever(): RelativeTime = fromMillis(FOREVER) + fun fromMillis(ms: Long) = RelativeTime(ms / 100L) } } |