From c2b242e89f07c13ae547bf071d7d2878f51e738d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 23 Oct 2026 15:09:24 -0400 Subject: [PATCH 1/3] build(kmp): declare shared targets in flipcash.kmp.library All 14 KMP library modules repeated the same android compileSdk/minSdk block and the same five Apple targets, and only text-format applied the convention plugin. The plugin now declares those targets, and every module applies it and keeps only its namespace and test opt-ins. shared-core attaches its SharedCore framework to each native target via targets.withType() instead of a local target list. discrete-curve hard-coded compileSdk = 37; it now picks up the catalog's 37 with minor API level 2 like the other modules. --- .../main/kotlin/KmpLibraryConventionPlugin.kt | 43 ++++++++++++++++++- kmp/shared-core/build.gradle.kts | 18 +++----- libs/analytics-events/build.gradle.kts | 15 +------ libs/codes/kikcode/build.gradle.kts | 15 +------ .../discrete-curve/build.gradle.kts | 11 +---- libs/encryption/base58/build.gradle.kts | 15 +------ libs/encryption/chat-cipher/build.gradle.kts | 15 +------ libs/encryption/ed25519/build.gradle.kts | 15 +------ libs/encryption/hmac/build.gradle.kts | 15 +------ libs/encryption/mnemonic/build.gradle.kts | 15 +------ libs/encryption/sha256/build.gradle.kts | 15 +------ libs/encryption/sha512/build.gradle.kts | 15 +------ libs/encryption/utils/build.gradle.kts | 15 +------ libs/reporting/build.gradle.kts | 15 +------ libs/text-format/build.gradle.kts | 12 ------ 15 files changed, 59 insertions(+), 190 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt index 4d394bce85..38b4d3f11b 100644 --- a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt @@ -1,5 +1,11 @@ +import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.plugins.ExtensionAware +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.getByType +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension /** * Convention plugin for KMP library modules. @@ -8,14 +14,25 @@ import org.gradle.api.Project * - `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. + * and declares the targets every shared library builds for: + * - `android` -- compileSdk and minSdk from the version catalog + * - `iosArm64`, `iosSimulatorArm64`, `iosX64`, `macosArm64`, `macosX64` -- the Kotlin/Native + * slices of the `SharedCore` framework iOS consumes + * + * A module still sets its own `namespace`, and opts into host/device tests, inside `kotlin { android {} }`. * * Usage in a module's `build.gradle.kts`: * ``` * plugins { * alias(libs.plugins.flipcash.kmp.library) * } + * + * kotlin { + * android { + * namespace = "com.example" + * withHostTest {} + * } + * } * ``` */ class KmpLibraryConventionPlugin : Plugin { @@ -25,6 +42,28 @@ class KmpLibraryConventionPlugin : Plugin { apply("org.jetbrains.kotlin.multiplatform") apply("com.android.kotlin.multiplatform.library") } + + val libs = extensions.getByType().named("libs") + val compileSdkVersion = libs.findVersion("android-compileSdk").get().requiredVersion.toInt() + val compileSdkMinorVersion = libs.findVersion("android-compileSdkMinor").get().requiredVersion.toInt() + val minSdkVersion = libs.findVersion("android-minSdk").get().requiredVersion.toInt() + + extensions.configure { + (this as ExtensionAware).extensions.configure { + compileSdk { + version = release(compileSdkVersion) { + minorApiLevel = compileSdkMinorVersion + } + } + minSdk = minSdkVersion + } + + iosArm64() + iosSimulatorArm64() + iosX64() + macosArm64() + macosX64() + } } } } diff --git a/kmp/shared-core/build.gradle.kts b/kmp/shared-core/build.gradle.kts index a9bfafc66e..3bf85b67e9 100644 --- a/kmp/shared-core/build.gradle.kts +++ b/kmp/shared-core/build.gradle.kts @@ -1,9 +1,9 @@ import co.touchlab.kmmbridge.KmmBridgeExtension import org.gradle.api.tasks.bundling.Zip +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget plugins { - kotlin("multiplatform") - id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.library) id("co.touchlab.kmmbridge.github") version "1.2.1" `maven-publish` } @@ -24,17 +24,11 @@ val spmPackageDir = findProperty("spmRepoDir") as String? kotlin { android { namespace = "com.flipcash.shared" - compileSdk { - version = release(libs.versions.android.compileSdk.get().toInt()) { - minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() - } - } - minSdk = 29 } - - val appleTargets = listOf(iosArm64(), iosSimulatorArm64(), iosX64(), macosArm64(), macosX64()) - appleTargets.forEach { - it.binaries.framework { + + // The Apple targets come from `flipcash.kmp.library`; each one ships a `SharedCore` slice. + targets.withType().configureEach { + binaries.framework { baseName = "SharedCore" isStatic = true export(project(":libs:codes:kikcode")) diff --git a/libs/analytics-events/build.gradle.kts b/libs/analytics-events/build.gradle.kts index 194f8071e1..48eae940ca 100644 --- a/libs/analytics-events/build.gradle.kts +++ b/libs/analytics-events/build.gradle.kts @@ -1,6 +1,5 @@ plugins { - kotlin("multiplatform") - id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.library) // Generates the builders and value enums from events.toml into commonMain. alias(libs.plugins.flipcash.analytics.catalogue) } @@ -8,21 +7,9 @@ plugins { kotlin { android { namespace = "com.flipcash.analytics" - compileSdk { - version = release(libs.versions.android.compileSdk.get().toInt()) { - minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() - } - } - minSdk = 29 withHostTest {} } - iosArm64() - iosSimulatorArm64() - iosX64() - macosArm64() - macosX64() - sourceSets { commonMain { // Pure Kotlin -- no external dependencies needed. diff --git a/libs/codes/kikcode/build.gradle.kts b/libs/codes/kikcode/build.gradle.kts index b2d926844a..fafb2aaca7 100644 --- a/libs/codes/kikcode/build.gradle.kts +++ b/libs/codes/kikcode/build.gradle.kts @@ -1,6 +1,5 @@ plugins { - kotlin("multiplatform") - id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.library) alias(libs.plugins.flipcash.kmp.test.fixtures) } @@ -13,21 +12,9 @@ testFixtures { kotlin { android { namespace = "com.getcode.codes.kikcode" - compileSdk { - version = release(libs.versions.android.compileSdk.get().toInt()) { - minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() - } - } - minSdk = 29 withHostTest {} } - iosArm64() - iosSimulatorArm64() - iosX64() - macosArm64() - macosX64() - sourceSets { commonMain { // Pure Kotlin -- geometry + string building, no platform APIs. diff --git a/libs/currency-math/discrete-curve/build.gradle.kts b/libs/currency-math/discrete-curve/build.gradle.kts index 6ba6a5cf34..5cdde2fe86 100644 --- a/libs/currency-math/discrete-curve/build.gradle.kts +++ b/libs/currency-math/discrete-curve/build.gradle.kts @@ -1,6 +1,5 @@ plugins { - kotlin("multiplatform") - id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.library) alias(libs.plugins.flipcash.kmp.test.fixtures) } @@ -13,8 +12,6 @@ testFixtures { kotlin { android { namespace = "com.flipcash.libs.currency.math.curve" - compileSdk = 37 - minSdk = 29 withHostTest {} // `flipcash.kmp.test.fixtures` compiles this module's fixtures -- @@ -31,12 +28,6 @@ kotlin { } } - iosArm64() - iosSimulatorArm64() - iosX64() - macosArm64() - macosX64() - sourceSets { commonMain { dependencies { diff --git a/libs/encryption/base58/build.gradle.kts b/libs/encryption/base58/build.gradle.kts index 143d07f8f0..a4ca3469bc 100644 --- a/libs/encryption/base58/build.gradle.kts +++ b/libs/encryption/base58/build.gradle.kts @@ -1,6 +1,5 @@ plugins { - kotlin("multiplatform") - id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.library) alias(libs.plugins.flipcash.kmp.test.fixtures) } @@ -13,21 +12,9 @@ testFixtures { kotlin { android { namespace = "com.getcode.encryption.base58" - compileSdk { - version = release(libs.versions.android.compileSdk.get().toInt()) { - minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() - } - } - minSdk = 29 withHostTest {} } - iosArm64() - iosSimulatorArm64() - iosX64() - macosArm64() - macosX64() - sourceSets { commonMain { // Pure Kotlin -- no external dependencies needed. diff --git a/libs/encryption/chat-cipher/build.gradle.kts b/libs/encryption/chat-cipher/build.gradle.kts index 3491f546a8..d61bc38456 100644 --- a/libs/encryption/chat-cipher/build.gradle.kts +++ b/libs/encryption/chat-cipher/build.gradle.kts @@ -1,8 +1,7 @@ import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree plugins { - kotlin("multiplatform") - id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.library) alias(libs.plugins.flipcash.kmp.test.fixtures) } @@ -15,12 +14,6 @@ testFixtures { kotlin { android { namespace = "com.getcode.encryption.chatcipher" - compileSdk { - version = release(libs.versions.android.compileSdk.get().toInt()) { - minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() - } - } - minSdk = 29 // No host test: libsodium's Android binding loads its .so from the APK and the Ed25519 // actual is JNI, so neither loads on a JVM host. The device test runs commonTest instead, // and the Apple targets run it natively. @@ -31,12 +24,6 @@ kotlin { } } - iosArm64() - iosSimulatorArm64() - iosX64() - macosArm64() - macosX64() - sourceSets { commonMain { dependencies { diff --git a/libs/encryption/ed25519/build.gradle.kts b/libs/encryption/ed25519/build.gradle.kts index b77ecbce3f..911b4bf760 100644 --- a/libs/encryption/ed25519/build.gradle.kts +++ b/libs/encryption/ed25519/build.gradle.kts @@ -1,8 +1,7 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget plugins { - kotlin("multiplatform") - id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.library) alias(libs.plugins.flipcash.kmp.test.fixtures) } @@ -79,21 +78,9 @@ appleTargetDefs.forEach { target -> kotlin { android { namespace = "com.getcode.encryption.ed25519" - compileSdk { - version = release(libs.versions.android.compileSdk.get().toInt()) { - minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() - } - } - minSdk = 29 withHostTest {} } - iosArm64() - iosSimulatorArm64() - iosX64() - macosArm64() - macosX64() - // ── Cinterop + linker wiring for each Apple target ──────────────────────── targets.withType().configureEach { val targetDef = appleTargetDefs.first { it.kotlinName == name } diff --git a/libs/encryption/hmac/build.gradle.kts b/libs/encryption/hmac/build.gradle.kts index 258a914a10..93776d856c 100644 --- a/libs/encryption/hmac/build.gradle.kts +++ b/libs/encryption/hmac/build.gradle.kts @@ -1,26 +1,13 @@ plugins { - kotlin("multiplatform") - id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.library) } kotlin { android { namespace = "com.getcode.encryption.hmac" - compileSdk { - version = release(libs.versions.android.compileSdk.get().toInt()) { - minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() - } - } - minSdk = 29 withHostTest {} } - iosArm64() - iosSimulatorArm64() - iosX64() - macosArm64() - macosX64() - sourceSets { commonMain { dependencies { diff --git a/libs/encryption/mnemonic/build.gradle.kts b/libs/encryption/mnemonic/build.gradle.kts index fa0c0dd626..6752d8968c 100644 --- a/libs/encryption/mnemonic/build.gradle.kts +++ b/libs/encryption/mnemonic/build.gradle.kts @@ -1,6 +1,5 @@ plugins { - kotlin("multiplatform") - id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.library) alias(libs.plugins.flipcash.kmp.test.fixtures) } @@ -13,22 +12,10 @@ testFixtures { kotlin { android { namespace = "com.getcode.encryption.mnemonic" - compileSdk { - version = release(libs.versions.android.compileSdk.get().toInt()) { - minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() - } - } - minSdk = 29 withHostTest {} withDeviceTest {} } - iosArm64() - iosSimulatorArm64() - iosX64() - macosArm64() - macosX64() - sourceSets { commonMain { dependencies { diff --git a/libs/encryption/sha256/build.gradle.kts b/libs/encryption/sha256/build.gradle.kts index adfcd4938f..b2dc650c44 100644 --- a/libs/encryption/sha256/build.gradle.kts +++ b/libs/encryption/sha256/build.gradle.kts @@ -1,26 +1,13 @@ plugins { - kotlin("multiplatform") - id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.library) } kotlin { android { namespace = "com.getcode.encryption.sha256" - compileSdk { - version = release(libs.versions.android.compileSdk.get().toInt()) { - minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() - } - } - minSdk = 29 withHostTest {} } - iosArm64() - iosSimulatorArm64() - iosX64() - macosArm64() - macosX64() - sourceSets { commonMain { dependencies { diff --git a/libs/encryption/sha512/build.gradle.kts b/libs/encryption/sha512/build.gradle.kts index 038470ad6b..e8557c31cc 100644 --- a/libs/encryption/sha512/build.gradle.kts +++ b/libs/encryption/sha512/build.gradle.kts @@ -1,26 +1,13 @@ plugins { - kotlin("multiplatform") - id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.library) } kotlin { android { namespace = "com.getcode.encryption.sha512" - compileSdk { - version = release(libs.versions.android.compileSdk.get().toInt()) { - minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() - } - } - minSdk = 29 withHostTest {} } - iosArm64() - iosSimulatorArm64() - iosX64() - macosArm64() - macosX64() - sourceSets { commonMain { dependencies { diff --git a/libs/encryption/utils/build.gradle.kts b/libs/encryption/utils/build.gradle.kts index 6b5bc632f3..79e57d881d 100644 --- a/libs/encryption/utils/build.gradle.kts +++ b/libs/encryption/utils/build.gradle.kts @@ -1,26 +1,13 @@ plugins { - kotlin("multiplatform") - id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.library) } kotlin { android { namespace = "com.getcode.encryption.utils" - compileSdk { - version = release(libs.versions.android.compileSdk.get().toInt()) { - minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() - } - } - minSdk = 29 withHostTest {} } - iosArm64() - iosSimulatorArm64() - iosX64() - macosArm64() - macosX64() - sourceSets { commonMain { dependencies { diff --git a/libs/reporting/build.gradle.kts b/libs/reporting/build.gradle.kts index a03de2ded7..70b77cc4e6 100644 --- a/libs/reporting/build.gradle.kts +++ b/libs/reporting/build.gradle.kts @@ -1,26 +1,13 @@ plugins { - kotlin("multiplatform") - id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.library) } kotlin { android { namespace = "com.flipcash.reporting" - compileSdk { - version = release(libs.versions.android.compileSdk.get().toInt()) { - minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() - } - } - minSdk = 29 withHostTest {} } - iosArm64() - iosSimulatorArm64() - iosX64() - macosArm64() - macosX64() - sourceSets { commonMain { // Pure Kotlin -- no external dependencies needed. diff --git a/libs/text-format/build.gradle.kts b/libs/text-format/build.gradle.kts index 16de7b7f18..bf08b14d1a 100644 --- a/libs/text-format/build.gradle.kts +++ b/libs/text-format/build.gradle.kts @@ -12,21 +12,9 @@ testFixtures { kotlin { android { namespace = "com.flipcash.libs.textformat" - compileSdk { - version = release(libs.versions.android.compileSdk.get().toInt()) { - minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() - } - } - minSdk = 29 withHostTest {} } - iosArm64() - iosSimulatorArm64() - iosX64() - macosArm64() - macosX64() - sourceSets { commonMain { // Pure Kotlin -- no external dependencies needed. From f2508cac172386657997a554fe0702b484dfe9df Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 23 Oct 2026 15:09:25 -0400 Subject: [PATCH 2/3] feat(kmp): add a jvm() target to the shared libraries First step of the desktop client: the KMP libraries need a JVM target before Compose Desktop modules can depend on them. flipcash.kmp.library now declares jvm() with jvmTarget from the android-java catalog version. Two expect declarations needed JVM actuals: - sha512 derivePlatform: the JDK ships PBKDF2WithHmacSHA512, so the actual is the Android JCA one with the same ASCII-only guard. - Ed25519Kmp: pure Java on net.i2p.crypto:eddsa, which already backs the test-utils shadow. The 64-byte private key is eddsa's clamped hash `h`, the same orlp layout the JNI and cinterop actuals return, so keys move between platforms unchanged. The RFC 8032 vectors in ed25519.json pass on jvmTest, so no native library is loaded on desktop. sign.c includes sha3.h, but that header declares the sha512_* functions and sha3.c is libtom's SHA-512 (it carries the 0x428a2f98d728ae22 round constant). It's a misnamed file, not SHA-3, which is why standard SHA-512 on the JVM reproduces the native signatures. --- .../main/kotlin/KmpLibraryConventionPlugin.kt | 9 +++ libs/encryption/ed25519/build.gradle.kts | 6 ++ .../com/getcode/ed25519kmp/Ed25519Kmp.jvm.kt | 61 +++++++++++++++++++ .../com/getcode/crypt/PBKDF2SHA512.jvm.kt | 27 ++++++++ 4 files changed, 103 insertions(+) create mode 100644 libs/encryption/ed25519/src/jvmMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.jvm.kt create mode 100644 libs/encryption/sha512/src/jvmMain/kotlin/com/getcode/crypt/PBKDF2SHA512.jvm.kt diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt index 38b4d3f11b..94efac5553 100644 --- a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt @@ -5,6 +5,7 @@ import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.plugins.ExtensionAware import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.getByType +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension /** @@ -18,6 +19,7 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension * - `android` -- compileSdk and minSdk from the version catalog * - `iosArm64`, `iosSimulatorArm64`, `iosX64`, `macosArm64`, `macosX64` -- the Kotlin/Native * slices of the `SharedCore` framework iOS consumes + * - `jvm` -- the desktop client * * A module still sets its own `namespace`, and opts into host/device tests, inside `kotlin { android {} }`. * @@ -47,6 +49,7 @@ class KmpLibraryConventionPlugin : Plugin { val compileSdkVersion = libs.findVersion("android-compileSdk").get().requiredVersion.toInt() val compileSdkMinorVersion = libs.findVersion("android-compileSdkMinor").get().requiredVersion.toInt() val minSdkVersion = libs.findVersion("android-minSdk").get().requiredVersion.toInt() + val javaVersion = libs.findVersion("android-java").get().requiredVersion extensions.configure { (this as ExtensionAware).extensions.configure { @@ -63,6 +66,12 @@ class KmpLibraryConventionPlugin : Plugin { iosX64() macosArm64() macosX64() + + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(javaVersion)) + } + } } } } diff --git a/libs/encryption/ed25519/build.gradle.kts b/libs/encryption/ed25519/build.gradle.kts index 911b4bf760..28dac3e0e8 100644 --- a/libs/encryption/ed25519/build.gradle.kts +++ b/libs/encryption/ed25519/build.gradle.kts @@ -125,6 +125,12 @@ kotlin { api(project(":libs:encryption:ed25519-native")) } } + jvmMain { + dependencies { + // Pure-Java Ed25519 for desktop -- no per-OS native library to build or ship. + implementation(libs.eddsa) + } + } commonTest { dependencies { implementation(kotlin("test")) diff --git a/libs/encryption/ed25519/src/jvmMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.jvm.kt b/libs/encryption/ed25519/src/jvmMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.jvm.kt new file mode 100644 index 0000000000..2b7ddbbad5 --- /dev/null +++ b/libs/encryption/ed25519/src/jvmMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.jvm.kt @@ -0,0 +1,61 @@ +package com.getcode.ed25519kmp + +import net.i2p.crypto.eddsa.EdDSAEngine +import net.i2p.crypto.eddsa.EdDSAPrivateKey +import net.i2p.crypto.eddsa.EdDSAPublicKey +import net.i2p.crypto.eddsa.math.GroupElement +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable +import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec +import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec +import java.security.MessageDigest + +/** + * JVM (desktop) actual: pure Java, on `net.i2p.crypto:eddsa`, so there is no native library to + * ship per OS. + * + * The 64-byte private key keeps the orlp layout the Android and Apple actuals produce: the clamped + * SHA-512 expansion of the seed (`a || prefix`). eddsa calls that same value `h`, and accepts it + * directly through [EdDSAPrivateKeySpec]'s `(spec, h)` constructor, so a key created on any + * platform signs identically here. The JDK's own Ed25519 provider can't do this: its private key + * spec only takes the 32-byte seed, which the orlp key doesn't contain. + */ +actual object Ed25519Kmp { + + private val spec = EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519) + + actual fun createKeyPair(seed: ByteArray): KeyPair { + val privateKey = EdDSAPrivateKey(EdDSAPrivateKeySpec(seed, spec)) + return KeyPair(publicKey = privateKey.abyte, privateKey = privateKey.h) + } + + actual fun sign( + message: ByteArray, + publicKey: ByteArray, + privateKey: ByteArray, + ): ByteArray { + val key = EdDSAPrivateKey(EdDSAPrivateKeySpec(spec, privateKey)) + return EdDSAEngine(MessageDigest.getInstance(spec.hashAlgorithm)).run { + initSign(key) + update(message) + sign() + } + } + + actual fun verify( + signature: ByteArray, + message: ByteArray, + publicKey: ByteArray, + ): Boolean = runCatching { + val key = EdDSAPublicKey(EdDSAPublicKeySpec(publicKey, spec)) + EdDSAEngine(MessageDigest.getInstance(spec.hashAlgorithm)).run { + initVerify(key) + update(message) + verify(signature) + } + }.getOrDefault(false) + + actual fun onCurve(publicKey: ByteArray): Boolean = runCatching { + GroupElement(spec.curve, publicKey) + true + }.getOrDefault(false) +} diff --git a/libs/encryption/sha512/src/jvmMain/kotlin/com/getcode/crypt/PBKDF2SHA512.jvm.kt b/libs/encryption/sha512/src/jvmMain/kotlin/com/getcode/crypt/PBKDF2SHA512.jvm.kt new file mode 100644 index 0000000000..eb2a0f2aa5 --- /dev/null +++ b/libs/encryption/sha512/src/jvmMain/kotlin/com/getcode/crypt/PBKDF2SHA512.jvm.kt @@ -0,0 +1,27 @@ +package com.getcode.crypt + +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec + +private const val ALGORITHM = "PBKDF2WithHmacSHA512" + +/** + * The JDK's SunJCE provider implements PBKDF2WithHmacSHA512, so the desktop JVM takes the same JCA + * route as Android (Conscrypt there). Same ASCII-only restriction and fallback as the Android + * actual -- see `PBKDF2SHA512.android.kt` for why: providers disagree about encoding a non-ASCII + * `char[]` password, and ASCII is where every encoding coincides. + */ +internal actual fun derivePlatform(P: String, S: String, c: Int, dkLen: Int): ByteArray? { + if (!P.isAscii()) return null + + return runCatching { + val spec = PBEKeySpec(P.toCharArray(), S.encodeToByteArray(), c, dkLen * 8) + try { + SecretKeyFactory.getInstance(ALGORITHM).generateSecret(spec).encoded + } finally { + spec.clearPassword() + } + }.getOrNull() +} + +private fun String.isAscii(): Boolean = all { it.code < 0x80 } From 1d367b543c94f367436e7a6562e3d471e17fd1bc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 23 Oct 2026 15:29:13 -0400 Subject: [PATCH 3/3] build(kmp): add flipcash.kmp.app.library for android+jvm modules Hilt's Gradle plugin rejects the AGP KMP android target, so KMP modules run the Hilt KSP processors through kspAndroid without the plugin. Only the app and notifications use @AndroidEntryPoint, which is what the plugin's bytecode rewrite exists for. libs:coroutines moves onto the plugin, and its test fixtures become a :libs:coroutines:test-fixtures module, since the KMP android target has no testFixtures. shared:funding moves its sources to androidMain as the first Hilt-bearing module on the KMP plugin. --- .../features/messenger/build.gradle.kts | 2 +- .../shared/appupdates/build.gradle.kts | 2 +- .../shared/authentication/build.gradle.kts | 2 +- apps/flipcash/shared/funding/build.gradle.kts | 46 +++++-- .../app/funding/PurchaseMethodStateTest.kt | 0 .../flipcash/app/funding/PurchaseMethod.kt | 0 .../app/funding/PurchaseMethodController.kt | 0 .../app/funding/PurchaseMethodState.kt | 0 .../flipcash/app/funding/internal/Buttons.kt | 0 .../InternalPurchaseMethodController.kt | 0 apps/flipcash/shared/invite/build.gradle.kts | 2 +- apps/flipcash/shared/session/build.gradle.kts | 2 +- build-logic/convention/build.gradle.kts | 4 + .../kotlin/KmpAppLibraryConventionPlugin.kt | 129 ++++++++++++++++++ gradle/libs.versions.toml | 1 + libs/coroutines/build.gradle.kts | 14 +- .../libs/coroutines/DispatcherProvider.kt | 0 .../coroutines/test-fixtures/build.gradle.kts | 16 +++ .../libs/coroutines/TestDispatcherProvider.kt | 0 settings.gradle.kts | 1 + 20 files changed, 197 insertions(+), 24 deletions(-) rename apps/flipcash/shared/funding/src/{test => androidHostTest}/kotlin/com/flipcash/app/funding/PurchaseMethodStateTest.kt (100%) rename apps/flipcash/shared/funding/src/{main => androidMain}/kotlin/com/flipcash/app/funding/PurchaseMethod.kt (100%) rename apps/flipcash/shared/funding/src/{main => androidMain}/kotlin/com/flipcash/app/funding/PurchaseMethodController.kt (100%) rename apps/flipcash/shared/funding/src/{main => androidMain}/kotlin/com/flipcash/app/funding/PurchaseMethodState.kt (100%) rename apps/flipcash/shared/funding/src/{main => androidMain}/kotlin/com/flipcash/app/funding/internal/Buttons.kt (100%) rename apps/flipcash/shared/funding/src/{main => androidMain}/kotlin/com/flipcash/app/funding/internal/InternalPurchaseMethodController.kt (100%) create mode 100644 build-logic/convention/src/main/kotlin/KmpAppLibraryConventionPlugin.kt rename libs/coroutines/src/{main => commonMain}/kotlin/com/flipcash/libs/coroutines/DispatcherProvider.kt (100%) create mode 100644 libs/coroutines/test-fixtures/build.gradle.kts rename libs/coroutines/{src/testFixtures => test-fixtures/src/commonMain}/kotlin/com/flipcash/libs/coroutines/TestDispatcherProvider.kt (100%) diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 1c21aec922..db75e306bb 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -9,7 +9,7 @@ android { dependencies { implementation(project(":apps:flipcash:shared:analytics")) testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) - testImplementation(testFixtures(project(":libs:coroutines"))) + testImplementation(project(":libs:coroutines:test-fixtures")) implementation(project(":apps:flipcash:shared:blob")) implementation(project(":apps:flipcash:shared:bills")) implementation(project(":apps:flipcash:shared:blocklist")) diff --git a/apps/flipcash/shared/appupdates/build.gradle.kts b/apps/flipcash/shared/appupdates/build.gradle.kts index c6677cd406..0c30c7d864 100644 --- a/apps/flipcash/shared/appupdates/build.gradle.kts +++ b/apps/flipcash/shared/appupdates/build.gradle.kts @@ -20,5 +20,5 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) testImplementation(libs.robolectric) - testImplementation(testFixtures(project(":libs:coroutines"))) + testImplementation(project(":libs:coroutines:test-fixtures")) } diff --git a/apps/flipcash/shared/authentication/build.gradle.kts b/apps/flipcash/shared/authentication/build.gradle.kts index d0da4fab23..76ffc14e35 100644 --- a/apps/flipcash/shared/authentication/build.gradle.kts +++ b/apps/flipcash/shared/authentication/build.gradle.kts @@ -26,5 +26,5 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) testImplementation(libs.robolectric) - testImplementation(testFixtures(project(":libs:coroutines"))) + testImplementation(project(":libs:coroutines:test-fixtures")) } diff --git a/apps/flipcash/shared/funding/build.gradle.kts b/apps/flipcash/shared/funding/build.gradle.kts index 178401e7f9..03fc03ffca 100644 --- a/apps/flipcash/shared/funding/build.gradle.kts +++ b/apps/flipcash/shared/funding/build.gradle.kts @@ -1,16 +1,44 @@ plugins { - alias(libs.plugins.flipcash.android.feature) + alias(libs.plugins.flipcash.kmp.library) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.ksp) } -android { - namespace = "${Gradle.flipcashNamespace}.shared.funding" +kotlin { + android { + namespace = "${Gradle.flipcashNamespace}.shared.funding" + withHostTest {} + androidResources { enable = true } + } + sourceSets { + androidMain.dependencies { + implementation(project.dependencies.platform(libs.compose.bom)) + implementation(libs.bundles.compose) + implementation(libs.bundles.hilt) + implementation(project(":libs:coroutines")) + implementation(project(":libs:logging")) + implementation(project(":ui:core")) + implementation(project(":ui:components")) + implementation(project(":ui:navigation")) + implementation(project(":ui:resources")) + implementation(project(":ui:theme")) + implementation(project(":apps:flipcash:core")) + implementation(project(":apps:flipcash:core-ui")) + implementation(project(":apps:flipcash:shared:analytics")) + implementation(project(":apps:flipcash:shared:featureflags")) + implementation(project(":apps:flipcash:shared:tokens:core")) + implementation(project(":apps:flipcash:shared:userflags")) + implementation(project(":libs:messaging")) + implementation(project(":services:flipcash")) + } + getByName("androidHostTest").dependencies { + implementation(kotlin("test")) + implementation(libs.bundles.unit.testing) + } + } } dependencies { - implementation(project(":apps:flipcash:shared:analytics")) - implementation(project(":apps:flipcash:shared:featureflags")) - implementation(project(":apps:flipcash:shared:tokens:core")) - implementation(project(":apps:flipcash:shared:userflags")) - implementation(project(":libs:messaging")) - implementation(project(":services:flipcash")) + add("kspAndroid", libs.hilt.android.compiler) + add("kspAndroid", libs.hilt.compiler) } diff --git a/apps/flipcash/shared/funding/src/test/kotlin/com/flipcash/app/funding/PurchaseMethodStateTest.kt b/apps/flipcash/shared/funding/src/androidHostTest/kotlin/com/flipcash/app/funding/PurchaseMethodStateTest.kt similarity index 100% rename from apps/flipcash/shared/funding/src/test/kotlin/com/flipcash/app/funding/PurchaseMethodStateTest.kt rename to apps/flipcash/shared/funding/src/androidHostTest/kotlin/com/flipcash/app/funding/PurchaseMethodStateTest.kt diff --git a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/PurchaseMethod.kt b/apps/flipcash/shared/funding/src/androidMain/kotlin/com/flipcash/app/funding/PurchaseMethod.kt similarity index 100% rename from apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/PurchaseMethod.kt rename to apps/flipcash/shared/funding/src/androidMain/kotlin/com/flipcash/app/funding/PurchaseMethod.kt diff --git a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/PurchaseMethodController.kt b/apps/flipcash/shared/funding/src/androidMain/kotlin/com/flipcash/app/funding/PurchaseMethodController.kt similarity index 100% rename from apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/PurchaseMethodController.kt rename to apps/flipcash/shared/funding/src/androidMain/kotlin/com/flipcash/app/funding/PurchaseMethodController.kt diff --git a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/PurchaseMethodState.kt b/apps/flipcash/shared/funding/src/androidMain/kotlin/com/flipcash/app/funding/PurchaseMethodState.kt similarity index 100% rename from apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/PurchaseMethodState.kt rename to apps/flipcash/shared/funding/src/androidMain/kotlin/com/flipcash/app/funding/PurchaseMethodState.kt diff --git a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/Buttons.kt b/apps/flipcash/shared/funding/src/androidMain/kotlin/com/flipcash/app/funding/internal/Buttons.kt similarity index 100% rename from apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/Buttons.kt rename to apps/flipcash/shared/funding/src/androidMain/kotlin/com/flipcash/app/funding/internal/Buttons.kt diff --git a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/InternalPurchaseMethodController.kt b/apps/flipcash/shared/funding/src/androidMain/kotlin/com/flipcash/app/funding/internal/InternalPurchaseMethodController.kt similarity index 100% rename from apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/InternalPurchaseMethodController.kt rename to apps/flipcash/shared/funding/src/androidMain/kotlin/com/flipcash/app/funding/internal/InternalPurchaseMethodController.kt diff --git a/apps/flipcash/shared/invite/build.gradle.kts b/apps/flipcash/shared/invite/build.gradle.kts index ab06609168..0a43002570 100644 --- a/apps/flipcash/shared/invite/build.gradle.kts +++ b/apps/flipcash/shared/invite/build.gradle.kts @@ -12,5 +12,5 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) testImplementation(libs.robolectric) - testImplementation(testFixtures(project(":libs:coroutines"))) + testImplementation(project(":libs:coroutines:test-fixtures")) } diff --git a/apps/flipcash/shared/session/build.gradle.kts b/apps/flipcash/shared/session/build.gradle.kts index cbd954cd22..d89ecb4b3d 100644 --- a/apps/flipcash/shared/session/build.gradle.kts +++ b/apps/flipcash/shared/session/build.gradle.kts @@ -9,7 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) - testImplementation(testFixtures(project(":libs:coroutines"))) + testImplementation(project(":libs:coroutines:test-fixtures")) testImplementation(testFixtures(project(":ui:resources"))) implementation(project(":apps:flipcash:shared:blob")) diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 256dd56494..2d16a1a8e8 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -49,6 +49,10 @@ gradlePlugin { id = "flipcash.kmp.library" implementationClass = "KmpLibraryConventionPlugin" } + register("kmpAppLibrary") { + id = "flipcash.kmp.app.library" + implementationClass = "KmpAppLibraryConventionPlugin" + } register("kmpTestFixtures") { id = "flipcash.kmp.test.fixtures" implementationClass = "KmpTestFixturesConventionPlugin" diff --git a/build-logic/convention/src/main/kotlin/KmpAppLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpAppLibraryConventionPlugin.kt new file mode 100644 index 0000000000..dd47da8950 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/KmpAppLibraryConventionPlugin.kt @@ -0,0 +1,129 @@ +import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.plugins.ExtensionAware +import org.gradle.api.tasks.testing.Test +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +/** + * Convention plugin for app-layer KMP modules that build for Android and the desktop JVM only. + * + * Unlike `flipcash.kmp.library` it declares no Kotlin/Native targets: these modules sit on Android + * and JVM libraries (gRPC, Room, DataStore, OkHttp) with no native counterpart. Because every target + * is a JVM, `commonMain` sees the JDK, so code that only needs `java.*` moves there unchanged and + * only `android.*` uses need an `expect`/`actual` or an interface. + * + * Applies: + * - `org.jetbrains.kotlin.multiplatform` + * - `com.android.kotlin.multiplatform.library` + * - `org.jetbrains.kotlin.plugin.serialization` + * + * and declares: + * - `android` -- compileSdk and minSdk from the version catalog, host tests enabled with the same + * options `flipcash.android.library` sets on unit tests + * - `jvm` -- the desktop client + * + * The AGP KMP library target is single-variant: it has no `buildTypes` and no `BuildConfig`, so a + * value that differed between debug and release must come from the app at runtime. + * + * Usage in a module's `build.gradle.kts`: + * ``` + * plugins { + * alias(libs.plugins.flipcash.kmp.app.library) + * } + * + * kotlin { + * android { + * namespace = "com.example" + * } + * } + * ``` + */ +class KmpAppLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("org.jetbrains.kotlin.multiplatform") + apply("com.android.kotlin.multiplatform.library") + apply("org.jetbrains.kotlin.plugin.serialization") + } + + val libs = extensions.getByType().named("libs") + val compileSdkVersion = libs.findVersion("android-compileSdk").get().requiredVersion.toInt() + val compileSdkMinorVersion = libs.findVersion("android-compileSdkMinor").get().requiredVersion.toInt() + val minSdkVersion = libs.findVersion("android-minSdk").get().requiredVersion.toInt() + val javaVersion = libs.findVersion("android-java").get().requiredVersion + + extensions.configure { + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(javaVersion)) + } + + (this as ExtensionAware).extensions.configure { + compileSdk { + version = release(compileSdkVersion) { + minorApiLevel = compileSdkMinorVersion + } + } + minSdk = minSdkVersion + withHostTestBuilder {}.configure { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(javaVersion)) + } + } + + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(javaVersion)) + } + } + + compilerOptions { + optIn.addAll( + "kotlin.time.ExperimentalTime", + "kotlin.ExperimentalUnsignedTypes", + "kotlin.RequiresOptIn" + ) + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + sourceSets.named("commonMain") { + dependencies { + implementation(libs.findLibrary("kotlinx-coroutines-core").get()) + } + } + sourceSets.named("androidMain") { + dependencies { + implementation(libs.findLibrary("timber").get()) + } + } + sourceSets.named("androidHostTest") { + dependencies { + implementation(libs.findLibrary("kotlin-test-junit").get()) + // Provides robolectric.properties, which pins the Robolectric SDK level. + implementation(project(":libs:test-utils")) + } + } + sourceSets.named("jvmTest") { + dependencies { + implementation(libs.findLibrary("kotlin-test-junit").get()) + } + } + } + + tasks.withType { + failOnNoDiscoveredTests.set(false) + maxHeapSize = "2g" + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ff83cf456c..55493d0055 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -349,6 +349,7 @@ 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" } +flipcash-kmp-app-library = { id = "flipcash.kmp.app.library" } flipcash-kmp-test-fixtures = { id = "flipcash.kmp.test.fixtures" } flipcash-analytics-catalogue = { id = "flipcash.analytics.catalogue" } android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/libs/coroutines/build.gradle.kts b/libs/coroutines/build.gradle.kts index 0be0cc0f8c..3d32a19f24 100644 --- a/libs/coroutines/build.gradle.kts +++ b/libs/coroutines/build.gradle.kts @@ -1,15 +1,9 @@ plugins { - alias(libs.plugins.flipcash.android.library) - id("org.jetbrains.kotlin.plugin.parcelize") + alias(libs.plugins.flipcash.kmp.app.library) } -android { - namespace = "${Gradle.codeNamespace}.libs.coroutines" - testFixtures { - enable = true +kotlin { + android { + namespace = "${Gradle.codeNamespace}.libs.coroutines" } } - -dependencies { - testFixturesImplementation(libs.kotlinx.coroutines.test) -} diff --git a/libs/coroutines/src/main/kotlin/com/flipcash/libs/coroutines/DispatcherProvider.kt b/libs/coroutines/src/commonMain/kotlin/com/flipcash/libs/coroutines/DispatcherProvider.kt similarity index 100% rename from libs/coroutines/src/main/kotlin/com/flipcash/libs/coroutines/DispatcherProvider.kt rename to libs/coroutines/src/commonMain/kotlin/com/flipcash/libs/coroutines/DispatcherProvider.kt diff --git a/libs/coroutines/test-fixtures/build.gradle.kts b/libs/coroutines/test-fixtures/build.gradle.kts new file mode 100644 index 0000000000..bdc3f43d24 --- /dev/null +++ b/libs/coroutines/test-fixtures/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.flipcash.kmp.app.library) +} + +kotlin { + android { + namespace = "${Gradle.codeNamespace}.libs.coroutines.testfixtures" + } + + sourceSets { + commonMain.dependencies { + api(project(":libs:coroutines")) + implementation(libs.kotlinx.coroutines.test) + } + } +} diff --git a/libs/coroutines/src/testFixtures/kotlin/com/flipcash/libs/coroutines/TestDispatcherProvider.kt b/libs/coroutines/test-fixtures/src/commonMain/kotlin/com/flipcash/libs/coroutines/TestDispatcherProvider.kt similarity index 100% rename from libs/coroutines/src/testFixtures/kotlin/com/flipcash/libs/coroutines/TestDispatcherProvider.kt rename to libs/coroutines/test-fixtures/src/commonMain/kotlin/com/flipcash/libs/coroutines/TestDispatcherProvider.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index 1a7f302c17..ce47d2b015 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -200,6 +200,7 @@ include( ":libs:code-detection", ":libs:codes:kikcode", ":libs:coroutines", + ":libs:coroutines:test-fixtures", ":libs:crypto:kin", ":libs:crypto:solana", ":libs:currency",