diff --git a/packages/kotlin-sdk/PUBLISHING.md b/packages/kotlin-sdk/PUBLISHING.md new file mode 100644 index 0000000000..caa3c6cdc3 --- /dev/null +++ b/packages/kotlin-sdk/PUBLISHING.md @@ -0,0 +1,87 @@ +# Publishing `org.dashj:dash-sdk-android` to Maven Central + +The SDK publishes under the existing **`org.dashj`** namespace (same as +`dashj-core`), so no new Central namespace verification is needed. This is a +Maven-coordinates decision only — the Kotlin source packages remain +`org.dashfoundation.dashsdk`. Deployment uses **JReleaser**, mirroring +[dashj/core/build.gradle](https://github.com/dashpay/dashj/blob/master/core/build.gradle): +release versions go to Maven Central via the Central Portal; `-SNAPSHOT` +versions go to the Sonatype snapshots repository. + +## Prerequisites + +- **Sonatype Central Portal token** for the `org.dashj` namespace (held by + HashEngineering — the same account that publishes `dashj-core`). +- **GPG signing key** (the dashj release key works), with the public key on the + keyservers. +- **Native toolchain**: Android NDK r28+ (`28.1.13356709`), `cargo-ndk`, rustup + targets `aarch64-linux-android` and `x86_64-linux-android`, JDK 17. + +## Credentials — environment only, never committed + +Gradle signing (signs artifacts during the staging publish; the classic +`signing.keyId` / `signing.password` / `signing.secretKeyRingFile` entries in +`~/.gradle/gradle.properties`, as used for dashj-core, also work): + +```bash +export ORG_GRADLE_PROJECT_signingKey="$(cat private-key.asc)" # ASCII-armored +export ORG_GRADLE_PROJECT_signingPassword="…" +``` + +JReleaser deploy (read only when a `jreleaser*` task runs): + +```bash +export JRELEASER_MAVENCENTRAL_SONATYPE_USERNAME="…" # Central Portal token name +export JRELEASER_MAVENCENTRAL_SONATYPE_PASSWORD="…" # Central Portal token value +export JRELEASER_NEXUS2_SNAPSHOTS_USERNAME="…" # same token (snapshots repo) +export JRELEASER_NEXUS2_SNAPSHOTS_PASSWORD="…" +export JRELEASER_GPG_PUBLIC_KEY="$(cat public-key.asc)" +export JRELEASER_GPG_SECRET_KEY="$(cat private-key.asc)" +export JRELEASER_GPG_PASSPHRASE="…" +``` + +## Release steps (from `packages/kotlin-sdk/`) + +```bash +# 1. Build the native JNI library (arm64-v8a + x86_64) and verify its exports. +./build_android.sh --verify + +# 2. Stage the signed release AAR + sources/javadoc jars into sdk/build/staging-deploy. +./gradlew :sdk:publishReleasePublicationToStagingRepository -PsdkVersion=0.1.0 +# (the staging dir sdk/build/staging-deploy is wiped automatically before every +# staging publish — cleanStagingDeploy — so stale versions from earlier runs +# can never reach JReleaser) + +# 3. Upload the staged artifacts. Release versions -> Maven Central; +# -SNAPSHOT versions -> Sonatype snapshots. +./gradlew :sdk:jreleaserDeploy -PsdkVersion=0.1.0 +``` + +Then approve/verify the deployment at and +confirm the artifact appears on search.maven.org (sync takes ~30 min). + +**Versioning:** the default is `0.1.0-SNAPSHOT`; pass the same +`-PsdkVersion=x.y.z` to steps 2 and 3. The first release should be a real +version (e.g. `0.1.0`) — the Android wallet's CI must depend on a pinned +release, never a snapshot. + +## The JNI guard + +`sdk/src/main/jniLibs/` is gitignored, so a clean checkout has no +`libdash_sdk_jni.so` — and an AAR published without it installs fine but dies +at runtime with `UnsatisfiedLinkError`. The `verifyJniLibsForRemotePublish` +task therefore **hard-fails** every staging/remote/jreleaser publish task when +the `.so` is missing for `arm64-v8a` or `x86_64`. There is no override for +remote publishes; `-PallowMissingJni` only downgrades the check for +`publishToMavenLocal` (metadata-only dry runs into `~/.m2`). If the guard +fires, run step 1. + +## Consuming the artifact + +```kotlin +repositories { mavenCentral() } +dependencies { implementation("org.dashj:dash-sdk-android:0.1.0") } +``` + +Requires `minSdk 29`. The AAR bundles the native library for `arm64-v8a` and +`x86_64`; `armeabi-v7a` is intentionally unsupported. diff --git a/packages/kotlin-sdk/gradle/libs.versions.toml b/packages/kotlin-sdk/gradle/libs.versions.toml index 67d6cace34..5bbdaaa7a3 100644 --- a/packages/kotlin-sdk/gradle/libs.versions.toml +++ b/packages/kotlin-sdk/gradle/libs.versions.toml @@ -20,6 +20,7 @@ androidxTestExtJunit = "1.2.1" androidxTestRunner = "1.6.2" espresso = "3.6.1" robolectric = "4.14.1" +jreleaser = "1.17.0" [libraries] kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } @@ -66,3 +67,4 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +jreleaser = { id = "org.jreleaser", version.ref = "jreleaser" } diff --git a/packages/kotlin-sdk/sdk/build.gradle.kts b/packages/kotlin-sdk/sdk/build.gradle.kts index 4a97e0c515..bdf8cf33a3 100644 --- a/packages/kotlin-sdk/sdk/build.gradle.kts +++ b/packages/kotlin-sdk/sdk/build.gradle.kts @@ -1,10 +1,30 @@ +import java.util.concurrent.Callable +import org.gradle.api.publish.maven.tasks.PublishToMavenLocal +import org.gradle.api.publish.maven.tasks.PublishToMavenRepository +import org.jreleaser.model.Active + plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) + `maven-publish` + id("signing") + alias(libs.plugins.jreleaser) } +// Published Maven coordinate group. Per HashEngineering's call on PR #4045: +// publish under the existing `org.dashj` group — Dash already owns dashj.org and +// ships its legacy Core/Platform SDKs there, so this needs no separate domain +// verification for Maven Central (`org.dashfoundation` would have, since +// dashfoundation.org is held by a third party). The Kotlin source packages stay +// `org.dashfoundation.dashsdk`; this is a Maven-coordinates decision only. +// `maven-publish` handles local / internal-repo publishing; jreleaser (bottom of +// file) handles Maven Central releases and Sonatype SNAPSHOTs, mirroring +// dashj/core/build.gradle. +group = "org.dashj" +version = project.findProperty("sdkVersion")?.toString() ?: "0.1.0-SNAPSHOT" + android { namespace = "org.dashfoundation.dashsdk" compileSdk = 35 @@ -35,6 +55,18 @@ android { // libdash_sdk_jni.so is produced by ../build_android.sh (cargo-ndk) into // src/main/jniLibs// — AGP packages it from there. NDK r28 emits // 16 KB-aligned ELF segments by default (Android 15+ requirement). + publishing { + singleVariant("release") { + withSourcesJar() + // Maven Central's Publisher Portal rejects any non-POM component that + // lacks a javadoc jar, and the snapshot deployer sets + // applyMavenCentralRules(true) which enforces it before upload + // (dashpay/platform#4045). AGP emits a (Kotlin-appropriate) javadoc jar + // here — no Dokka dependency required. + withJavadocJar() + } + } + packaging { jniLibs { useLegacyPackaging = false @@ -59,7 +91,11 @@ ksp { } dependencies { - implementation(libs.kotlinx.coroutines.core) + // `api`, not `implementation`: the public SDK surface returns coroutines + // types (Room DAO `Flow<…>`, `PlatformWalletManager.pendingIdentityKeys: + // StateFlow<…>`, etc.), so a consumer of the published coordinate needs + // coroutines-core on its own compile classpath. + api(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.serialization.json) @@ -83,3 +119,226 @@ dependencies { androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.room.testing) } + +// Publishes the release AAR (incl. libdash_sdk_jni.so if ../build_android.sh ran first) with a +// POM carrying the Room/DataStore/Biometric api dependencies. +// Local: ./gradlew :sdk:publishToMavenLocal Coordinates: org.dashj:dash-sdk-android: +// Remote: `:sdk:publishReleasePublicationToStagingRepository` stages the signed +// artifacts into build/staging-deploy, then `:sdk:jreleaserDeploy` uploads +// them to Maven Central (release) / Sonatype snapshots (see jreleaser block). +// Override the version with -PsdkVersion=x.y.z (defaults to 0.1.0-SNAPSHOT). +afterEvaluate { + publishing { + repositories { + // Local staging dir that jreleaser reads from and uploads (below); it is + // not itself a network repo, so staging never needs credentials. + maven { + name = "staging" + url = uri(layout.buildDirectory.dir("staging-deploy")) + } + } + + // The staging dir is reused across runs and maven-publish never removes + // other versions' directories — a release deploy after an earlier + // snapshot/release staging run would hand JReleaser stale coordinates. + // Wipe it before every staging publish so each deploy starts clean. + val cleanStagingDeploy = tasks.register("cleanStagingDeploy") { + delete(layout.buildDirectory.dir("staging-deploy")) + } + tasks.withType().configureEach { + if (name.endsWith("ToStagingRepository")) { + dependsOn(cleanStagingDeploy) + } + } + publications { + create("release") { + from(components["release"]) + groupId = "org.dashj" + artifactId = "dash-sdk-android" + pom { + name.set("Dash Platform Kotlin SDK") + description.set( + "Kotlin SDK for Dash Core (L1 SPV) and Dash Platform " + + "(identities, DPNS, DashPay, shielded balances)" + ) + url.set("https://github.com/dashpay/platform") + licenses { + license { + name.set("MIT License") + url.set("https://github.com/dashpay/platform/blob/master/LICENSE") + } + } + // scm + developers: required by Maven Central (and most + // remote repos) for POM validation. Pointed at the GitHub repo. + scm { + url.set("https://github.com/dashpay/platform") + connection.set("scm:git:git://github.com/dashpay/platform.git") + developerConnection.set( + "scm:git:ssh://git@github.com/dashpay/platform.git" + ) + } + developers { + developer { + id.set("dashpay") + name.set("Dash Platform Contributors") + url.set("https://github.com/dashpay/platform") + } + } + } + } + } + } + + // Sign published artifacts, but only for a real remote publish. `publishToMavenLocal` + // and a bare `assemble` never put a PublishToMavenRepository task in the graph, so a + // local build requires no GPG key. Keys come from the standard signing properties/env + // (signing.keyId + signing.password + signing.secretKeyRingFile, or the in-memory + // ORG_GRADLE_PROJECT_signingKey / signingPassword pair) when a remote publish runs. + signing { + // Wire the documented in-memory-key path: the ORG_GRADLE_PROJECT_signingKey + // / signingPassword env vars land as the Gradle project properties + // `signingKey` / `signingPassword`, but the signing plugin does not consume + // them automatically — a CI release following that path would otherwise fail + // in signReleasePublication before staging anything (dashpay/platform#4045). + // Absent (local dev), this is skipped and, with required=false below, no key + // is needed. The legacy signing.keyId/password/secretKeyRingFile path is + // untouched and still handled by Gradle's standard signing properties. + val signingKey = project.findProperty("signingKey") as String? + val signingPassword = project.findProperty("signingPassword") as String? + if (!signingKey.isNullOrBlank()) { + useInMemoryPgpKeys(signingKey, signingPassword) + } + setRequired(Callable { + gradle.taskGraph.allTasks.any { it is PublishToMavenRepository } + }) + sign(publishing.publications["release"]) + } +} + +// Never publish a coordinate whose AAR lacks the native JNI library +// (libdash_sdk_jni.so). ../build_android.sh (cargo-ndk) produces it into +// src/main/jniLibs//; that directory is gitignored and absent in a clean +// checkout, so without this guard a publish from a fresh tree yields an artifact +// that installs cleanly but dies at runtime with UnsatisfiedLinkError +// (NativeLoader.ensureLoaded → System.loadLibrary("dash_sdk_jni")). +val requiredJniAbis = listOf("arm64-v8a", "x86_64") // build_android.sh ABI policy + +fun missingJniAbis(): List = requiredJniAbis.filterNot { abi -> + file("src/main/jniLibs/$abi/libdash_sdk_jni.so").isFile +} + +fun jniMissingMessage(missing: List): String = + "Native JNI library libdash_sdk_jni.so is missing for ABI(s) " + + "${missing.joinToString()} under src/main/jniLibs/ — run ./build_android.sh " + + "before publishing so the coordinate isn't broken at runtime." + +// Remote / staging publish: ALWAYS hard-fail on a missing .so. `-PallowMissingJni` +// is deliberately NOT honored here — a nativeless AAR must never reach a network +// repo, nor jreleaser's build/staging-deploy dir (which `jreleaserDeploy` then +// uploads to Maven Central). This closes the escape where `-PallowMissingJni` +// previously downgraded the check to a warning for every publish task +// (dashpay/platform#4045, finding 8b899bc2e5e8). +val verifyJniLibsForRemotePublish = tasks.register("verifyJniLibsForRemotePublish") { + doLast { + val missing = missingJniAbis() + if (missing.isNotEmpty()) { + throw GradleException( + jniMissingMessage(missing) + + " -PallowMissingJni is a local-only escape and is NOT honored for " + + "remote/staging deploys." + ) + } + } +} + +// Local publish (`publishToMavenLocal`): a dev may pass `-PallowMissingJni` for a +// POM/metadata-only dry run into the local ~/.m2, which never leaves the machine. +val verifyJniLibsForLocalPublish = tasks.register("verifyJniLibsForLocalPublish") { + doLast { + val missing = missingJniAbis() + if (missing.isNotEmpty()) { + val message = jniMissingMessage(missing) + if (project.hasProperty("allowMissingJni")) { + logger.warn( + "WARNING: $message (continuing: -PallowMissingJni is set — local publish only.)" + ) + } else { + throw GradleException("$message Pass -PallowMissingJni for a local-only dry run.") + } + } + } +} + +tasks.withType().configureEach { + dependsOn(verifyJniLibsForRemotePublish) +} +tasks.withType().configureEach { + dependsOn(verifyJniLibsForLocalPublish) +} +// jreleaser's deploy/upload/release tasks push the already-staged artifacts, so +// gate them on the strict remote check too — a staging dir populated out-of-band +// (or by a future task rewiring) can't be uploaded without the native library. +tasks.matching { task -> + task.name.startsWith("jreleaser") && + listOf("Deploy", "Upload", "Release").any { task.name.contains(it) } +}.configureEach { dependsOn(verifyJniLibsForRemotePublish) } + +// Maven Central / Sonatype deployment via jreleaser, mirroring +// dashj/core/build.gradle (https://github.com/dashpay/dashj/blob/master/core/build.gradle#L139). +// Flow: `maven-publish` stages the signed artifacts into build/staging-deploy (the +// `staging` repository configured above), then `./gradlew :sdk:jreleaserDeploy` +// uploads them — release versions to Maven Central, -SNAPSHOT versions to the +// Sonatype snapshots repo. jreleaser reads its GPG key and Sonatype credentials +// from env/props (JRELEASER_GPG_*, JRELEASER_MAVENCENTRAL_*/JRELEASER_NEXUS2_*) +// at deploy time only, so `publishToMavenLocal` and any build that never invokes a +// jreleaser task need no signing key or account. +jreleaser { + project { + name.set("dash-sdk-android") + description.set( + "Kotlin SDK for Dash Core (L1 SPV) and Dash Platform " + + "(identities, DPNS, DashPay, shielded balances)" + ) + links { + homepage.set("https://github.com/dashpay/platform") + } + authors.set(listOf("Dash Platform Contributors")) + license.set("MIT") + gitRootSearch.set(true) + } + + signing { + active.set(Active.ALWAYS) + armored.set(true) + } + + deploy { + maven { + // Tagged release versions -> Maven Central via the Sonatype portal. + mavenCentral { + create("sonatype") { + active.set(Active.RELEASE) + url.set("https://central.sonatype.com/api/v1/publisher") + stagingRepository( + layout.buildDirectory.dir("staging-deploy").get().asFile.path + ) + } + } + // -SNAPSHOT versions -> Sonatype snapshots repository. + nexus2 { + create("snapshots") { + active.set(Active.SNAPSHOT) + url.set("https://central.sonatype.com/repository/maven-snapshots/") + snapshotUrl.set("https://central.sonatype.com/repository/maven-snapshots/") + applyMavenCentralRules.set(true) + snapshotSupported.set(true) + closeRepository.set(true) + releaseRepository.set(true) + stagingRepository( + layout.buildDirectory.dir("staging-deploy").get().asFile.path + ) + } + } + } + } +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/Sdk.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/Sdk.kt index 07900fa976..9c8063a73c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/Sdk.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/Sdk.kt @@ -124,8 +124,16 @@ class Sdk private constructor( * `discoverActiveMasternodes`) — a single strict field would fail the * whole devnet discovery. */ + // `internal`, not public: this is a wire-format DTO for the `/masternodes` + // response, touched only by the `private` [MasternodesEnvelope] and + // `internal` [parseActiveMasternodes] — never returned by a public function + // (public discovery returns the non-@Serializable [ActiveMasternode]). Keeping + // it out of the public ABI means its generated `serializer()` isn't public + // API, so consumers of the published coordinate don't need + // kotlinx-serialization on their compile classpath and it can stay + // `implementation` (dashpay/platform#4045, finding 96b7b2a236ff). @Serializable - data class MasternodeEntry( + internal data class MasternodeEntry( val address: String, val status: String = "", val platformHTTPPort: Int? = null,