From 17fe6738cfcb058f612801a19c2c214b5e9a0fb0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 7 Aug 2026 14:53:30 -0400 Subject: [PATCH 1/2] feat(kmp): extract DerivePath to KMP module + split utils into common/android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - :libs:encryption:derivepath — new KMP module, pure Kotlin, no platform deps. Extracts DerivePath (BIP-44/SLIP-10 path parsing) from mnemonic so it can compile on iOS targets via SharedCore XCFramework. relationship(Domain) refactored to relationship(host: String) — callers pass domain.relationshipHost. Tests moved from mnemonic to derivepath commonTest; DerivePathTest runs on JVM + iosSimulatorArm64. - :libs:encryption:utils — converted to KMP: portable byte helpers (intToByteArray, byteArrayToLong, subByteArray, hexEncodedString, base58 extensions, replaceParam, toUTF8Bytes, Byte.shl) moved to commonMain; Android-specific helpers (Base64, URL encode, protobuf ByteString, Ed25519, MessageDigest SHA-512) moved to androidMain. Utils.java ported to Kotlin and placed in androidMain. Android-coupled tests (Base64, URL, Utils) moved to androidHostTest; commonTest covers the pure-Kotlin helpers with KAT vectors. - :kmp:shared-core — exports :libs:encryption:derivepath so iOS gets it through the SharedCore XCFramework. --- kmp/shared-core/build.gradle.kts | 2 + libs/encryption/derivepath/build.gradle.kts | 28 + .../kotlin/com/getcode/crypt/DerivePath.kt | 97 +++ .../com/getcode/crypt/DerivePathTest.kt | 13 + libs/encryption/mnemonic/build.gradle.kts | 1 + .../kotlin/com/getcode/crypt/DerivePath.kt | 99 ---- .../main/kotlin/com/getcode/model/Domain.kt | 46 -- libs/encryption/utils/build.gradle.kts | 49 +- .../com/getcode/utils/Base64ExtensionsTest.kt | 0 .../com/getcode/utils/UrlExtensionsTest.kt | 18 + .../kotlin/com/getcode/utils/UtilsTest.kt | 0 .../com/getcode/utils/Extensions.android.kt | 48 ++ .../kotlin/com/getcode/utils/Utils.kt | 443 ++++++++++++++ .../kotlin/com/getcode/utils/Extensions.kt | 132 ++--- .../com/getcode/utils/ExtensionsTest.kt | 13 - .../main/java/com/getcode/utils/Utils.java | 555 ------------------ settings.gradle.kts | 1 + 17 files changed, 730 insertions(+), 815 deletions(-) create mode 100644 libs/encryption/derivepath/build.gradle.kts create mode 100644 libs/encryption/derivepath/src/commonMain/kotlin/com/getcode/crypt/DerivePath.kt rename libs/encryption/{mnemonic/src/test => derivepath/src/commonTest}/kotlin/com/getcode/crypt/DerivePathTest.kt (91%) delete mode 100644 libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivePath.kt delete mode 100644 libs/encryption/mnemonic/src/main/kotlin/com/getcode/model/Domain.kt rename libs/encryption/utils/src/{test => androidHostTest}/kotlin/com/getcode/utils/Base64ExtensionsTest.kt (100%) create mode 100644 libs/encryption/utils/src/androidHostTest/kotlin/com/getcode/utils/UrlExtensionsTest.kt rename libs/encryption/utils/src/{test => androidHostTest}/kotlin/com/getcode/utils/UtilsTest.kt (100%) create mode 100644 libs/encryption/utils/src/androidMain/kotlin/com/getcode/utils/Extensions.android.kt create mode 100644 libs/encryption/utils/src/androidMain/kotlin/com/getcode/utils/Utils.kt rename libs/encryption/utils/src/{main => commonMain}/kotlin/com/getcode/utils/Extensions.kt (53%) rename libs/encryption/utils/src/{test => commonTest}/kotlin/com/getcode/utils/ExtensionsTest.kt (92%) delete mode 100644 libs/encryption/utils/src/main/java/com/getcode/utils/Utils.java diff --git a/kmp/shared-core/build.gradle.kts b/kmp/shared-core/build.gradle.kts index ccd6d09386..afdb776f25 100644 --- a/kmp/shared-core/build.gradle.kts +++ b/kmp/shared-core/build.gradle.kts @@ -21,6 +21,7 @@ kotlin { baseName = "SharedCore" isStatic = true export(project(":libs:encryption:base58")) + export(project(":libs:encryption:derivepath")) } } @@ -28,6 +29,7 @@ kotlin { commonMain { dependencies { api(project(":libs:encryption:base58")) + api(project(":libs:encryption:derivepath")) } } } diff --git a/libs/encryption/derivepath/build.gradle.kts b/libs/encryption/derivepath/build.gradle.kts new file mode 100644 index 0000000000..b8090aefb4 --- /dev/null +++ b/libs/encryption/derivepath/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") +} + +kotlin { + android { + namespace = "com.getcode.encryption.derivepath" + compileSdk = 37 + minSdk = 29 + withHostTest {} + } + + iosArm64() + iosSimulatorArm64() + iosX64() + + sourceSets { + commonMain { + // Pure Kotlin — no external dependencies needed. + } + commonTest { + dependencies { + implementation(kotlin("test")) + } + } + } +} diff --git a/libs/encryption/derivepath/src/commonMain/kotlin/com/getcode/crypt/DerivePath.kt b/libs/encryption/derivepath/src/commonMain/kotlin/com/getcode/crypt/DerivePath.kt new file mode 100644 index 0000000000..f214ea8910 --- /dev/null +++ b/libs/encryption/derivepath/src/commonMain/kotlin/com/getcode/crypt/DerivePath.kt @@ -0,0 +1,97 @@ +package com.getcode.crypt + +/// Represents a BIP-44/SLIP-10 hierarchical derivation path (e.g. `m/44'/501'/0'/0'`). +class DerivePath(val indexes: List, val password: String? = null) { + /// Returns the canonical string form of the path (e.g. `m/44'/501'/0'/0'`). + fun stringRepresentation(): String { + val components = indexes.joinToString(separator) { it.stringRepresentation() } + return "$identifier$separator$components" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DerivePath) return false + if (indexes != other.indexes) return false + return true + } + + override fun hashCode(): Int { + return indexes.hashCode() + } + + /// A single index component of a derivation path, optionally hardened. + data class Index(val value: Int, val hardened: Boolean) { + /// Returns the string form of this index (e.g. `0'` for a hardened index, `0` otherwise). + fun stringRepresentation(): String = + value.toString().let { if (hardened) "$it$hardener" else it } + } + + companion object { + /// Parses a derivation path string (e.g. `m/44'/501'/0'/0'`) into a `DerivePath`, or returns `null` if invalid. + fun newInstance(string: String, password: String? = null): DerivePath? { + val strings = string.split(separator) + if (strings.firstOrNull() != identifier) return null + val indexStrings = strings.drop(1) + + val indexes: List = indexStrings.map { s -> + val hardened = s.contains(hardener) + val value = s.replace(hardener, "").toIntOrNull() ?: return@map null + Index(value, hardened) + }.filterNotNull() + + if (indexes.size != indexStrings.count()) return null + + return DerivePath(indexes, password) + } + + // Primary - m/44'/501'/0'/0' + // + // Incoming - m/44'/501'/0'/0'/i'/2 + // Outgoing - m/44'/501'/0'/0'/i'/3 + // + // Relationship - m/44'/501'/0'/0'/0'/0 + // Swap - m/44'/501'/0'/0'/1'/0 + // + // Bucket1 - m/44'/501'/0'/0'/0'/1 + // Bucket10 - m/44'/501'/0'/0'/0'/10 + // Bucket100 - m/44'/501'/0'/0'/0'/100 + // Bucket1k - m/44'/501'/0'/0'/0'/1000 + // Bucket10k - m/44'/501'/0'/0'/0'/10000 + // Bucket100k - m/44'/501'/0'/0'/0'/100000 + // Bucket1m - m/44'/501'/0'/0'/0'/1000000 + + val bucket1 = newInstance("m/44'/501'/0'/0'/0'/1")!! + val bucket10 = newInstance("m/44'/501'/0'/0'/0'/10")!! + val bucket100 = newInstance("m/44'/501'/0'/0'/0'/100")!! + val bucket1k = newInstance("m/44'/501'/0'/0'/0'/1000")!! + val bucket10k = newInstance("m/44'/501'/0'/0'/0'/10000")!! + val bucket100k = newInstance("m/44'/501'/0'/0'/0'/100000")!! + val bucket1m = newInstance("m/44'/501'/0'/0'/0'/1000000")!! + val primary = newInstance("m/44'/501'/0'/0'")!! + val swap = newInstance("m/44'/501'/0'/0'/1'/0")!! + + /// Returns the derivation path for an incoming bucket at the given index. + fun getBucketIncoming(index: Int): DerivePath = + newInstance("m/44'/501'/0'/0'/$index'/2")!! + + /// Returns the derivation path for an outgoing bucket at the given index. + fun getBucketOutgoing(index: Int): DerivePath = + newInstance("m/44'/501'/0'/0'/$index'/3")!! + + /// Returns the derivation path for a pool at the given index. + fun getPool(index: Long): DerivePath = + newInstance("m/44'/501'/0'/0'/7665'/$index'")!! + + /// Returns the derivation path for a pool rendezvous key at the given index. + fun getPoolRendezvous(index: Long): DerivePath = + newInstance("m/44'/501'/0'/0'/2335'/$index'")!! + + /// Returns the relationship derivation path using the given domain host string as the password. + fun relationship(host: String): DerivePath = + newInstance("m/44'/501'/0'/0'/0'/0", password = host)!! + + private const val identifier = "m" + private const val separator = "/" + private const val hardener = "'" + } +} diff --git a/libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/DerivePathTest.kt b/libs/encryption/derivepath/src/commonTest/kotlin/com/getcode/crypt/DerivePathTest.kt similarity index 91% rename from libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/DerivePathTest.kt rename to libs/encryption/derivepath/src/commonTest/kotlin/com/getcode/crypt/DerivePathTest.kt index 94828c20b0..5fe90b3f07 100644 --- a/libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/DerivePathTest.kt +++ b/libs/encryption/derivepath/src/commonTest/kotlin/com/getcode/crypt/DerivePathTest.kt @@ -132,5 +132,18 @@ class DerivePathTest { assertEquals(a, b) } + @Test + fun relationshipUsesHostAsPassword() { + val path = DerivePath.relationship("example.com") + assertNotNull(path) + assertEquals("m/44'/501'/0'/0'/0'/0", path.stringRepresentation()) + assertEquals("example.com", path.password) + } + + @Test + fun primaryPathString() { + assertEquals("m/44'/501'/0'/0'", DerivePath.primary.stringRepresentation()) + } + private fun assertFalse(value: Boolean) = kotlin.test.assertFalse(value) } diff --git a/libs/encryption/mnemonic/build.gradle.kts b/libs/encryption/mnemonic/build.gradle.kts index 57db319e15..363aeabdba 100644 --- a/libs/encryption/mnemonic/build.gradle.kts +++ b/libs/encryption/mnemonic/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { implementation(project(":libs:encryption:base58")) + api(project(":libs:encryption:derivepath")) implementation(project(":libs:encryption:ed25519")) implementation(project(":libs:encryption:hmac")) implementation(project(":libs:encryption:sha256")) diff --git a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivePath.kt b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivePath.kt deleted file mode 100644 index 9e876ef1df..0000000000 --- a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivePath.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.getcode.crypt - -import com.getcode.model.Domain - -class DerivePath(val indexes: List, val password: String? = null) { - fun stringRepresentation(): String { - val components = indexes.joinToString(separator) { it.stringRepresentation() } - return "$identifier$separator$components" - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as DerivePath - - if (indexes != other.indexes) return false - - return true - } - - override fun hashCode(): Int { - return indexes.hashCode() - } - - data class Index(val value: Int, val hardened: Boolean) { - fun stringRepresentation(): String = - value.toString().let { if (hardened) "$it$hardener" else it } - } - - companion object { - fun newInstance(string: String, password: String? = null): DerivePath? { - val strings = string.split(separator) - if (strings.firstOrNull() != identifier) return null - val indexStrings = strings.drop(1) - - val indexes: List = indexStrings.map { s -> - val hardened = s.contains(hardener) - val value = s.replace(hardener, "").toIntOrNull() ?: return@map null - Index(value, hardened) - }.filterNotNull() - - if (indexes.size != indexStrings.count()) return null - - return DerivePath(indexes, password) - } - - // Primary - m/44'/501'/0/0 - // - // Incoming - m/44'/501'/0/0/i/2 - // Outgoing - m/44'/501'/0/0/i/3 - // - // Relationship - m/44'/501'/0/0/0/0 - // Swap - m/44'/501'/0/0/1/0 * - // etc. - m/44'/501'/0/0/2/0 * - // - // Bucket1 - m/44'/501'/0/0/0/1 - // Bucket10 - m/44'/501'/0/0/0/10 - // Bucket100 - m/44'/501'/0/0/0/100 - // Bucket1k - m/44'/501'/0/0/0/1000 - // Bucket10k - m/44'/501'/0/0/0/10000 - // Bucket100k - m/44'/501'/0/0/0/100000 - // Bucket1m - m/44'/501'/0/0/0/1000000 - - val bucket1 = newInstance("m/44'/501'/0'/0'/0'/1")!! - val bucket10 = newInstance("m/44'/501'/0'/0'/0'/10")!! - val bucket100 = newInstance("m/44'/501'/0'/0'/0'/100")!! - val bucket1k = newInstance("m/44'/501'/0'/0'/0'/1000")!! - val bucket10k = newInstance("m/44'/501'/0'/0'/0'/10000")!! - val bucket100k = newInstance("m/44'/501'/0'/0'/0'/100000")!! - val bucket1m = newInstance("m/44'/501'/0'/0'/0'/1000000")!! - val primary = newInstance("m/44'/501'/0'/0'")!! - val swap = newInstance("m/44'/501'/0'/0'/1'/0")!! - - fun getBucketIncoming(index: Int): DerivePath { - return newInstance("m/44'/501'/0'/0'/$index'/2")!! - } - - fun getBucketOutgoing(index: Int): DerivePath { - return newInstance("m/44'/501'/0'/0'/$index'/3")!! - } - - fun getPool(index: Long): DerivePath { - return newInstance("m/44'/501'/0'/0'/7665'/$index'")!! - } - - fun getPoolRendezvous(index: Long): DerivePath { - return newInstance("m/44'/501'/0'/0'/2335'/$index'")!! - } - - fun relationship(domain: Domain): DerivePath { - return newInstance("m/44'/501'/0'/0'/0'/0", password = domain.relationshipHost)!! - } - - private const val identifier = "m" - private const val separator = "/" - private const val hardener = "'" - } -} \ No newline at end of file diff --git a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/model/Domain.kt b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/model/Domain.kt deleted file mode 100644 index 71a6752f5d..0000000000 --- a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/model/Domain.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.getcode.model - -import android.net.Uri -import androidx.core.net.toUri - -class Domain private constructor( - val relationshipHost: String, - val urlString: String -) { companion object { - fun from(uri: Uri, supportSubdomains: Boolean = false): Domain? { - val url = if (uri.scheme == null) - Uri.Builder() - .scheme("https") - .authority(uri.path) - .build() - else uri - - val hostName = url.host - val baseHost = baseDomain(hostName, supportSubdomains) - - if (!(!hostName.isNullOrEmpty() && !baseHost.isNullOrEmpty())) { - return null - } - - return Domain(baseHost, uri.toString()) - } - - fun from(url: String?, supportSubdomains: Boolean = false): Domain? { - url ?: return null - return from(url.toUri(), supportSubdomains) - } - - } -} - -private fun baseDomain(hostname: String?, supportSubdomains: Boolean): String? { - val components = hostname?.split(".")?.takeIf { it.count() > 1 } ?: return null - // 1 2 3 - // app.getcode.com - // - // 1 2 - // getcode.com - // - val componentCount = if (supportSubdomains) 3 else 2 - return components.takeLast(componentCount).joinToString(".") -} \ No newline at end of file diff --git a/libs/encryption/utils/build.gradle.kts b/libs/encryption/utils/build.gradle.kts index 510a30949e..2ca7e039c9 100644 --- a/libs/encryption/utils/build.gradle.kts +++ b/libs/encryption/utils/build.gradle.kts @@ -1,17 +1,44 @@ plugins { - alias(libs.plugins.flipcash.android.library) + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") } -android { - namespace = "${Gradle.codeNamespace}.encryption.utils" -} +kotlin { + android { + namespace = "com.getcode.encryption.utils" + compileSdk = 37 + minSdk = 29 + withHostTest {} + } -dependencies { - implementation(project(":libs:encryption:base58")) - implementation(libs.protobuf.kotlin.lite) - implementation(project(":libs:encryption:ed25519")) - implementation(libs.bundles.kotlinx.serialization) + iosArm64() + iosSimulatorArm64() + iosX64() - testImplementation(kotlin("test")) - testImplementation(libs.robolectric) + sourceSets { + commonMain { + dependencies { + implementation(project(":libs:encryption:base58")) + } + } + androidMain { + dependencies { + implementation(project(":libs:encryption:ed25519")) + implementation(project(":libs:logging")) + implementation(libs.protobuf.kotlin.lite) + implementation(libs.bundles.kotlinx.serialization) + } + } + commonTest { + dependencies { + implementation(kotlin("test")) + } + } + getByName("androidHostTest") { + dependencies { + implementation(kotlin("test")) + implementation(libs.robolectric) + } + } + } } diff --git a/libs/encryption/utils/src/test/kotlin/com/getcode/utils/Base64ExtensionsTest.kt b/libs/encryption/utils/src/androidHostTest/kotlin/com/getcode/utils/Base64ExtensionsTest.kt similarity index 100% rename from libs/encryption/utils/src/test/kotlin/com/getcode/utils/Base64ExtensionsTest.kt rename to libs/encryption/utils/src/androidHostTest/kotlin/com/getcode/utils/Base64ExtensionsTest.kt diff --git a/libs/encryption/utils/src/androidHostTest/kotlin/com/getcode/utils/UrlExtensionsTest.kt b/libs/encryption/utils/src/androidHostTest/kotlin/com/getcode/utils/UrlExtensionsTest.kt new file mode 100644 index 0000000000..9ee80aee26 --- /dev/null +++ b/libs/encryption/utils/src/androidHostTest/kotlin/com/getcode/utils/UrlExtensionsTest.kt @@ -0,0 +1,18 @@ +package com.getcode.utils + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UrlExtensionsTest { + + @Test + fun urlEncodeDecodeRoundtrip() { + val original = "hello world & foo=bar" + assertEquals(original, original.urlEncode().urlDecode()) + } + + @Test + fun urlEncodeSpaces() { + assertEquals("hello+world", "hello world".urlEncode()) + } +} diff --git a/libs/encryption/utils/src/test/kotlin/com/getcode/utils/UtilsTest.kt b/libs/encryption/utils/src/androidHostTest/kotlin/com/getcode/utils/UtilsTest.kt similarity index 100% rename from libs/encryption/utils/src/test/kotlin/com/getcode/utils/UtilsTest.kt rename to libs/encryption/utils/src/androidHostTest/kotlin/com/getcode/utils/UtilsTest.kt diff --git a/libs/encryption/utils/src/androidMain/kotlin/com/getcode/utils/Extensions.android.kt b/libs/encryption/utils/src/androidMain/kotlin/com/getcode/utils/Extensions.android.kt new file mode 100644 index 0000000000..6bbbd55e96 --- /dev/null +++ b/libs/encryption/utils/src/androidMain/kotlin/com/getcode/utils/Extensions.android.kt @@ -0,0 +1,48 @@ +package com.getcode.utils + +import android.util.Base64 +import com.getcode.ed25519.Ed25519 +import com.getcode.vendor.Base58 +import com.google.protobuf.ByteString +import java.net.URLDecoder +import java.net.URLEncoder +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +fun List.toByteString(): ByteString = ByteString.copyFrom(this.toByteArray()) +fun ByteArray.toByteString(): ByteString = ByteString.copyFrom(this) + +val List.base64: String + get() = Base64.encodeToString(toByteArray(), Base64.NO_WRAP) + +fun String.decodeBase64(): ByteArray = Base64.decode(this, Base64.NO_WRAP) + +fun String.decodeBase64UrlSafe(): ByteArray = Base64.decode(this, Base64.NO_WRAP or Base64.URL_SAFE) + +fun ByteArray.decodeBase64(): ByteArray = Base64.decode(this, Base64.NO_WRAP) + +val ByteArray.base64: String + get() = Base64.encodeToString(this, Base64.NO_WRAP) + +fun ByteArray.encodeBase64(urlSafe: Boolean = false): String { + val flags = if (urlSafe) Base64.NO_WRAP or Base64.URL_SAFE else Base64.NO_WRAP + return Base64.encodeToString(this, flags) +} + +fun ByteArray.encodeBase64ToArray(): ByteArray = Base64.encode(this, Base64.NO_WRAP) + +fun ByteArray.sha512(): ByteArray { + return try { + MessageDigest.getInstance("SHA-512") + .apply { update(this@sha512) } + .digest() + } catch (e: NoSuchAlgorithmException) { + throw RuntimeException("SHA-512 not implemented") + } +} + +fun String.urlEncode(): String = URLEncoder.encode(this, "UTF-8") + +fun String.urlDecode(): String = URLDecoder.decode(this, "UTF-8") + +fun Ed25519.KeyPair.getPublicKeyBase58(): String = Base58.encode(publicKeyBytes) diff --git a/libs/encryption/utils/src/androidMain/kotlin/com/getcode/utils/Utils.kt b/libs/encryption/utils/src/androidMain/kotlin/com/getcode/utils/Utils.kt new file mode 100644 index 0000000000..3d2153b949 --- /dev/null +++ b/libs/encryption/utils/src/androidMain/kotlin/com/getcode/utils/Utils.kt @@ -0,0 +1,443 @@ +package com.getcode.utils + +/* + * Copyright 2011 Google Inc. + * Copyright 2014 Andreas Schildbach + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.math.BigInteger +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** A collection of various utility methods that are helpful for working with the Bitcoin protocol. */ +object Utils { + const val TAG = "Api Utils" + + /** Hex encoder for use throughout the framework. */ + @JvmField + val HEX = HexEncoder() + + class HexEncoder { + private val hexChars = "0123456789abcdef".toCharArray() + + fun encode(data: ByteArray): String { + val result = CharArray(data.size * 2) + for (i in data.indices) { + result[i * 2] = hexChars[(data[i].toInt() shr 4) and 0xF] + result[i * 2 + 1] = hexChars[data[i].toInt() and 0xF] + } + return String(result) + } + } + + /** Joins an iterable of objects with a space separator. */ + @JvmStatic + fun spaceJoin(parts: Iterable<*>): String = parts.joinToString(" ") + + /** Max initial size of variable-length arrays to guard against memory exhaustion attacks. */ + const val MAX_INITIAL_ARRAY_LENGTH = 20 + + /** + * Encodes a positive BigInteger as a fixed-length big-endian byte array. + * This is the antagonist to [bytesToBigInteger]. + */ + @JvmStatic + fun bigIntegerToBytes(b: BigInteger, numBytes: Int): ByteArray { + require(b.signum() >= 0) { "b must be positive or zero" } + require(numBytes > 0) { "numBytes must be positive" } + val src = b.toByteArray() + val dest = ByteArray(numBytes) + val isFirstByteOnlyForSign = src[0] == 0.toByte() + val length = if (isFirstByteOnlyForSign) src.size - 1 else src.size + require(length <= numBytes) { "The given number does not fit in $numBytes" } + val srcPos = if (isFirstByteOnlyForSign) 1 else 0 + val destPos = numBytes - length + System.arraycopy(src, srcPos, dest, destPos, length) + return dest + } + + /** Converts a big-endian byte array to a positive BigInteger. Antagonist of [bigIntegerToBytes]. */ + @JvmStatic + fun bytesToBigInteger(bytes: ByteArray): BigInteger = BigInteger(1, bytes) + + /** Writes 2 bytes as an unsigned 16-bit integer in little-endian format. */ + @JvmStatic + fun uint16ToByteArrayLE(value: Int, out: ByteArray, offset: Int) { + out[offset] = (value and 0xFF).toByte() + out[offset + 1] = ((value shr 8) and 0xFF).toByte() + } + + /** Writes 4 bytes as an unsigned 32-bit integer in little-endian format. */ + @JvmStatic + fun uint32ToByteArrayLE(value: Long, out: ByteArray, offset: Int) { + out[offset] = (value and 0xFF).toByte() + out[offset + 1] = ((value shr 8) and 0xFF).toByte() + out[offset + 2] = ((value shr 16) and 0xFF).toByte() + out[offset + 3] = ((value shr 24) and 0xFF).toByte() + } + + /** Writes 4 bytes as an unsigned 32-bit integer in big-endian format. */ + @JvmStatic + fun uint32ToByteArrayBE(value: Long, out: ByteArray, offset: Int) { + out[offset] = ((value shr 24) and 0xFF).toByte() + out[offset + 1] = ((value shr 16) and 0xFF).toByte() + out[offset + 2] = ((value shr 8) and 0xFF).toByte() + out[offset + 3] = (value and 0xFF).toByte() + } + + /** Writes 8 bytes as a signed 64-bit integer in little-endian format. */ + @JvmStatic + fun int64ToByteArrayLE(value: Long, out: ByteArray, offset: Int) { + out[offset] = (value and 0xFF).toByte() + out[offset + 1] = ((value shr 8) and 0xFF).toByte() + out[offset + 2] = ((value shr 16) and 0xFF).toByte() + out[offset + 3] = ((value shr 24) and 0xFF).toByte() + out[offset + 4] = ((value shr 32) and 0xFF).toByte() + out[offset + 5] = ((value shr 40) and 0xFF).toByte() + out[offset + 6] = ((value shr 48) and 0xFF).toByte() + out[offset + 7] = ((value shr 56) and 0xFF).toByte() + } + + /** Writes 2 bytes as an unsigned 16-bit integer in little-endian format to a stream. */ + @JvmStatic + @Throws(IOException::class) + fun uint16ToByteStreamLE(value: Int, stream: OutputStream) { + stream.write(value and 0xFF) + stream.write((value shr 8) and 0xFF) + } + + /** Writes 2 bytes as an unsigned 16-bit integer in big-endian format to a stream. */ + @JvmStatic + @Throws(IOException::class) + fun uint16ToByteStreamBE(value: Int, stream: OutputStream) { + stream.write((value shr 8) and 0xFF) + stream.write(value and 0xFF) + } + + /** Writes 4 bytes as an unsigned 32-bit integer in little-endian format to a stream. */ + @JvmStatic + @Throws(IOException::class) + fun uint32ToByteStreamLE(value: Long, stream: OutputStream) { + stream.write((value and 0xFF).toInt()) + stream.write(((value shr 8) and 0xFF).toInt()) + stream.write(((value shr 16) and 0xFF).toInt()) + stream.write(((value shr 24) and 0xFF).toInt()) + } + + /** Writes 4 bytes as an unsigned 32-bit integer in big-endian format to a stream. */ + @JvmStatic + @Throws(IOException::class) + fun uint32ToByteStreamBE(value: Long, stream: OutputStream) { + stream.write(((value shr 24) and 0xFF).toInt()) + stream.write(((value shr 16) and 0xFF).toInt()) + stream.write(((value shr 8) and 0xFF).toInt()) + stream.write((value and 0xFF).toInt()) + } + + /** Writes 8 bytes as a signed 64-bit integer in little-endian format to a stream. */ + @JvmStatic + @Throws(IOException::class) + fun int64ToByteStreamLE(value: Long, stream: OutputStream) { + stream.write((value and 0xFF).toInt()) + stream.write(((value shr 8) and 0xFF).toInt()) + stream.write(((value shr 16) and 0xFF).toInt()) + stream.write(((value shr 24) and 0xFF).toInt()) + stream.write(((value shr 32) and 0xFF).toInt()) + stream.write(((value shr 40) and 0xFF).toInt()) + stream.write(((value shr 48) and 0xFF).toInt()) + stream.write(((value shr 56) and 0xFF).toInt()) + } + + /** Writes 8 bytes as an unsigned 64-bit integer in little-endian format to a stream. */ + @JvmStatic + @Throws(IOException::class) + fun uint64ToByteStreamLE(value: BigInteger, stream: OutputStream) { + var bytes = value.toByteArray() + require(bytes.size <= 8) { "Input too large to encode into a uint64" } + bytes = reverseBytes(bytes) + stream.write(bytes) + repeat(8 - bytes.size) { stream.write(0) } + } + + /** Parses 2 bytes as an unsigned 16-bit integer in little-endian format. */ + @JvmStatic + fun readUint16(bytes: ByteArray, offset: Int): Int = + (bytes[offset].toInt() and 0xFF) or ((bytes[offset + 1].toInt() and 0xFF) shl 8) + + /** Parses 4 bytes as an unsigned 32-bit integer in little-endian format. */ + @JvmStatic + fun readUint32(bytes: ByteArray, offset: Int): Long = + (bytes[offset].toLong() and 0xFFL) or + ((bytes[offset + 1].toLong() and 0xFFL) shl 8) or + ((bytes[offset + 2].toLong() and 0xFFL) shl 16) or + ((bytes[offset + 3].toLong() and 0xFFL) shl 24) + + /** Parses 8 bytes as a signed 64-bit integer in little-endian format. */ + @JvmStatic + fun readInt64(bytes: ByteArray, offset: Int): Long = + (bytes[offset].toLong() and 0xFFL) or + ((bytes[offset + 1].toLong() and 0xFFL) shl 8) or + ((bytes[offset + 2].toLong() and 0xFFL) shl 16) or + ((bytes[offset + 3].toLong() and 0xFFL) shl 24) or + ((bytes[offset + 4].toLong() and 0xFFL) shl 32) or + ((bytes[offset + 5].toLong() and 0xFFL) shl 40) or + ((bytes[offset + 6].toLong() and 0xFFL) shl 48) or + ((bytes[offset + 7].toLong() and 0xFFL) shl 56) + + /** Parses 4 bytes as an unsigned 32-bit integer in big-endian format. */ + @JvmStatic + fun readUint32BE(bytes: ByteArray, offset: Int): Long = + ((bytes[offset].toLong() and 0xFFL) shl 24) or + ((bytes[offset + 1].toLong() and 0xFFL) shl 16) or + ((bytes[offset + 2].toLong() and 0xFFL) shl 8) or + (bytes[offset + 3].toLong() and 0xFFL) + + /** Parses 2 bytes as an unsigned 16-bit integer in big-endian format. */ + @JvmStatic + fun readUint16BE(bytes: ByteArray, offset: Int): Int = + ((bytes[offset].toInt() and 0xFF) shl 8) or (bytes[offset + 1].toInt() and 0xFF) + + /** Parses 2 bytes from a stream as an unsigned 16-bit integer in little-endian format. */ + @JvmStatic + fun readUint16FromStream(stream: InputStream): Int = + try { + (stream.read() and 0xFF) or ((stream.read() and 0xFF) shl 8) + } catch (e: IOException) { + throw RuntimeException(e) + } + + /** Parses 4 bytes from a stream as an unsigned 32-bit integer in little-endian format. */ + @JvmStatic + fun readUint32FromStream(stream: InputStream): Long = + try { + (stream.read().toLong() and 0xFFL) or + ((stream.read().toLong() and 0xFFL) shl 8) or + ((stream.read().toLong() and 0xFFL) shl 16) or + ((stream.read().toLong() and 0xFFL) shl 24) + } catch (e: IOException) { + throw RuntimeException(e) + } + + /** Returns a copy of the given byte array in reverse order. */ + @JvmStatic + fun reverseBytes(bytes: ByteArray): ByteArray { + val buf = ByteArray(bytes.size) + for (i in bytes.indices) buf[i] = bytes[bytes.size - 1 - i] + return buf + } + + /** + * Decodes an MPI-encoded number. MPI encoded numbers consist of a 4-byte big-endian length field + * followed by the number in big-endian format with a sign bit. + * @param hasLength if false, the 4-byte length prefix is absent + */ + @JvmStatic + fun decodeMPI(mpi: ByteArray, hasLength: Boolean): BigInteger { + val buf: ByteArray = if (hasLength) { + val length = readUint32BE(mpi, 0).toInt() + mpi.copyOfRange(4, 4 + length) + } else { + mpi + } + if (buf.isEmpty()) return BigInteger.ZERO + val isNegative = (buf[0].toInt() and 0x80) == 0x80 + if (isNegative) buf[0] = (buf[0].toInt() and 0x7F).toByte() + val result = BigInteger(buf) + return if (isNegative) result.negate() else result + } + + /** + * Encodes a BigInteger in MPI format. MPI encoded numbers consist of a 4-byte big-endian + * length field followed by the number in big-endian format with a sign bit. + * @param includeLength if true, prepends the 4-byte length field + */ + @JvmStatic + fun encodeMPI(value: BigInteger, includeLength: Boolean): ByteArray { + if (value == BigInteger.ZERO) { + return if (!includeLength) ByteArray(0) else byteArrayOf(0, 0, 0, 0) + } + val isNegative = value.signum() < 0 + var array = if (isNegative) value.negate().toByteArray() else value.toByteArray() + var length = array.size + if ((array[0].toInt() and 0x80) == 0x80) length++ + return if (includeLength) { + val result = ByteArray(length + 4) + System.arraycopy(array, 0, result, length - array.size + 3, array.size) + uint32ToByteArrayBE(length.toLong(), result, 0) + if (isNegative) result[4] = (result[4].toInt() or 0x80).toByte() + result + } else { + if (length != array.size) { + val result = ByteArray(length) + System.arraycopy(array, 0, result, 1, array.size) + array = result + } + if (isNegative) array[0] = (array[0].toInt() or 0x80).toByte() + array + } + } + + /** + * Decodes the Bitcoin "compact bits" difficulty encoding. + * @see encodeCompactBits + */ + @JvmStatic + fun decodeCompactBits(compact: Long): BigInteger { + val size = ((compact shr 24) and 0xFF).toInt() + val bytes = ByteArray(4 + size) + bytes[3] = size.toByte() + if (size >= 1) bytes[4] = ((compact shr 16) and 0xFF).toByte() + if (size >= 2) bytes[5] = ((compact shr 8) and 0xFF).toByte() + if (size >= 3) bytes[6] = (compact and 0xFF).toByte() + return decodeMPI(bytes, true) + } + + /** + * Encodes a BigInteger in Bitcoin's "compact bits" difficulty format. + * @see decodeCompactBits + */ + @JvmStatic + fun encodeCompactBits(value: BigInteger): Long { + var result: Long + var size = value.toByteArray().size + result = if (size <= 3) value.toLong() shl (8 * (3 - size)) + else value.shiftRight(8 * (size - 3)).toLong() + if ((result and 0x00800000L) != 0L) { + result = result shr 8 + size++ + } + result = result or (size.toLong() shl 24) + result = result or (if (value.signum() == -1) 0x00800000L else 0L) + return result + } + + /** If non-null, overrides the return value of [now]. */ + @Volatile + private var mockTime: Date? = null + + /** Advances (or rewinds) the mock clock by the given number of seconds. */ + @JvmStatic + fun rollMockClock(seconds: Int): Date = rollMockClockMillis(seconds * 1000L) + + /** Advances (or rewinds) the mock clock by the given number of milliseconds. */ + @JvmStatic + fun rollMockClockMillis(millis: Long): Date { + val current = mockTime ?: throw IllegalStateException("You need to use setMockClock() first.") + return Date(current.time + millis).also { mockTime = it } + } + + /** Sets the mock clock to the current time. */ + @JvmStatic + fun setMockClock() { + mockTime = Date() + } + + /** Sets the mock clock to the given time (in seconds since epoch). */ + @JvmStatic + fun setMockClock(mockClockSeconds: Long) { + mockTime = Date(mockClockSeconds * 1000) + } + + /** Clears the mock clock. */ + @JvmStatic + fun resetMocking() { + mockTime = null + } + + /** Returns the current time, or a mocked equivalent. */ + @JvmStatic + fun now(): Date = mockTime ?: Date() + + /** Returns the current time in milliseconds since the epoch, or a mocked equivalent. */ + @JvmStatic + fun currentTimeMillis(): Long = mockTime?.time ?: System.currentTimeMillis() + + /** Returns the current time in seconds since the epoch, or a mocked equivalent. */ + @JvmStatic + fun currentTimeSeconds(): Long = currentTimeMillis() / 1000 + + private val UTC = TimeZone.getTimeZone("UTC") + + /** Formats a date to an ISO 8601 string. */ + @JvmStatic + fun dateTimeFormat(dateTime: Date): String { + val iso8601 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) + iso8601.timeZone = UTC + return iso8601.format(dateTime) + } + + /** Formats a unix time in milliseconds to an ISO 8601 string. */ + @JvmStatic + fun dateTimeFormat(dateTime: Long): String { + val iso8601 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) + iso8601.timeZone = UTC + return iso8601.format(dateTime) + } + + private val bitMask = intArrayOf(0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80) + + /** Checks if the given bit is set in data using little-endian bit order. */ + @JvmStatic + fun checkBitLE(data: ByteArray, index: Int): Boolean = + (data[index ushr 3].toInt() and bitMask[7 and index]) != 0 + + /** Sets the given bit in data to 1 using little-endian bit order. */ + @JvmStatic + fun setBitLE(data: ByteArray, index: Int) { + data[index ushr 3] = (data[index ushr 3].toInt() or bitMask[7 and index]).toByte() + } + + private enum class Runtime { ANDROID, OPENJDK, ORACLE_JAVA } + private enum class OS { LINUX, WINDOWS, MAC_OS } + + private val runtime: Runtime? + private val os: OS? + + init { + val runtimeProp = (System.getProperty("java.runtime.name") ?: "").lowercase(Locale.US) + runtime = when { + runtimeProp.isEmpty() -> null + runtimeProp.contains("android") -> Runtime.ANDROID + runtimeProp.contains("openjdk") -> Runtime.OPENJDK + runtimeProp.contains("java(tm) se") -> Runtime.ORACLE_JAVA + else -> { trace("Unknown java.runtime.name '$runtimeProp'", tag = TAG); null } + } + val osProp = (System.getProperty("os.name") ?: "").lowercase(Locale.US) + os = when { + osProp.isEmpty() -> null + osProp.contains("linux") -> OS.LINUX + osProp.contains("win") -> OS.WINDOWS + osProp.contains("mac") -> OS.MAC_OS + else -> { trace("Unknown os.name '$osProp'", tag = TAG); null } + } + } + + @JvmStatic fun isAndroidRuntime(): Boolean = runtime == Runtime.ANDROID + @JvmStatic fun isOpenJDKRuntime(): Boolean = runtime == Runtime.OPENJDK + @JvmStatic fun isOracleJavaRuntime(): Boolean = runtime == Runtime.ORACLE_JAVA + @JvmStatic fun isLinux(): Boolean = os == OS.LINUX + @JvmStatic fun isWindows(): Boolean = os == OS.WINDOWS + @JvmStatic fun isMac(): Boolean = os == OS.MAC_OS + + /** Encodes a stack of byte arrays as a space-separated hex string surrounded by brackets. */ + @JvmStatic + fun toString(stack: List): String = + stack.joinToString(" ") { "[${HEX.encode(it)}]" } +} diff --git a/libs/encryption/utils/src/main/kotlin/com/getcode/utils/Extensions.kt b/libs/encryption/utils/src/commonMain/kotlin/com/getcode/utils/Extensions.kt similarity index 53% rename from libs/encryption/utils/src/main/kotlin/com/getcode/utils/Extensions.kt rename to libs/encryption/utils/src/commonMain/kotlin/com/getcode/utils/Extensions.kt index fd4ab24fd0..3b016d91f9 100644 --- a/libs/encryption/utils/src/main/kotlin/com/getcode/utils/Extensions.kt +++ b/libs/encryption/utils/src/commonMain/kotlin/com/getcode/utils/Extensions.kt @@ -1,71 +1,11 @@ package com.getcode.utils -import android.util.Base64 -import com.getcode.ed25519.Ed25519 import com.getcode.vendor.Base58 -import com.google.protobuf.ByteString -import java.net.URLDecoder -import java.net.URLEncoder -import java.security.MessageDigest -import java.security.NoSuchAlgorithmException - -fun List.toByteString(): ByteString = ByteString.copyFrom(this.toByteArray()) -fun ByteArray.toByteString(): ByteString = ByteString.copyFrom(this) - - -val List.base58: String - get() = Base58.encode(toByteArray()) - -val ByteArray.base58: String - get() = Base58.encode(this) - -val List.base64: String - get() = Base64.encodeToString(toByteArray(), Base64.NO_WRAP) - -fun String.decodeBase58(): ByteArray { - return Base58.decode(this) -} - -fun String.decodeBase64(): ByteArray { - return Base64.decode(this, Base64.NO_WRAP) -} - -fun String.decodeBase64UrlSafe(): ByteArray { - return Base64.decode(this, Base64.NO_WRAP or Base64.URL_SAFE) -} - -fun ByteArray.decodeBase64(): ByteArray { - return Base64.decode(this, Base64.NO_WRAP) -} - -val ByteArray.base64: String - get() = Base64.encodeToString(this, Base64.NO_WRAP) - -fun ByteArray.encodeBase64(urlSafe: Boolean = false): String { - val flags = if (urlSafe) Base64.NO_WRAP or Base64.URL_SAFE else Base64.NO_WRAP - return Base64.encodeToString(this, flags) -} - -fun ByteArray.encodeBase64ToArray(): ByteArray { - return Base64.encode(this, Base64.NO_WRAP) -} - -fun ByteArray.sha512(): ByteArray { - return try { - MessageDigest.getInstance("SHA-512") - .apply { update(this@sha512) } - .digest() - - } catch (e: NoSuchAlgorithmException) { - throw RuntimeException("SHA-512 not implemented") - } -} - -fun List.toByteList(): List { - return this.map { it.toByte() } -} +/// Converts a list of integers to a list of bytes by truncating each value to a single byte. +fun List.toByteList(): List = map { it.toByte() } +/// Encodes this integer as a 4-byte little-endian byte array. fun Int.intToByteArray(): ByteArray = byteArrayOf( this.toByte(), @@ -74,10 +14,11 @@ fun Int.intToByteArray(): ByteArray = (this ushr 24).toByte() ) +/// Returns this integer as a little-endian byte list. val Int.bytes: List get() = intToByteArray().toList() - +/// Encodes this long as an 8-byte little-endian byte array. fun Long.toByteArray(): ByteArray = byteArrayOf( this.toByte(), @@ -90,30 +31,14 @@ fun Long.toByteArray(): ByteArray = (this ushr 56).toByte() ) +/// Returns this long as a little-endian byte list. val Long.bytes: List get() = toByteArray().toList() -fun String.urlEncode(): String { - return URLEncoder.encode(this, "UTF-8") -} - -fun String.urlDecode(): String { - return URLDecoder.decode(this, "UTF-8") -} - -fun String.replaceParam(vararg value: String?): String { - var result = this - value.forEachIndexed { index, s -> - result = result.replaceParam(index, s) - } - return result -} - -fun String.replaceParam(index: Int = 0, value: String?): String { - val param = "%${index + 1}\$s" - return this.replace(param, value.orEmpty()) -} +/// Alias for [Long.toByteArray]; encodes this long as an 8-byte little-endian byte array. +fun Long.longToByteArray(): ByteArray = toByteArray() +/// Decodes a little-endian byte array into a long value. fun ByteArray.byteArrayToLong(): Long { var result = 0L for (i in (size - 1) downTo 0) { @@ -122,6 +47,7 @@ fun ByteArray.byteArrayToLong(): Long { return result } +/// Decodes a little-endian byte array into an int value. fun ByteArray.byteArrayToInt(): Int { var result = 0 for (i in (size - 1) downTo 0) { @@ -130,18 +56,31 @@ fun ByteArray.byteArrayToInt(): Int { return result } -fun Long.longToByteArray(): ByteArray = toByteArray() - +/// Returns a sub-array of [count] bytes starting at [start]. fun ByteArray.subByteArray(start: Int, count: Int): ByteArray = copyOfRange(start, start + count) +/// Shifts this byte left by [bitCount] bits, truncating to a byte. infix fun Byte.shl(bitCount: Int): Byte = (toInt() shl bitCount).toByte() -fun String.toUTF8Bytes(): ByteArray = toByteArray(Charsets.UTF_8) +/// Returns this string encoded as UTF-8 bytes. +fun String.toUTF8Bytes(): ByteArray = encodeToByteArray() + +/// Substitutes indexed positional params (`%1$s`, `%2$s`, …) in this string with the given values. +fun String.replaceParam(vararg value: String?): String { + var result = this + value.forEachIndexed { index, s -> + result = result.replaceParam(index, s) + } + return result +} -fun Ed25519.KeyPair.getPublicKeyBase58(): String { - return Base58.encode(publicKeyBytes) +/// Substitutes the positional param at [index] (`%{index+1}$s`) with [value]. +fun String.replaceParam(index: Int = 0, value: String?): String { + val param = "%${index + 1}\$s" + return this.replace(param, value.orEmpty()) } +/// Returns a lowercase hex string for these bytes, or uppercase if [HexEncodingOptions.Uppercase] is given. fun List.hexEncodedString(options: Set = emptySet()): String { val hexDigits = if (options.contains(HexEncodingOptions.Uppercase)) "0123456789ABCDEF" @@ -156,10 +95,21 @@ fun List.hexEncodedString(options: Set = emptySet()): chars[index++] = hexDigits[byte.toInt() and 0xF] } - return String(chars) + return chars.concatToString() } +/// Options for [hexEncodedString]. sealed interface HexEncodingOptions { - data object Uppercase: HexEncodingOptions + data object Uppercase : HexEncodingOptions } +/// Returns the Base58-encoded string for this byte list. +val List.base58: String + get() = Base58.encode(toByteArray()) + +/// Returns the Base58-encoded string for this byte array. +val ByteArray.base58: String + get() = Base58.encode(this) + +/// Decodes this Base58-encoded string into bytes. +fun String.decodeBase58(): ByteArray = Base58.decode(this) diff --git a/libs/encryption/utils/src/test/kotlin/com/getcode/utils/ExtensionsTest.kt b/libs/encryption/utils/src/commonTest/kotlin/com/getcode/utils/ExtensionsTest.kt similarity index 92% rename from libs/encryption/utils/src/test/kotlin/com/getcode/utils/ExtensionsTest.kt rename to libs/encryption/utils/src/commonTest/kotlin/com/getcode/utils/ExtensionsTest.kt index 77e187f71b..42e93a5185 100644 --- a/libs/encryption/utils/src/test/kotlin/com/getcode/utils/ExtensionsTest.kt +++ b/libs/encryption/utils/src/commonTest/kotlin/com/getcode/utils/ExtensionsTest.kt @@ -120,19 +120,6 @@ class ExtensionsTest { assertEquals(4.toByte(), b shl 2) } - // --- String.urlEncode / urlDecode --- - - @Test - fun urlEncodeDecodeRoundtrip() { - val original = "hello world & foo=bar" - assertEquals(original, original.urlEncode().urlDecode()) - } - - @Test - fun urlEncodeSpaces() { - assertEquals("hello+world", "hello world".urlEncode()) - } - // --- String.replaceParam --- @Test diff --git a/libs/encryption/utils/src/main/java/com/getcode/utils/Utils.java b/libs/encryption/utils/src/main/java/com/getcode/utils/Utils.java deleted file mode 100644 index 8cca0226ad..0000000000 --- a/libs/encryption/utils/src/main/java/com/getcode/utils/Utils.java +++ /dev/null @@ -1,555 +0,0 @@ -package com.getcode.utils; - -/* - * Copyright 2011 Google Inc. - * Copyright 2014 Andreas Schildbach - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.math.BigInteger; -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.TimeZone; -import java.util.regex.Pattern; - -import timber.log.Timber; - -/** - * A collection of various utility methods that are helpful for working with the Bitcoin protocol. - * To enable debug logging from the library, run with -Dbitcoinj.logging=true on your command line. - */ -public class Utils { - public static final String TAG = "Api Utils"; - - /** Hex encoder for use throughout the framework. */ - public static final HexEncoder HEX = new HexEncoder(); - - public static class HexEncoder { - private static final char[] HEX_CHARS = "0123456789abcdef".toCharArray(); - - public String encode(byte[] data) { - char[] result = new char[data.length * 2]; - for (int i = 0; i < data.length; i++) { - result[i * 2] = HEX_CHARS[(data[i] >> 4) & 0xF]; - result[i * 2 + 1] = HEX_CHARS[data[i] & 0xF]; - } - return new String(result); - } - } - - /** Joins strings with a space. */ - public static String spaceJoin(Iterable parts) { - StringBuilder sb = new StringBuilder(); - boolean first = true; - for (Object part : parts) { - if (!first) sb.append(' '); - sb.append(part); - first = false; - } - return sb.toString(); - } - - /** - * Max initial size of variable length arrays and ArrayLists that could be attacked. - * Avoids this attack: Attacker sends a msg indicating it will contain a huge number (eg 2 billion) elements (eg transaction inputs) and - * forces bitcoinj to try to allocate a huge piece of the memory resulting in OutOfMemoryError. - */ - public static final int MAX_INITIAL_ARRAY_LENGTH = 20; - - /** - *

- * The regular {@link BigInteger#toByteArray()} includes the sign bit of the number and - * might result in an extra byte addition. This method removes this extra byte. - *

- *

- * Assuming only positive numbers, it's possible to discriminate if an extra byte - * is added by checking if the first element of the array is 0 (0000_0000). - * Due to the minimal representation provided by BigInteger, it means that the bit sign - * is the least significant bit 0000_0000 . - * Otherwise the representation is not minimal. - * For example, if the sign bit is 0000_0000, then the representation is not minimal due to the rightmost zero. - *

- * This is the antagonist to {@link #bytesToBigInteger(byte[])}. - * @param b the integer to format into a byte array - * @param numBytes the desired size of the resulting byte array - * @return numBytes byte long array. - */ - public static byte[] bigIntegerToBytes(BigInteger b, int numBytes) { - if (b.signum() < 0) throw new IllegalArgumentException("b must be positive or zero"); - if (numBytes <= 0) throw new IllegalArgumentException("numBytes must be positive"); - byte[] src = b.toByteArray(); - byte[] dest = new byte[numBytes]; - boolean isFirstByteOnlyForSign = src[0] == 0; - int length = isFirstByteOnlyForSign ? src.length - 1 : src.length; - if (length > numBytes) throw new IllegalArgumentException("The given number does not fit in " + numBytes); - int srcPos = isFirstByteOnlyForSign ? 1 : 0; - int destPos = numBytes - length; - System.arraycopy(src, srcPos, dest, destPos, length); - return dest; - } - - /** - * Converts an array of bytes into a positive BigInteger. This is the antagonist to - * {@link #bigIntegerToBytes(BigInteger, int)}. - * - * @param bytes to convert into a BigInteger - * @return the converted BigInteger - */ - public static BigInteger bytesToBigInteger(byte[] bytes) { - return new BigInteger(1, bytes); - } - - /** Write 2 bytes to the byte array (starting at the offset) as unsigned 16-bit integer in little endian format. */ - public static void uint16ToByteArrayLE(int val, byte[] out, int offset) { - out[offset] = (byte) (0xFF & val); - out[offset + 1] = (byte) (0xFF & (val >> 8)); - } - - /** Write 4 bytes to the byte array (starting at the offset) as unsigned 32-bit integer in little endian format. */ - public static void uint32ToByteArrayLE(long val, byte[] out, int offset) { - out[offset] = (byte) (0xFF & val); - out[offset + 1] = (byte) (0xFF & (val >> 8)); - out[offset + 2] = (byte) (0xFF & (val >> 16)); - out[offset + 3] = (byte) (0xFF & (val >> 24)); - } - - /** Write 4 bytes to the byte array (starting at the offset) as unsigned 32-bit integer in big endian format. */ - public static void uint32ToByteArrayBE(long val, byte[] out, int offset) { - out[offset] = (byte) (0xFF & (val >> 24)); - out[offset + 1] = (byte) (0xFF & (val >> 16)); - out[offset + 2] = (byte) (0xFF & (val >> 8)); - out[offset + 3] = (byte) (0xFF & val); - } - - /** Write 8 bytes to the byte array (starting at the offset) as signed 64-bit integer in little endian format. */ - public static void int64ToByteArrayLE(long val, byte[] out, int offset) { - out[offset] = (byte) (0xFF & val); - out[offset + 1] = (byte) (0xFF & (val >> 8)); - out[offset + 2] = (byte) (0xFF & (val >> 16)); - out[offset + 3] = (byte) (0xFF & (val >> 24)); - out[offset + 4] = (byte) (0xFF & (val >> 32)); - out[offset + 5] = (byte) (0xFF & (val >> 40)); - out[offset + 6] = (byte) (0xFF & (val >> 48)); - out[offset + 7] = (byte) (0xFF & (val >> 56)); - } - - /** Write 2 bytes to the output stream as unsigned 16-bit integer in little endian format. */ - public static void uint16ToByteStreamLE(int val, OutputStream stream) throws IOException { - stream.write((int) (0xFF & val)); - stream.write((int) (0xFF & (val >> 8))); - } - - /** Write 2 bytes to the output stream as unsigned 16-bit integer in big endian format. */ - public static void uint16ToByteStreamBE(int val, OutputStream stream) throws IOException { - stream.write((int) (0xFF & (val >> 8))); - stream.write((int) (0xFF & val)); - } - - /** Write 4 bytes to the output stream as unsigned 32-bit integer in little endian format. */ - public static void uint32ToByteStreamLE(long val, OutputStream stream) throws IOException { - stream.write((int) (0xFF & val)); - stream.write((int) (0xFF & (val >> 8))); - stream.write((int) (0xFF & (val >> 16))); - stream.write((int) (0xFF & (val >> 24))); - } - - /** Write 4 bytes to the output stream as unsigned 32-bit integer in big endian format. */ - public static void uint32ToByteStreamBE(long val, OutputStream stream) throws IOException { - stream.write((int) (0xFF & (val >> 24))); - stream.write((int) (0xFF & (val >> 16))); - stream.write((int) (0xFF & (val >> 8))); - stream.write((int) (0xFF & val)); - } - - /** Write 8 bytes to the output stream as signed 64-bit integer in little endian format. */ - public static void int64ToByteStreamLE(long val, OutputStream stream) throws IOException { - stream.write((int) (0xFF & val)); - stream.write((int) (0xFF & (val >> 8))); - stream.write((int) (0xFF & (val >> 16))); - stream.write((int) (0xFF & (val >> 24))); - stream.write((int) (0xFF & (val >> 32))); - stream.write((int) (0xFF & (val >> 40))); - stream.write((int) (0xFF & (val >> 48))); - stream.write((int) (0xFF & (val >> 56))); - } - - /** Write 8 bytes to the output stream as unsigned 64-bit integer in little endian format. */ - public static void uint64ToByteStreamLE(BigInteger val, OutputStream stream) throws IOException { - byte[] bytes = val.toByteArray(); - if (bytes.length > 8) { - throw new RuntimeException("Input too large to encode into a uint64"); - } - bytes = reverseBytes(bytes); - stream.write(bytes); - if (bytes.length < 8) { - for (int i = 0; i < 8 - bytes.length; i++) - stream.write(0); - } - } - - /** Parse 2 bytes from the byte array (starting at the offset) as unsigned 16-bit integer in little endian format. */ - public static int readUint16(byte[] bytes, int offset) { - return (bytes[offset] & 0xff) | - ((bytes[offset + 1] & 0xff) << 8); - } - - /** Parse 4 bytes from the byte array (starting at the offset) as unsigned 32-bit integer in little endian format. */ - public static long readUint32(byte[] bytes, int offset) { - return (bytes[offset] & 0xffl) | - ((bytes[offset + 1] & 0xffl) << 8) | - ((bytes[offset + 2] & 0xffl) << 16) | - ((bytes[offset + 3] & 0xffl) << 24); - } - - /** Parse 8 bytes from the byte array (starting at the offset) as signed 64-bit integer in little endian format. */ - public static long readInt64(byte[] bytes, int offset) { - return (bytes[offset] & 0xffl) | - ((bytes[offset + 1] & 0xffl) << 8) | - ((bytes[offset + 2] & 0xffl) << 16) | - ((bytes[offset + 3] & 0xffl) << 24) | - ((bytes[offset + 4] & 0xffl) << 32) | - ((bytes[offset + 5] & 0xffl) << 40) | - ((bytes[offset + 6] & 0xffl) << 48) | - ((bytes[offset + 7] & 0xffl) << 56); - } - - /** Parse 4 bytes from the byte array (starting at the offset) as unsigned 32-bit integer in big endian format. */ - public static long readUint32BE(byte[] bytes, int offset) { - return ((bytes[offset] & 0xffl) << 24) | - ((bytes[offset + 1] & 0xffl) << 16) | - ((bytes[offset + 2] & 0xffl) << 8) | - (bytes[offset + 3] & 0xffl); - } - - /** Parse 2 bytes from the byte array (starting at the offset) as unsigned 16-bit integer in big endian format. */ - public static int readUint16BE(byte[] bytes, int offset) { - return ((bytes[offset] & 0xff) << 8) | - (bytes[offset + 1] & 0xff); - } - - /** Parse 2 bytes from the stream as unsigned 16-bit integer in little endian format. */ - public static int readUint16FromStream(InputStream is) { - try { - return (is.read() & 0xff) | - ((is.read() & 0xff) << 8); - } catch (IOException x) { - throw new RuntimeException(x); - } - } - - /** Parse 4 bytes from the stream as unsigned 32-bit integer in little endian format. */ - public static long readUint32FromStream(InputStream is) { - try { - return (is.read() & 0xffl) | - ((is.read() & 0xffl) << 8) | - ((is.read() & 0xffl) << 16) | - ((is.read() & 0xffl) << 24); - } catch (IOException x) { - throw new RuntimeException(x); - } - } - - /** - * Returns a copy of the given byte array in reverse order. - */ - public static byte[] reverseBytes(byte[] bytes) { - // We could use the XOR trick here but it's easier to understand if we don't. If we find this is really a - // performance issue the matter can be revisited. - byte[] buf = new byte[bytes.length]; - for (int i = 0; i < bytes.length; i++) - buf[i] = bytes[bytes.length - 1 - i]; - return buf; - } - - /** - * MPI encoded numbers are produced by the OpenSSL BN_bn2mpi function. They consist of - * a 4 byte big endian length field, followed by the stated number of bytes representing - * the number in big endian format (with a sign bit). - * @param hasLength can be set to false if the given array is missing the 4 byte length field - */ - public static BigInteger decodeMPI(byte[] mpi, boolean hasLength) { - byte[] buf; - if (hasLength) { - int length = (int) readUint32BE(mpi, 0); - buf = new byte[length]; - System.arraycopy(mpi, 4, buf, 0, length); - } else - buf = mpi; - if (buf.length == 0) - return BigInteger.ZERO; - boolean isNegative = (buf[0] & 0x80) == 0x80; - if (isNegative) - buf[0] &= 0x7f; - BigInteger result = new BigInteger(buf); - return isNegative ? result.negate() : result; - } - - /** - * MPI encoded numbers are produced by the OpenSSL BN_bn2mpi function. They consist of - * a 4 byte big endian length field, followed by the stated number of bytes representing - * the number in big endian format (with a sign bit). - * @param includeLength indicates whether the 4 byte length field should be included - */ - public static byte[] encodeMPI(BigInteger value, boolean includeLength) { - if (value.equals(BigInteger.ZERO)) { - if (!includeLength) - return new byte[] {}; - else - return new byte[] {0x00, 0x00, 0x00, 0x00}; - } - boolean isNegative = value.signum() < 0; - if (isNegative) - value = value.negate(); - byte[] array = value.toByteArray(); - int length = array.length; - if ((array[0] & 0x80) == 0x80) - length++; - if (includeLength) { - byte[] result = new byte[length + 4]; - System.arraycopy(array, 0, result, length - array.length + 3, array.length); - uint32ToByteArrayBE(length, result, 0); - if (isNegative) - result[4] |= 0x80; - return result; - } else { - byte[] result; - if (length != array.length) { - result = new byte[length]; - System.arraycopy(array, 0, result, 1, array.length); - }else - result = array; - if (isNegative) - result[0] |= 0x80; - return result; - } - } - - /** - *

The "compact" format is a representation of a whole number N using an unsigned 32 bit number similar to a - * floating point format. The most significant 8 bits are the unsigned exponent of base 256. This exponent can - * be thought of as "number of bytes of N". The lower 23 bits are the mantissa. Bit number 24 (0x800000) represents - * the sign of N. Therefore, N = (-1^sign) * mantissa * 256^(exponent-3).

- * - *

Satoshi's original implementation used BN_bn2mpi() and BN_mpi2bn(). MPI uses the most significant bit of the - * first byte as sign. Thus 0x1234560000 is compact 0x05123456 and 0xc0de000000 is compact 0x0600c0de. Compact - * 0x05c0de00 would be -0x40de000000.

- * - *

Bitcoin only uses this "compact" format for encoding difficulty targets, which are unsigned 256bit quantities. - * Thus, all the complexities of the sign bit and using base 256 are probably an implementation accident.

- */ - public static BigInteger decodeCompactBits(long compact) { - int size = ((int) (compact >> 24)) & 0xFF; - byte[] bytes = new byte[4 + size]; - bytes[3] = (byte) size; - if (size >= 1) bytes[4] = (byte) ((compact >> 16) & 0xFF); - if (size >= 2) bytes[5] = (byte) ((compact >> 8) & 0xFF); - if (size >= 3) bytes[6] = (byte) (compact & 0xFF); - return decodeMPI(bytes, true); - } - - /** - * @see Utils#decodeCompactBits(long) - */ - public static long encodeCompactBits(BigInteger value) { - long result; - int size = value.toByteArray().length; - if (size <= 3) - result = value.longValue() << 8 * (3 - size); - else - result = value.shiftRight(8 * (size - 3)).longValue(); - // The 0x00800000 bit denotes the sign. - // Thus, if it is already set, divide the mantissa by 256 and increase the exponent. - if ((result & 0x00800000L) != 0) { - result >>= 8; - size++; - } - result |= size << 24; - result |= value.signum() == -1 ? 0x00800000 : 0; - return result; - } - - /** - * If non-null, overrides the return value of now(). - */ - private static volatile Date mockTime; - - /** - * Advances (or rewinds) the mock clock by the given number of seconds. - */ - public static Date rollMockClock(int seconds) { - return rollMockClockMillis(seconds * 1000); - } - - /** - * Advances (or rewinds) the mock clock by the given number of milliseconds. - */ - public static Date rollMockClockMillis(long millis) { - if (mockTime == null) - throw new IllegalStateException("You need to use setMockClock() first."); - mockTime = new Date(mockTime.getTime() + millis); - return mockTime; - } - - /** - * Sets the mock clock to the current time. - */ - public static void setMockClock() { - mockTime = new Date(); - } - - /** - * Sets the mock clock to the given time (in seconds). - */ - public static void setMockClock(long mockClockSeconds) { - mockTime = new Date(mockClockSeconds * 1000); - } - - /** - * Clears the mock clock and sleep - */ - public static void resetMocking() { - mockTime = null; - } - - /** - * Returns the current time, or a mocked out equivalent. - */ - public static Date now() { - return mockTime != null ? mockTime : new Date(); - } - - /** - * Returns the current time in milliseconds since the epoch, or a mocked out equivalent. - */ - public static long currentTimeMillis() { - return mockTime != null ? mockTime.getTime() : System.currentTimeMillis(); - } - - /** - * Returns the current time in seconds since the epoch, or a mocked out equivalent. - */ - public static long currentTimeSeconds() { - return currentTimeMillis() / 1000; - } - - private static final TimeZone UTC = TimeZone.getTimeZone("UTC"); - - /** - * Formats a given date+time value to an ISO 8601 string. - * @param dateTime value to format, as a Date - */ - public static String dateTimeFormat(Date dateTime) { - DateFormat iso8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US); - iso8601.setTimeZone(UTC); - return iso8601.format(dateTime); - } - - /** - * Formats a given date+time value to an ISO 8601 string. - * @param dateTime value to format, unix time (ms) - */ - public static String dateTimeFormat(long dateTime) { - DateFormat iso8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US); - iso8601.setTimeZone(UTC); - return iso8601.format(dateTime); - } - - // 00000001, 00000010, 00000100, 00001000, ... - private static final int[] bitMask = {0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80}; - - /** Checks if the given bit is set in data, using little endian (not the same as Java native big endian) */ - public static boolean checkBitLE(byte[] data, int index) { - return (data[index >>> 3] & bitMask[7 & index]) != 0; - } - - /** Sets the given bit in data to one, using little endian (not the same as Java native big endian) */ - public static void setBitLE(byte[] data, int index) { - data[index >>> 3] |= bitMask[7 & index]; - } - - private enum Runtime { - ANDROID, OPENJDK, ORACLE_JAVA - } - - private enum OS { - LINUX, WINDOWS, MAC_OS - } - - private static Runtime runtime = null; - private static OS os = null; - static { - String runtimeProp = System.getProperty("java.runtime.name", "").toLowerCase(Locale.US); - if (runtimeProp.equals("")) - runtime = null; - else if (runtimeProp.contains("android")) - runtime = Runtime.ANDROID; - else if (runtimeProp.contains("openjdk")) - runtime = Runtime.OPENJDK; - else if (runtimeProp.contains("java(tm) se")) - runtime = Runtime.ORACLE_JAVA; - else - Timber.i("Unknown java.runtime.name '{}' " + runtimeProp); - - String osProp = System.getProperty("os.name", "").toLowerCase(Locale.US); - if (osProp.equals("")) - os = null; - else if (osProp.contains("linux")) - os = OS.LINUX; - else if (osProp.contains("win")) - os = OS.WINDOWS; - else if (osProp.contains("mac")) - os = OS.MAC_OS; - else - Timber.i("Unknown os.name '{}' %s", runtimeProp); - } - - public static boolean isAndroidRuntime() { - return runtime == Runtime.ANDROID; - } - - public static boolean isOpenJDKRuntime() { - return runtime == Runtime.OPENJDK; - } - - public static boolean isOracleJavaRuntime() { - return runtime == Runtime.ORACLE_JAVA; - } - - public static boolean isLinux() { - return os == OS.LINUX; - } - - public static boolean isWindows() { - return os == OS.WINDOWS; - } - - public static boolean isMac() { - return os == OS.MAC_OS; - } - - public static String toString(List stack) { - List parts = new ArrayList<>(stack.size()); - for (byte[] push : stack) - parts.add('[' + HEX.encode(push) + ']'); - return spaceJoin(parts); - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9759367a10..b7fb8cde97 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -139,6 +139,7 @@ include( ":libs:datetime", ":libs:emojis", ":libs:encryption:base58", + ":libs:encryption:derivepath", ":libs:encryption:ed25519", ":libs:encryption:hmac", ":libs:encryption:keys", From d938099333c9ec9304aaddb1a28bf2d2bbd972c9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 7 Aug 2026 14:56:52 -0400 Subject: [PATCH 2/2] fix(kmp): wire KMP modules into flipcashTestDebug via testAndroidHostTest KMP libraries (com.android.kotlin.multiplatform.library) expose testAndroidHostTest rather than testDebugUnitTest. The aggregate flipcashTestDebug task now has a third category for these modules. Also excludes :kmp:shared-core (umbrella only, no tests) from all test aggregation lists. Fixes the pre-existing breakage introduced with the base58 KMP extraction. --- build.gradle.kts | 6 +++++- settings.gradle.kts | 21 +++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 265a2cbf24..1d3f166c0d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -66,14 +66,18 @@ dependencies { // Isolated Projects forbids. The module lists come from `settings.gradle.kts`; the // task depends on each module's test task by *path* — a lazy, cross-project-safe // dependency that never configures the other project at configuration time. -// Android modules expose `testDebugUnitTest`; pure-JVM modules expose `test`. +// Android modules expose `testDebugUnitTest`; KMP modules expose `testAndroidHostTest`; +// pure-JVM modules expose `test`. @Suppress("UNCHECKED_CAST") val androidUnitTestModules = rootProject.extra["flipcash.androidUnitTestModules"] as List @Suppress("UNCHECKED_CAST") val jvmUnitTestModules = rootProject.extra["flipcash.jvmUnitTestModules"] as List +@Suppress("UNCHECKED_CAST") +val kmpUnitTestModules = rootProject.extra["flipcash.kmpUnitTestModules"] as List tasks.register("flipcashTestDebug") { description = "Run testDebug for all Flipcash modules" dependsOn(androidUnitTestModules.map { "$it:testDebugUnitTest" }) dependsOn(jvmUnitTestModules.map { "$it:test" }) + dependsOn(kmpUnitTestModules.map { "$it:testAndroidHostTest" }) } diff --git a/settings.gradle.kts b/settings.gradle.kts index b7fb8cde97..695831f70e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -242,15 +242,26 @@ val koverModules = includedProjectPaths.filter { path -> // Aggregate unit tests: :apps:flipcash, service modules, and :libs (which holds the host-JVM // cross-platform vector gate for base58 + any future pure-JVM lib tests). Android modules expose -// `testDebugUnitTest`; pure-JVM modules expose `test`; the androidTest `:benchmark` module has -// no unit-test task. +// `testDebugUnitTest`; KMP modules expose `testAndroidHostTest`; pure-JVM modules expose `test`; +// the androidTest `:benchmark` module has no unit-test task. val unitTestPaths = listOf(":apps:flipcash", ":services:flipcash", ":services:opencode", ":libs", ":kmp") val jvmUnitTestModules = setOf(":apps:flipcash:shared:ksp") -val noUnitTestModules = setOf(":apps:flipcash:benchmark") +// KMP modules use `testAndroidHostTest` (exposed by `com.android.kotlin.multiplatform.library` +// when `withHostTest {}` is configured) rather than `testDebugUnitTest` from `com.android.library`. +// Modules without `withHostTest {}` (e.g. :kmp:shared-core) have no host test task and are +// excluded from both lists. +val kmpUnitTestModules = setOf( + ":libs:encryption:base58", + ":libs:encryption:derivepath", + ":libs:encryption:utils", +) +// :apps:flipcash:benchmark is an instrumented-only module (no unit tests). +// :kmp:shared-core is a KMP umbrella with no tests of its own. +val noUnitTestModules = setOf(":apps:flipcash:benchmark", ":kmp:shared-core") val unitTestCandidates = includedProjectPaths.filter { path -> unitTestPaths.any { path == it || path.startsWith("$it:") } && path !in noUnitTestModules } -val androidUnitTestModules = unitTestCandidates.filter { it !in jvmUnitTestModules } +val androidUnitTestModules = unitTestCandidates.filter { it !in jvmUnitTestModules && it !in kmpUnitTestModules } // Forced dependency versions for the per-project configuration below. The // version catalog isn't registered on projects yet at `beforeProject` time, so @@ -278,6 +289,7 @@ run { val koverModulesForRoot = koverModules.toList() val androidUnitTestForRoot = androidUnitTestModules.toList() val jvmUnitTestForRoot = jvmUnitTestModules.toList() + val kmpUnitTestForRoot = kmpUnitTestModules.toList() val forcedDependencies = listOf( "org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion", "org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion", @@ -288,6 +300,7 @@ run { extra["flipcash.koverModules"] = koverModulesForRoot extra["flipcash.androidUnitTestModules"] = androidUnitTestForRoot extra["flipcash.jvmUnitTestModules"] = jvmUnitTestForRoot + extra["flipcash.kmpUnitTestModules"] = kmpUnitTestForRoot } configurations.configureEach {