diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000000..41a2975aaf --- /dev/null +++ b/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version:5.9 +import PackageDescription + +let packageName = "SharedCore" + +let package = Package( + name: packageName, + platforms: [ + .iOS(.v15) + ], + products: [ + .library( + name: packageName, + targets: [packageName] + ), + ], + targets: [ + .binaryTarget( + name: packageName, + path: "./kmp/shared-core/build/XCFrameworks/debug/\(packageName).xcframework" + ) + , + ] +) \ No newline at end of file diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index 97ed765066..f60e1117e5 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -175,6 +175,9 @@ androidComponents { dependencies { implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) + // KMP shared core — consumed transitively via :libs:encryption:base58 but declared + // explicitly so the app's own code can reference com.flipcash.shared.* directly. + implementation(project(":kmp:shared-core")) implementation(project(":services:flipcash-compose")) implementation(project(":apps:flipcash:core")) diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index f1ef2a7b1d..4020568ef1 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -41,5 +41,9 @@ gradlePlugin { id = "flipcash.android.ed25519.shadow" implementationClass = "AndroidEd25519ShadowConventionPlugin" } + register("kmpLibrary") { + id = "flipcash.kmp.library" + implementationClass = "KmpLibraryConventionPlugin" + } } } diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt new file mode 100644 index 0000000000..4d394bce85 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt @@ -0,0 +1,30 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project + +/** + * Convention plugin for KMP library modules. + * + * Applies: + * - `org.jetbrains.kotlin.multiplatform` + * - `com.android.kotlin.multiplatform.library` + * + * After applying this plugin the `kotlin {}` DSL block is available for configuring targets, + * source sets, and dependencies. + * + * Usage in a module's `build.gradle.kts`: + * ``` + * plugins { + * alias(libs.plugins.flipcash.kmp.library) + * } + * ``` + */ +class KmpLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("org.jetbrains.kotlin.multiplatform") + apply("com.android.kotlin.multiplatform.library") + } + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index f85139aee9..265a2cbf24 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,6 +32,10 @@ plugins { alias(libs.plugins.androidx.room) apply false alias(libs.plugins.screenshot) apply false alias(libs.plugins.kover) + // KMP modules: shared-core umbrella + libs/* pilots + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.android.kotlin.multiplatform.library) apply false + alias(libs.plugins.kmmbridge) apply false } // NOTE: The per-project `allprojects { }` configuration that used to live here diff --git a/gradle.properties b/gradle.properties index 42df4d3ba9..5b07082ba9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -48,3 +48,5 @@ android.enableR8.fullMode=true # Enabled parallel sync for Gradle 9.4+ org.gradle.tooling.parallel=true + +kotlin.native.ignoreDisabledTargets=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 272e982939..d427075970 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,13 +5,14 @@ android-targetSdk = "37" android-java = "21" kotlin = "2.4.10" -ksp = "2.3.11" +ksp = "2.3.10" kotlinx-coroutines = "1.11.0" kotlinx-serialization = "1.11.0" kotlinx-datetime = "0.8.0" kover = "0.9.9" agp = "9.3.1" +kmmbridge = "1.2.1" google-services = "4.5.0" androidx-appcompat = "1.7.1" @@ -48,26 +49,26 @@ hilt-jetpack = "1.4.0" okhttp = "5.4.0" retrofit = "3.0.0" kin-sdk = "2.1.2" -grpc-android = "1.83.1" +grpc-android = "1.83.0" slf4j = "1.7.36" -firebase-bom = "34.17.0" +firebase-bom = "34.16.0" play-service-ml-barcode = "18.3.1" play-services-auth-api-phone = "18.3.1" google-play-billing = "9.1.0" google-play-updates = "2.1.0" -grpc = "1.83.1" -grpc-okhttp = "1.83.1" +grpc = "1.83.0" +grpc-okhttp = "1.83.0" grpc-kotlin = "1.5.0" protobuf = "4.35.1" protobuf-plugin = "0.10.0" protovalidate-kt = "0.1.1" -lib-phone-number-port = "9.0.36" -lib-phone-number-google = "9.0.36" +lib-phone-number-port = "9.0.34" +lib-phone-number-google = "9.0.35" zxing = "3.5.4" -androidx-benchmark-macro = "1.5.0-beta01" +androidx-benchmark-macro = "1.5.0-alpha07" androidx-profileinstaller = "1.4.1" androidx-test-runner = "1.7.0" junit = "4.13.2" @@ -325,6 +326,7 @@ flipcash-android-library = { id = "flipcash.android.library" } flipcash-android-library-compose = { id = "flipcash.android.library.compose" } flipcash-android-feature = { id = "flipcash.android.feature" } flipcash-android-ed25519-shadow = { id = "flipcash.android.ed25519.shadow" } +flipcash-kmp-library = { id = "flipcash.kmp.library" } android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } @@ -344,3 +346,6 @@ androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "and screenshot = { id = "com.android.compose.screenshot", version.ref = "screenshot" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } protobuf-validate = { id = "dev.bmcreations.protovalidate", version.ref = "protovalidate-kt" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +kmmbridge = { id = "co.touchlab.kmmbridge.github", version.ref = "kmmbridge" } diff --git a/kmp/shared-core/build.gradle.kts b/kmp/shared-core/build.gradle.kts new file mode 100644 index 0000000000..ccd6d09386 --- /dev/null +++ b/kmp/shared-core/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") + id("co.touchlab.kmmbridge.github") version "1.2.1" + `maven-publish` +} + +group = "com.flipcash" +version = "0.1.0" + +kotlin { + android { + namespace = "com.flipcash.shared" + compileSdk = 37 + minSdk = 29 + } + + val appleTargets = listOf(iosArm64(), iosSimulatorArm64(), iosX64()) + appleTargets.forEach { + it.binaries.framework { + baseName = "SharedCore" + isStatic = true + export(project(":libs:encryption:base58")) + } + } + + sourceSets { + commonMain { + dependencies { + api(project(":libs:encryption:base58")) + } + } + } +} + +kmmbridge { + gitHubReleaseArtifacts() + spm(swiftToolVersion = "5.9") { + iOS { v("15") } + } +} diff --git a/kmp/shared-core/src/commonMain/kotlin/com/flipcash/shared/SharedCore.kt b/kmp/shared-core/src/commonMain/kotlin/com/flipcash/shared/SharedCore.kt new file mode 100644 index 0000000000..6004434156 --- /dev/null +++ b/kmp/shared-core/src/commonMain/kotlin/com/flipcash/shared/SharedCore.kt @@ -0,0 +1,10 @@ +package com.flipcash.shared + +/** + * Marker for the cross-platform shared core. Gives iOS a symbol to verify the + * XCFramework is linked (`SharedCore.version`) and a home for the shared surface + * as it grows across milestones. + */ +object SharedCore { + const val version: String = "0.1.0" +} diff --git a/libs/encryption/base58/build.gradle.kts b/libs/encryption/base58/build.gradle.kts index 5fcf912f67..7ecf2a4f19 100644 --- a/libs/encryption/base58/build.gradle.kts +++ b/libs/encryption/base58/build.gradle.kts @@ -1,12 +1,32 @@ plugins { - alias(libs.plugins.flipcash.android.library) + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") } -android { - namespace = "${Gradle.codeNamespace}.encryption.base58" -} +kotlin { + android { + namespace = "com.getcode.encryption.base58" + compileSdk = 37 + minSdk = 29 + withHostTest {} + } + + iosArm64() + iosSimulatorArm64() + iosX64() -dependencies { - testImplementation(kotlin("test")) - testImplementation(libs.kotlinx.serialization.json) // parse cross-platform test-vector fixtures + sourceSets { + commonMain { + // Pure Kotlin -- no external dependencies needed. + } + androidMain { + // MessageDigest + BigInteger -- JDK only; no extra Gradle deps. + } + commonTest { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.serialization.json) + } + } + } } diff --git a/libs/encryption/base58/src/androidHostTest/kotlin/com/getcode/vendor/TestResources.android.kt b/libs/encryption/base58/src/androidHostTest/kotlin/com/getcode/vendor/TestResources.android.kt new file mode 100644 index 0000000000..5365a32baf --- /dev/null +++ b/libs/encryption/base58/src/androidHostTest/kotlin/com/getcode/vendor/TestResources.android.kt @@ -0,0 +1,6 @@ +package com.getcode.vendor + +actual fun readTestResource(name: String): String = + checkNotNull(Thread.currentThread().contextClassLoader?.getResourceAsStream(name)) { + "Resource '$name' not found on classpath" + }.bufferedReader().use { it.readText() } diff --git a/libs/encryption/base58/src/androidMain/kotlin/com/getcode/vendor/Base58Jvm.kt b/libs/encryption/base58/src/androidMain/kotlin/com/getcode/vendor/Base58Jvm.kt new file mode 100644 index 0000000000..046ba8ee3f --- /dev/null +++ b/libs/encryption/base58/src/androidMain/kotlin/com/getcode/vendor/Base58Jvm.kt @@ -0,0 +1,54 @@ +package com.getcode.vendor + +import java.math.BigInteger +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +/** + * JVM-only helpers for Base58. These use [MessageDigest] and [BigInteger] which are not + * available on Kotlin/Native, so they live in `androidMain` rather than `commonMain`. + * + * All are extension functions / top-level functions on `Base58` so existing callers that + * call `Base58.encodeChecked(...)` etc. keep compiling unchanged. + */ + +/** Encodes version + payload with a 4-byte SHA256d checksum appended. */ +fun Base58.encodeChecked(version: Int, payload: ByteArray): String { + require(version in 0..255) { "Version not in range." } + val addressBytes = ByteArray(1 + payload.size + 4) + addressBytes[0] = version.toByte() + payload.copyInto(addressBytes, 1) + val checksum = hashTwice(addressBytes, 0, payload.size + 1) + checksum.copyInto(addressBytes, payload.size + 1, 0, 4) + return encode(addressBytes) +} + +/** Decodes a base58-encoded string and verifies the 4-byte checksum. */ +@Throws(Base58.AddressFormatException::class) +fun Base58.decodeChecked(input: String): ByteArray { + val decoded = decode(input) + if (decoded.size < 4) throw Base58.AddressFormatException.InvalidDataLength("Input too short: ${decoded.size}") + val data = decoded.copyOfRange(0, decoded.size - 4) + val checksum = decoded.copyOfRange(decoded.size - 4, decoded.size) + val actualChecksum = hashTwice(data).copyOfRange(0, 4) + if (!checksum.contentEquals(actualChecksum)) throw Base58.AddressFormatException.InvalidChecksum() + return data +} + +/** Decodes a base58 string to a [BigInteger]. */ +@Throws(Base58.AddressFormatException::class) +fun Base58.decodeToBigInteger(input: String): BigInteger = BigInteger(1, decode(input)) + +/** + * Computes SHA-256(SHA-256(input[offset.. + ALPHABET.forEachIndexed { i, c -> arr[c.code] = i } + } + + sealed class AddressFormatException(message: String?) : IllegalArgumentException(message) { + class InvalidCharacter(val character: Char, val position: Int) : + AddressFormatException("Invalid character '${character}' at position $position") + class InvalidDataLength(message: String?) : AddressFormatException(message) + class InvalidChecksum : AddressFormatException("Checksum does not validate") + } + + /** + * Encodes the given bytes as a base58 string (no checksum appended). + * + * @param input the bytes to encode + * @return the base58-encoded string + */ + fun encode(input: ByteArray): String { + if (input.isEmpty()) return "" + + // Working copy so we can modify in-place during divmod. + val data = input.copyOf() + + // Count leading zeros. + var zeros = 0 + while (zeros < data.size && data[zeros] == 0.toByte()) zeros++ + + val encoded = CharArray(data.size * 2) + var outputStart = encoded.size + var inputStart = zeros + while (inputStart < data.size) { + encoded[--outputStart] = ALPHABET[divmod(data, inputStart, 256, 58).toInt()] + if (data[inputStart] == 0.toByte()) inputStart++ + } + + // Strip leading ENCODED_ZERO chars that are spurious, then re-add exactly `zeros` of them. + while (outputStart < encoded.size && encoded[outputStart] == ENCODED_ZERO) outputStart++ + repeat(zeros) { encoded[--outputStart] = ENCODED_ZERO } + + return encoded.concatToString(outputStart, encoded.size) + } + + /** + * Decodes the given base58 string into the original data bytes. + * + * @param input the base58-encoded string to decode + * @return the decoded data bytes + * @throws AddressFormatException.InvalidCharacter if a character is not in the alphabet + */ + @Throws(AddressFormatException::class) + fun decode(input: String): ByteArray { + if (input.isEmpty()) return ByteArray(0) + + val input58 = ByteArray(input.length) + for (i in input.indices) { + val c = input[i] + val digit = if (c.code < 128) INDEXES[c.code] else -1 + if (digit < 0) throw AddressFormatException.InvalidCharacter(c, i) + input58[i] = digit.toByte() + } + + var zeros = 0 + while (zeros < input58.size && input58[zeros] == 0.toByte()) zeros++ + + val decoded = ByteArray(input.length) + var outputStart = decoded.size + var inputStart = zeros + while (inputStart < input58.size) { + decoded[--outputStart] = divmod(input58, inputStart, 58, 256) + if (input58[inputStart] == 0.toByte()) inputStart++ + } + + while (outputStart < decoded.size && decoded[outputStart] == 0.toByte()) outputStart++ + + return decoded.copyOfRange(outputStart - zeros, decoded.size) + } + + /** + * Long division on a byte array representing a big-endian number in the given [base]. + * Modifies [number] in place. Returns the remainder byte. + */ + private fun divmod(number: ByteArray, firstDigit: Int, base: Int, divisor: Int): Byte { + var remainder = 0 + for (i in firstDigit until number.size) { + val digit = number[i].toInt() and 0xFF + val temp = remainder * base + digit + number[i] = (temp / divisor).toByte() + remainder = temp % divisor + } + return remainder.toByte() + } +} diff --git a/libs/encryption/base58/src/test/kotlin/com/getcode/vendor/Base58VectorTest.kt b/libs/encryption/base58/src/commonTest/kotlin/com/getcode/vendor/Base58VectorTest.kt similarity index 50% rename from libs/encryption/base58/src/test/kotlin/com/getcode/vendor/Base58VectorTest.kt rename to libs/encryption/base58/src/commonTest/kotlin/com/getcode/vendor/Base58VectorTest.kt index 78b60d880e..f9f8121902 100644 --- a/libs/encryption/base58/src/test/kotlin/com/getcode/vendor/Base58VectorTest.kt +++ b/libs/encryption/base58/src/commonTest/kotlin/com/getcode/vendor/Base58VectorTest.kt @@ -9,18 +9,19 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * GATE: this repo's Base58 must reproduce the canonical cross-platform fixtures exactly (encode and - * decode). The iOS repo asserts the identical fixtures against its Base58 — matching on both sides is - * what guarantees the apps agree on address/key encoding. Anchored to the Solana all-ones address. + * GATE: Base58 must reproduce the canonical cross-platform fixtures exactly. + * These vectors are asserted on both Android (JVM) and iOS (native) so any + * divergence between platforms is caught immediately. * - * Pure-JVM host unit test (Base58 has no Android/JNI deps). Fixture synced from `code/test-vectors/`. + * Fixture anchored to `test-vectors/base58.json` (canonical source). */ class Base58VectorTest { @Test fun base58_matches_canonical_vectors() { - val text = javaClass.getResourceAsStream("/base58.json")!!.bufferedReader().use { it.readText() } - val vectors = Json.parseToJsonElement(text).jsonObject["vectors"]!!.jsonArray + val text = readTestResource("base58.json") + val root = Json.parseToJsonElement(text).jsonObject + val vectors = root["vectors"]!!.jsonArray assertTrue(vectors.isNotEmpty(), "no vectors loaded") for (el in vectors) { @@ -29,12 +30,17 @@ class Base58VectorTest { val bytes = o["bytes"]!!.jsonPrimitive.content.hexToBytes() val expected = o["base58"]!!.jsonPrimitive.content - assertEquals(expected, Base58.encode(bytes), "encode mismatch for $name") - assertTrue(Base58.decode(expected).contentEquals(bytes), "decode mismatch for $name") + assertEquals(expected, Base58.encode(bytes), "encode mismatch for '$name'") + assertTrue( + Base58.decode(expected).contentEquals(bytes), + "decode mismatch for '$name'" + ) } } } private fun String.hexToBytes(): ByteArray = if (isEmpty()) ByteArray(0) - else ByteArray(length / 2) { ((this[it * 2].digitToInt(16) shl 4) or this[it * 2 + 1].digitToInt(16)).toByte() } + else ByteArray(length / 2) { i -> + ((this[i * 2].digitToInt(16) shl 4) or this[i * 2 + 1].digitToInt(16)).toByte() + } diff --git a/libs/encryption/base58/src/commonTest/kotlin/com/getcode/vendor/TestResources.kt b/libs/encryption/base58/src/commonTest/kotlin/com/getcode/vendor/TestResources.kt new file mode 100644 index 0000000000..018aeb9205 --- /dev/null +++ b/libs/encryption/base58/src/commonTest/kotlin/com/getcode/vendor/TestResources.kt @@ -0,0 +1,4 @@ +package com.getcode.vendor + +/** Reads a test resource file by name from `src/commonTest/resources/`. */ +expect fun readTestResource(name: String): String diff --git a/libs/encryption/base58/src/test/resources/base58.json b/libs/encryption/base58/src/commonTest/resources/base58.json similarity index 100% rename from libs/encryption/base58/src/test/resources/base58.json rename to libs/encryption/base58/src/commonTest/resources/base58.json diff --git a/libs/encryption/base58/src/iosTest/kotlin/com/getcode/vendor/TestResources.ios.kt b/libs/encryption/base58/src/iosTest/kotlin/com/getcode/vendor/TestResources.ios.kt new file mode 100644 index 0000000000..4640b6649d --- /dev/null +++ b/libs/encryption/base58/src/iosTest/kotlin/com/getcode/vendor/TestResources.ios.kt @@ -0,0 +1,17 @@ +package com.getcode.vendor + +import platform.Foundation.NSBundle +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.stringWithContentsOfFile + +actual fun readTestResource(name: String): String { + val nameWithoutExt = name.substringBeforeLast(".") + val ext = name.substringAfterLast(".", "") + val path = checkNotNull( + NSBundle.mainBundle.pathForResource(nameWithoutExt, ext) + ) { "Resource '$name' not found in bundle" } + return checkNotNull( + NSString.stringWithContentsOfFile(path, NSUTF8StringEncoding, null) + ) { "Failed to read resource '$name' at $path" } as String +} diff --git a/libs/encryption/base58/src/main/kotlin/com/getcode/vendor/Base58.kt b/libs/encryption/base58/src/main/kotlin/com/getcode/vendor/Base58.kt deleted file mode 100644 index eb6adcaecf..0000000000 --- a/libs/encryption/base58/src/main/kotlin/com/getcode/vendor/Base58.kt +++ /dev/null @@ -1,245 +0,0 @@ -package com.getcode.vendor - -import java.math.BigInteger -import java.security.MessageDigest -import java.security.NoSuchAlgorithmException -import java.util.Arrays - -/** - * Taken and adapted from https://github.com/bitcoinj/bitcoinj/blob/master/core/src/main/java/org/bitcoinj/core/Base58.java - * - * Copyright 2011 Google Inc. - * Copyright 2018 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. - */ - -/** - * Base58 is a way to encode Bitcoin addresses (or arbitrary data) as alphanumeric strings. - * - * - * Note that this is not the same base58 as used by Flickr, which you may find referenced around the Internet. - * - * - * Satoshi explains: why base-58 instead of standard base-64 encoding? - * - * * Don't want 0OIl characters that look the same in some fonts and - * could be used to create visually identical looking account numbers. - * * A string with non-alphanumeric characters is not as easily accepted as an account number. - * * E-mail usually won't line-break if there's no punctuation to break at. - * * Doubleclicking selects the whole number as one word if it's all alphanumeric. - * - * - * - * However, note that the encoding/decoding runs in O(n) time, so it is not useful for large data. - * - * - * The basic idea of the encoding is to treat the data bytes as a large number represented using - * base-256 digits, convert the number to be represented using base-58 digits, preserve the exact - * number of leading zeros (which are otherwise lost during the mathematical operations on the - * numbers), and finally represent the resulting base-58 digits as alphanumeric ASCII characters. - */ -object Base58 { - private val ALPHABET = - "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray() - private val ENCODED_ZERO = ALPHABET[0] - private val INDEXES = IntArray(128) - - sealed class AddressFormatException(message: String?) : IllegalArgumentException(message) { - - class InvalidCharacter(val character: Char, val position: Int) : - AddressFormatException("Invalid character '" + Character.toString(character) + "' at position " + position) - - class InvalidDataLength(message: String?) : AddressFormatException(message) - - class InvalidChecksum : AddressFormatException("Checksum does not validate") - } - - /** - * Encodes the given bytes as a base58 string (no checksum is appended). - * - * @param input the bytes to encode - * @return the base58-encoded string - */ - fun encode(input: ByteArray): String { - var inputCopy = input - if (inputCopy.size == 0) { - return "" - } - // Count leading zeros. - var zeros = 0 - while (zeros < inputCopy.size && inputCopy[zeros] == 0.toByte()) { - ++zeros - } - // Convert base-256 digits to base-58 digits (plus conversion to ASCII characters) - inputCopy = Arrays.copyOf(inputCopy, inputCopy.size) // since we modify it in-place - val encoded = CharArray(inputCopy.size * 2) // upper bound - var outputStart = encoded.size - var inputStart = zeros - while (inputStart < inputCopy.size) { - encoded[--outputStart] = ALPHABET[divmod(inputCopy, inputStart, 256, 58).toInt()] - if (inputCopy[inputStart] == 0.toByte()) { - ++inputStart // optimization - skip leading zeros - } - } - // Preserve exactly as many leading encoded zeros in output as there were leading zeros in input. - while (outputStart < encoded.size && encoded[outputStart] == ENCODED_ZERO) { - ++outputStart - } - while (--zeros >= 0) { - encoded[--outputStart] = ENCODED_ZERO - } - // Return encoded string (including encoded leading zeros). - return String(encoded, outputStart, encoded.size - outputStart) - } - - /** - * Encodes the given version and bytes as a base58 string. A checksum is appended. - * - * @param version the version to encode - * @param payload the bytes to encode, e.g. pubkey hash - * @return the base58-encoded string - */ - fun encodeChecked(version: Int, payload: ByteArray): String { - require(!(version < 0 || version > 255)) { "Version not in range." } - - // A stringified buffer is: - // 1 byte version + data bytes + 4 bytes check code (a truncated hash) - val addressBytes = ByteArray(1 + payload.size + 4) - addressBytes[0] = version.toByte() - System.arraycopy(payload, 0, addressBytes, 1, payload.size) - val checksum: ByteArray = hashTwice(addressBytes, 0, payload.size + 1) - System.arraycopy(checksum, 0, addressBytes, payload.size + 1, 4) - return encode(addressBytes) - } - - /** - * Decodes the given base58 string into the original data bytes. - * - * @param input the base58-encoded string to decode - * @return the decoded data bytes - * @throws AddressFormatException if the given string is not a valid base58 string - */ - @Throws(AddressFormatException::class) - fun decode(input: String): ByteArray { - if (input.isEmpty()) { - return ByteArray(0) - } - // Convert the base58-encoded ASCII chars to a base58 byte sequence (base58 digits). - val input58 = ByteArray(input.length) - for (i in input.indices) { - val c = input[i] - val digit = if (c.toInt() < 128) INDEXES[c.toInt()] else -1 - if (digit < 0) { - throw AddressFormatException.InvalidCharacter(c, i) - } - input58[i] = digit.toByte() - } - // Count leading zeros. - var zeros = 0 - while (zeros < input58.size && input58[zeros] == 0.toByte()) { - ++zeros - } - // Convert base-58 digits to base-256 digits. - val decoded = ByteArray(input.length) - var outputStart = decoded.size - var inputStart = zeros - while (inputStart < input58.size) { - decoded[--outputStart] = divmod(input58, inputStart, 58, 256) - if (input58[inputStart] == 0.toByte()) { - ++inputStart // optimization - skip leading zeros - } - } - // Ignore extra leading zeroes that were added during the calculation. - while (outputStart < decoded.size && decoded[outputStart] == 0.toByte()) { - ++outputStart - } - // Return decoded data (including original number of leading zeros). - return Arrays.copyOfRange(decoded, outputStart - zeros, decoded.size) - } - - @Throws(AddressFormatException::class) - fun decodeToBigInteger(input: String): BigInteger { - return BigInteger(1, decode(input)) - } - - /** - * Decodes the given base58 string into the original data bytes, using the checksum in the - * last 4 bytes of the decoded data to verify that the rest are correct. The checksum is - * removed from the returned data. - * - * @param input the base58-encoded string to decode (which should include the checksum) - * @throws AddressFormatException if the input is not base 58 or the checksum does not validate. - */ - @Throws(AddressFormatException::class) - fun decodeChecked(input: String): ByteArray { - val decoded = decode(input) - if (decoded.size < 4) throw AddressFormatException.InvalidDataLength("Input too short: " + decoded.size) - val data = Arrays.copyOfRange(decoded, 0, decoded.size - 4) - val checksum = Arrays.copyOfRange(decoded, decoded.size - 4, decoded.size) - val actualChecksum: ByteArray = Arrays.copyOfRange(hashTwice(data), 0, 4) - if (!Arrays.equals(checksum, actualChecksum)) throw AddressFormatException.InvalidChecksum() - return data - } - - /** - * Divides a number, represented as an array of bytes each containing a single digit - * in the specified base, by the given divisor. The given number is modified in-place - * to contain the quotient, and the return value is the remainder. - * - * @param number the number to divide - * @param firstDigit the index within the array of the first non-zero digit - * (this is used for optimization by skipping the leading zeros) - * @param base the base in which the number's digits are represented (up to 256) - * @param divisor the number to divide by (up to 256) - * @return the remainder of the division operation - */ - private fun divmod(number: ByteArray, firstDigit: Int, base: Int, divisor: Int): Byte { - // this is just long division which accounts for the base of the input digits - var remainder = 0 - for (i in firstDigit until number.size) { - val digit = number[i].toInt() and 0xFF - val temp = remainder * base + digit - number[i] = (temp / divisor).toByte() - remainder = temp % divisor - } - return remainder.toByte() - } - - /** - * Calculates the SHA-256 hash of the given byte range, - * and then hashes the resulting hash again. - * - * @param input the array containing the bytes to hash - * @param offset the offset within the array of the bytes to hash - * @param length the number of bytes to hash - * @return the double-hash (in big-endian order) - */ - @JvmOverloads - fun hashTwice(input: ByteArray, offset: Int = 0, length: Int = input.size): ByteArray { - val digest = try { - MessageDigest.getInstance("SHA-256") - } catch (e: NoSuchAlgorithmException) { - throw RuntimeException(e) // Can't happen. - } - digest.update(input, offset, length) - return digest.digest(digest.digest()) - } - - init { - Arrays.fill(INDEXES, -1) - for (i in ALPHABET.indices) { - INDEXES[ALPHABET[i].toInt()] = i - } - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9d7cfab57f..9759367a10 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -192,6 +192,9 @@ include( ":ui:testing", ":ui:theme", + // cross-platform shared core (Kotlin Multiplatform → Android + iOS XCFramework) + ":kmp:shared-core", + // 3rd party imported dependencies ":vendor:kik:scanner", ":vendor:tipkit:tipkit", @@ -240,7 +243,7 @@ val koverModules = includedProjectPaths.filter { path -> // 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. -val unitTestPaths = listOf(":apps:flipcash", ":services:flipcash", ":services:opencode", ":libs") +val unitTestPaths = listOf(":apps:flipcash", ":services:flipcash", ":services:opencode", ":libs", ":kmp") val jvmUnitTestModules = setOf(":apps:flipcash:shared:ksp") val noUnitTestModules = setOf(":apps:flipcash:benchmark") val unitTestCandidates = includedProjectPaths.filter { path ->