diff --git a/.gitignore b/.gitignore index c0c2bab..6a1da5a 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,18 @@ lib*.a # Python ck-tap emulator /emulator_env/ *.log + +# Android +cktap-android/.gradle/ +cktap-android/.kotlin/ +cktap-android/build/ +cktap-android/lib/build/ +cktap-android/lib/src/main/jniLibs/ +cktap-android/lib/src/main/kotlin/ +cktap-android/local.properties +*.so + +# Credentials / signing (must never be committed) +secring.gpg +*.asc +*.gpg diff --git a/Cargo.toml b/Cargo.toml index b5f9283..406c7d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,4 +15,8 @@ opt-level = 'z' lto = true codegen-units = 1 panic = "abort" -strip = true \ No newline at end of file +# NOTE: do not set `strip = true` here. uniffi-bindgen's `--library` mode reads +# UNIFFI_META_* symbols from the compiled .so to discover the interface, and +# stripping at compile time removes them. Strip the copies that ship in the +# Android AAR explicitly (e.g. with `llvm-strip`) after bindings have been +# generated — see cktap-android/scripts/release/build-release-*.sh. \ No newline at end of file diff --git a/cktap-android/README.md b/cktap-android/README.md new file mode 100644 index 0000000..40b4c6b --- /dev/null +++ b/cktap-android/README.md @@ -0,0 +1,200 @@ +# cktap-android + +This project builds an `.aar` package for the Android platform that provides Kotlin language bindings for the [rust-cktap] library. The Kotlin bindings are generated by the [`cktap-ffi`](../cktap-ffi) crate using [uniffi-rs]. + +## How it works + +`cktap-android` is a thin Android packaging layer on top of the Rust implementation. The stack looks like this: + +``` +Android app (Kotlin) + └─ cktap-android Kotlin bindings + prebuilt native libs, shipped as an .aar + └─ cktap-ffi Rust crate that exports the UniFFI scaffolding (cdylib → libcktap_ffi.so) + └─ rust-cktap Pure-Rust implementation of the Coinkite Tap Protocol (SATSCARD, TAPSIGNER, SATSCHIP) +``` + +What the build produces: + +- **`libcktap_ffi.so`** — the Rust library compiled for each supported Android ABI (`arm64-v8a`, `armeabi-v7a`, `x86_64`), placed under `lib/src/main/jniLibs//`. +- **`cktap_ffi.kt`** — the Kotlin API surface auto-generated by UniFFI from the Rust signatures in `cktap-ffi`, placed under `lib/src/main/kotlin/com/coinkite/cktap/`. +- **`cktap-android-.aar`** — the final artifact that bundles the generated Kotlin sources together with the native libraries. It depends on [JNA](https://github.com/java-native-access/jna) at runtime to dispatch Kotlin calls through JNI into the Rust functions. + +Consumers see plain Kotlin types (`suspend` functions, typed exceptions, data classes) and never interact with the JNI bridge directly — UniFFI handles marshalling in both directions. + +## How to Use + +Once published to Maven Central, add the following to your Android project's `build.gradle.kts`: + +```kotlin +repositories { + mavenCentral() +} + +dependencies { + implementation("org.bitcoindevkit:cktap-android:") +} +``` + +### Snapshot releases + +To use a snapshot release, add the snapshot repository: + +```kotlin +repositories { + maven("https://s01.oss.sonatype.org/content/repositories/snapshots/") +} + +dependencies { + implementation("org.bitcoindevkit:cktap-android:-SNAPSHOT") +} +``` + +### Local Maven (`~/.m2/repository`) + +During development — or until an artifact is published to Maven Central — consume the library from your local Maven repository. Declare `mavenLocal()` **before** `mavenCentral()` so Gradle prefers the locally built snapshot and falls back to Maven Central automatically once a release is available: + +```kotlin +repositories { + mavenLocal() + mavenCentral() +} + +dependencies { + implementation("org.bitcoindevkit:cktap-android:0.1.0-SNAPSHOT") +} +``` + +See [How to publish to your local Maven repository](#how-to-publish-to-your-local-maven-repository) below for the publish flow. + +## How to build locally + +_Note: Kotlin `2.3.10`+, JDK 17, Android SDK API 34+, and Android NDK `27.2.12479018`+ are required._ + +1. Clone this repository: + ```shell + git clone https://github.com/notmandatory/rust-cktap + ``` + +2. Set up environment variables for Android SDK and NDK: + ```shell + # macOS + export ANDROID_SDK_ROOT=~/Library/Android/sdk + export ANDROID_NDK_ROOT=$ANDROID_SDK_ROOT/ndk/27.2.12479018 + ``` + +3. Build the native libraries and Kotlin bindings: + ```shell + cd cktap-android + just build macos-aarch64 + ``` + +4. (Optional) Run instrumented tests on a connected emulator/device: + ```shell + just test + ``` + +## How to publish to your local Maven repository + +This flow works **without any signing keys or Sonatype credentials** and is ideal for local development and testing. + +### Prerequisites + +- [Rust toolchain](https://rustup.rs/) (`cargo`, `rustup`) +- [`just`](https://github.com/casey/just) (`brew install just` on macOS) +- JDK 17 +- Android SDK API 34+ and Android NDK `27.2.12479018`+ (install via Android Studio → SDK Manager → SDK Tools → NDK) +- `local.properties` inside `cktap-android/` pointing at the SDK — either create the file with `sdk.dir=/path/to/Android/sdk` or export `ANDROID_SDK_ROOT` + +### Steps + +1. Export the SDK/NDK environment variables (adjust the NDK version to match what is installed): + + ```shell + export ANDROID_SDK_ROOT=~/Library/Android/sdk + export ANDROID_NDK_ROOT=$ANDROID_SDK_ROOT/ndk/27.2.12479018 + ``` + +2. Build the Rust library and generate the Kotlin bindings for your host ABI: + + ```shell + cd cktap-android + just build macos-aarch64 + ``` + + This compiles `libcktap_ffi.so` for `arm64-v8a` and regenerates `lib/src/main/kotlin/com/coinkite/cktap/cktap_ffi.kt` via UniFFI. + +3. Publish the AAR to your local Maven repository: + + ```shell + just publish-local # alias for: ./gradlew publishToMavenLocal -P localBuild + ``` + + The `-P localBuild` flag disables GPG signing so no keys or Sonatype credentials are required. The AAR, sources JAR, and POM metadata are deposited under `~/.m2/repository/org/bitcoindevkit/cktap-android/0.1.0-SNAPSHOT/`. + +Because the version ends in `-SNAPSHOT`, Gradle revalidates the local Maven cache on every build in consumer projects — re-run `just publish-local` after any change and the next consumer build will pick up the fresh copy automatically. + +### Consuming the local build + +```kotlin +repositories { + mavenLocal() + mavenCentral() +} + +dependencies { + implementation("org.bitcoindevkit:cktap-android:0.1.0-SNAPSHOT") +} +``` + +## How to publish to Maven Central (official release) + +This flow **requires signing keys and Sonatype credentials**. Configure them in `~/.gradle/gradle.properties` (outside the repo): + +```properties +# Sonatype Central Publisher Portal tokens +mavenCentralUsername= +mavenCentralPassword= + +# GPG signing +signing.gnupg.keyName= +signing.gnupg.passphrase= +``` + +Then: + +```shell +# 1. Update version in lib/build.gradle.kts (remove -SNAPSHOT) +# 2. Build native libraries for all supported ABIs +just build macos-aarch64 + +# 3. Publish +just publish-central # alias for: ./gradlew publishAndReleaseToMavenCentral + +# 4. Tag and push +git tag v0.1.0 && git push --tags +``` + +## Known issues + +### JNA dependency + +Depending on the JVM version used by your consumer project, JNA may not be on the classpath. If you see: + +``` +class file for com.sun.jna.Pointer not found +``` + +Add JNA explicitly: + +```kotlin +dependencies { + implementation("net.java.dev.jna:jna:5.14.0@aar") +} +``` + +### x86 emulators + +The library currently ships native binaries for `arm64-v8a`, `armeabi-v7a`, and `x86_64`. It does **not** ship 32-bit `x86`. Use an `x86_64` emulator when testing on macOS/Linux x86 hosts. + +[rust-cktap]: https://github.com/notmandatory/rust-cktap +[uniffi-rs]: https://github.com/mozilla/uniffi-rs diff --git a/cktap-android/build.gradle.kts b/cktap-android/build.gradle.kts new file mode 100644 index 0000000..a1120c2 --- /dev/null +++ b/cktap-android/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("com.android.library").version("8.13.2").apply(false) + id("org.jetbrains.kotlin.android").version("2.3.10").apply(false) + id("org.jetbrains.dokka").version("2.1.0").apply(false) + id("com.vanniktech.maven.publish").version("0.36.0").apply(false) +} diff --git a/cktap-android/docs/DOKKA_LANDING.md b/cktap-android/docs/DOKKA_LANDING.md new file mode 100644 index 0000000..4553862 --- /dev/null +++ b/cktap-android/docs/DOKKA_LANDING.md @@ -0,0 +1,7 @@ +# Module cktap-android + +The [rust-cktap](https://github.com/notmandatory/rust-cktap) language bindings library for Kotlin on Android. + +This library exposes the Coinkite Tap Protocol (cktap) for use with [SATSCARD](https://satscard.com/), [TAPSIGNER](https://tapsigner.com/), and [SATSCHIP](https://satschip.com/) products via NFC. + +# Package org.bitcoindevkit.cktap diff --git a/cktap-android/gradle.properties b/cktap-android/gradle.properties new file mode 100644 index 0000000..d35d02a --- /dev/null +++ b/cktap-android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx1536m +android.useAndroidX=true +android.enableJetifier=true +kotlin.code.style=official +org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled +org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true diff --git a/cktap-android/gradle/wrapper/gradle-wrapper.jar b/cktap-android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/cktap-android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/cktap-android/gradle/wrapper/gradle-wrapper.properties b/cktap-android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f78a6 --- /dev/null +++ b/cktap-android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/cktap-android/gradlew b/cktap-android/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/cktap-android/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/cktap-android/gradlew.bat b/cktap-android/gradlew.bat new file mode 100644 index 0000000..25da30d --- /dev/null +++ b/cktap-android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/cktap-android/justfile b/cktap-android/justfile new file mode 100644 index 0000000..0709962 --- /dev/null +++ b/cktap-android/justfile @@ -0,0 +1,55 @@ +[group("Repo")] +[doc("Default command; list all available commands.")] +@list: + just --list --unsorted + +[group("Repo")] +[doc("Open repo on GitHub in your default browser.")] +repo: + open https://github.com/notmandatory/rust-cktap + +[group("Repo")] +[doc("Build the API docs.")] +docs: + ./gradlew :lib:dokkaGeneratePublicationHtml + +[group("Build")] +[doc("Build the library for given ARCH.")] +build ARCH="macos-aarch64": + bash ./scripts/release/build-release-{{ARCH}}.sh + +[group("Build")] +[doc("Build the library for a single architecture in development mode.")] +build-dev: + bash ./scripts/dev/build-dev-macos-aarch64.sh + +[group("Build")] +[doc("List available architectures for the build command.")] +@list-architectures: + echo "Available architectures:" + echo " - macos-aarch64" + +[group("Build")] +[doc("Remove all caches and previous build directories to start from scratch.")] +clean: + rm -rf ../target/ + rm -rf ./build/ + rm -rf ./lib/build/ + rm -rf ./lib/src/main/kotlin/org/bitcoindevkit/cktap/* + rm -rf ./lib/src/main/kotlin/com/coinkite/cktap/* + rm -rf ./lib/src/main/jniLibs/* + +[group("Publish")] +[doc("Publish the library to your local Maven repository (no signing, no credentials needed).")] +publish-local: + ./gradlew publishToMavenLocal -P localBuild + +[group("Publish")] +[doc("Publish the library to Maven Central (requires signing key and Sonatype credentials).")] +publish-central: + ./gradlew publishAndReleaseToMavenCentral + +[group("Test")] +[doc("Run all connected device tests.")] +test: + ./gradlew connectedAndroidTest diff --git a/cktap-android/lib/build.gradle.kts b/cktap-android/lib/build.gradle.kts new file mode 100644 index 0000000..cf60b3e --- /dev/null +++ b/cktap-android/lib/build.gradle.kts @@ -0,0 +1,160 @@ +import com.vanniktech.maven.publish.AndroidSingleVariantLibrary +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.SourcesJar +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.dokka") + id("com.vanniktech.maven.publish") +} + +group = "org.bitcoindevkit" +version = "0.1.0-SNAPSHOT" + +android { + namespace = group.toString() + compileSdk = 34 + + defaultConfig { + minSdk = 24 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles(file("proguard-android-optimize.txt"), file("proguard-rules.pro")) + } + } +} + +tasks.withType { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +// Fail fast if the Rust/UniFFI pipeline hasn't populated the generated Kotlin +// sources and native libraries. Without this, Gradle will happily assemble and +// publish an empty AAR from a clean checkout — see +// `scripts/release/build-release-*.sh` (or `scripts/dev/build-dev-*.sh`) for the +// step that must run first. +val verifyFfiArtifacts by tasks.registering { + group = "verification" + description = "Verifies generated Kotlin bindings and jniLibs exist before assembling the AAR." + doLast { + val kotlinSrc = file("src/main/kotlin") + val jniLibs = file("src/main/jniLibs") + val hasBindings = kotlinSrc.walkTopDown().any { it.isFile && it.extension == "kt" } + val hasNative = jniLibs.walkTopDown().any { it.isFile && it.name == "libcktap_ffi.so" } + if (!hasBindings || !hasNative) { + throw GradleException( + "Missing FFI artifacts. Run `just build ` (release) or " + + "`just build-dev` from cktap-android/ before assembling or publishing.\n" + + " kotlin bindings present: $hasBindings\n" + + " libcktap_ffi.so present: $hasNative" + ) + } + } +} + +tasks.matching { it.name == "preBuild" }.configureEach { + dependsOn(verifyFfiArtifacts) +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + +dependencies { + implementation("net.java.dev.jna:jna:5.14.0@aar") + implementation("androidx.appcompat:appcompat:1.4.0") + implementation("androidx.core:core-ktx:1.7.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + api("org.slf4j:slf4j-api:1.7.30") + + androidTestImplementation("com.github.tony19:logback-android:2.0.0") + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") + androidTestImplementation("org.jetbrains.kotlin:kotlin-test:1.6.10") + androidTestImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.6.10") +} + +mavenPublishing { + coordinates( + groupId = group.toString(), + artifactId = "cktap-android", + version = version.toString() + ) + + pom { + name.set("cktap-android") + description.set("Coinkite Tap Protocol language bindings for Android.") + url.set("https://github.com/bitcoindevkit/rust-cktap") + inceptionYear.set("2025") + licenses { + license { + name.set("APACHE 2.0") + url.set("https://github.com/bitcoindevkit/rust-cktap/blob/master/LICENSE-APACHE") + } + license { + name.set("MIT") + url.set("https://github.com/bitcoindevkit/rust-cktap/blob/master/LICENSE-MIT") + } + } + developers { + developer { + id.set("cktapdevelopers") + name.set("rust-cktap developers") + email.set("dev@bitcoindevkit.org") + } + } + scm { + url.set("https://github.com/bitcoindevkit/rust-cktap/") + connection.set("scm:git:github.com/bitcoindevkit/rust-cktap.git") + developerConnection.set("scm:git:ssh://github.com/bitcoindevkit/rust-cktap.git") + } + } + + configure( + AndroidSingleVariantLibrary( + javadocJar = JavadocJar.Dokka("dokkaGeneratePublicationHtml"), + sourcesJar = SourcesJar.Sources(), + variant = "release", + ) + ) + + publishToMavenCentral() + // Signing is only applied when NOT in localBuild mode AND GPG props are present. + // This keeps local development fully functional without any credentials. + // To enable signing (for Maven Central release), add to ~/.gradle/gradle.properties: + // signing.gnupg.keyName= + // signing.gnupg.passphrase= + if (!project.hasProperty("localBuild") + && project.findProperty("signing.gnupg.keyName") != null + ) { + signAllPublications() + } +} + +dokka { + moduleName.set("cktap-android") + moduleVersion.set(version.toString()) + dokkaSourceSets.main { + includes.from("../docs/DOKKA_LANDING.md") + sourceLink { + localDirectory.set(file("src/main/kotlin")) + remoteUrl("https://github.com/bitcoindevkit/rust-cktap") + remoteLineSuffix.set("#L") + } + } + pluginsConfiguration.html { + footerMessage.set("(c) rust-cktap Developers") + } +} diff --git a/cktap-android/lib/consumer-rules.pro b/cktap-android/lib/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/cktap-android/lib/proguard-rules.pro b/cktap-android/lib/proguard-rules.pro new file mode 100644 index 0000000..46c50e2 --- /dev/null +++ b/cktap-android/lib/proguard-rules.pro @@ -0,0 +1,6 @@ +# for JNA +-dontwarn java.awt.* +-keep class com.sun.jna.* { *; } +-keep class com.coinkite.cktap.* { *; } +-keepclassmembers class * extends com.coinkite.cktap.* { public *; } +-keepclassmembers class * extends com.sun.jna.* { public *; } diff --git a/cktap-android/lib/src/main/AndroidManifest.xml b/cktap-android/lib/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a2f47b6 --- /dev/null +++ b/cktap-android/lib/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/cktap-android/scripts/dev/build-dev-macos-aarch64.sh b/cktap-android/scripts/dev/build-dev-macos-aarch64.sh new file mode 100755 index 0000000..6c98d70 --- /dev/null +++ b/cktap-android/scripts/dev/build-dev-macos-aarch64.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +if [ -z "$ANDROID_NDK_ROOT" ]; then + echo "Error: ANDROID_NDK_ROOT is not defined in your environment" + exit 1 +fi + +PATH="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/darwin-x86_64/bin:$PATH" +CFLAGS="-D__ANDROID_MIN_SDK_VERSION__=24" +AR="llvm-ar" +LIB_NAME="libcktap_ffi.so" +FFI_PKG_NAME="cktap-ffi" +COMPILATION_TARGET_ARM64_V8A="aarch64-linux-android" +RESOURCE_DIR_ARM64_V8A="arm64-v8a" + +# Move to the Rust library directory +cd ../cktap-ffi/ || exit +rustup target add $COMPILATION_TARGET_ARM64_V8A + +# Build the binary (debug mode, single arch) +CC="aarch64-linux-android24-clang" CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="aarch64-linux-android24-clang" \ + cargo build --package ${FFI_PKG_NAME} --target $COMPILATION_TARGET_ARM64_V8A + +# Copy the binary to the resource directory +mkdir -p ../cktap-android/lib/src/main/jniLibs/$RESOURCE_DIR_ARM64_V8A/ +cp ../target/$COMPILATION_TARGET_ARM64_V8A/debug/$LIB_NAME ../cktap-android/lib/src/main/jniLibs/$RESOURCE_DIR_ARM64_V8A/ + +# Generate Kotlin bindings using cktap-uniffi-bindgen with android-specific config +cargo run --package ${FFI_PKG_NAME} --bin cktap-uniffi-bindgen generate \ + --library ../target/$COMPILATION_TARGET_ARM64_V8A/debug/$LIB_NAME \ + --language kotlin \ + --out-dir ../cktap-android/lib/src/main/kotlin/ \ + --config uniffi-android.toml \ + --no-format diff --git a/cktap-android/scripts/release/build-release-macos-aarch64.sh b/cktap-android/scripts/release/build-release-macos-aarch64.sh new file mode 100755 index 0000000..9ca00dd --- /dev/null +++ b/cktap-android/scripts/release/build-release-macos-aarch64.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +if [ -z "$ANDROID_NDK_ROOT" ]; then + echo "Error: ANDROID_NDK_ROOT is not defined in your environment" + exit 1 +fi + +PATH="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/darwin-x86_64/bin:$PATH" +CFLAGS="-D__ANDROID_MIN_SDK_VERSION__=24" +AR="llvm-ar" +LIB_NAME="libcktap_ffi.so" +FFI_PKG_NAME="cktap-ffi" +COMPILATION_TARGET_ARM64_V8A="aarch64-linux-android" +COMPILATION_TARGET_X86_64="x86_64-linux-android" +COMPILATION_TARGET_ARMEABI_V7A="armv7-linux-androideabi" +RESOURCE_DIR_ARM64_V8A="arm64-v8a" +RESOURCE_DIR_X86_64="x86_64" +RESOURCE_DIR_ARMEABI_V7A="armeabi-v7a" + +# Move to the Rust library directory +cd ../cktap-ffi/ || exit +rustup target add $COMPILATION_TARGET_ARM64_V8A $COMPILATION_TARGET_ARMEABI_V7A $COMPILATION_TARGET_X86_64 + +# Build the binaries +CC="aarch64-linux-android24-clang" CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="aarch64-linux-android24-clang" \ + cargo build --package ${FFI_PKG_NAME} --profile release-smaller --target $COMPILATION_TARGET_ARM64_V8A +CC="x86_64-linux-android24-clang" CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER="x86_64-linux-android24-clang" \ + cargo build --package ${FFI_PKG_NAME} --profile release-smaller --target $COMPILATION_TARGET_X86_64 +CC="armv7a-linux-androideabi24-clang" CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER="armv7a-linux-androideabi24-clang" \ + cargo build --package ${FFI_PKG_NAME} --profile release-smaller --target $COMPILATION_TARGET_ARMEABI_V7A + +# Copy the binaries to their respective resource directories +mkdir -p ../cktap-android/lib/src/main/jniLibs/$RESOURCE_DIR_ARM64_V8A/ +mkdir -p ../cktap-android/lib/src/main/jniLibs/$RESOURCE_DIR_ARMEABI_V7A/ +mkdir -p ../cktap-android/lib/src/main/jniLibs/$RESOURCE_DIR_X86_64/ +cp ../target/$COMPILATION_TARGET_ARM64_V8A/release-smaller/$LIB_NAME ../cktap-android/lib/src/main/jniLibs/$RESOURCE_DIR_ARM64_V8A/ +cp ../target/$COMPILATION_TARGET_ARMEABI_V7A/release-smaller/$LIB_NAME ../cktap-android/lib/src/main/jniLibs/$RESOURCE_DIR_ARMEABI_V7A/ +cp ../target/$COMPILATION_TARGET_X86_64/release-smaller/$LIB_NAME ../cktap-android/lib/src/main/jniLibs/$RESOURCE_DIR_X86_64/ + +# Generate Kotlin bindings using cktap-uniffi-bindgen with android-specific config. +# This must run BEFORE stripping, because `--library` mode reads UNIFFI_META_* +# symbols from the .so to discover the interface. +cargo run --package ${FFI_PKG_NAME} --bin cktap-uniffi-bindgen generate \ + --library ../target/$COMPILATION_TARGET_ARM64_V8A/release-smaller/$LIB_NAME \ + --language kotlin \ + --out-dir ../cktap-android/lib/src/main/kotlin/ \ + --config uniffi-android.toml \ + --no-format + +# Strip the copies shipped in the AAR to keep the artifact small. The originals +# under target/ are left intact so bindgen can be re-run without rebuilding. +llvm-strip ../cktap-android/lib/src/main/jniLibs/$RESOURCE_DIR_ARM64_V8A/$LIB_NAME +llvm-strip ../cktap-android/lib/src/main/jniLibs/$RESOURCE_DIR_ARMEABI_V7A/$LIB_NAME +llvm-strip ../cktap-android/lib/src/main/jniLibs/$RESOURCE_DIR_X86_64/$LIB_NAME diff --git a/cktap-android/settings.gradle.kts b/cktap-android/settings.gradle.kts new file mode 100644 index 0000000..df1943f --- /dev/null +++ b/cktap-android/settings.gradle.kts @@ -0,0 +1,17 @@ +rootProject.name = "cktap-android" + +include(":lib") + +pluginManagement { + repositories { + gradlePluginPortal() + google() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + } +} diff --git a/cktap-ffi/.cargo/config.toml b/cktap-ffi/.cargo/config.toml new file mode 100644 index 0000000..6adffde --- /dev/null +++ b/cktap-ffi/.cargo/config.toml @@ -0,0 +1,7 @@ +[target.'cfg(target_os = "android")'] +rustflags = [ + "-C", "link-arg=-z", + "-C", "link-arg=max-page-size=16384", + "-C", "link-arg=-z", + "-C", "link-arg=common-page-size=16384", +] diff --git a/cktap-ffi/uniffi-android.toml b/cktap-ffi/uniffi-android.toml new file mode 100644 index 0000000..41fac48 --- /dev/null +++ b/cktap-ffi/uniffi-android.toml @@ -0,0 +1,5 @@ +[bindings.kotlin] +package_name = "org.bitcoindevkit.cktap" +cdylib_name = "cktap_ffi" +android = true +android_cleaner = true diff --git a/cktap-ffi/uniffi.toml b/cktap-ffi/uniffi.toml new file mode 100644 index 0000000..322960b --- /dev/null +++ b/cktap-ffi/uniffi.toml @@ -0,0 +1,7 @@ +[bindings.kotlin] +package_name = "com.coinkite.cktap" +cdylib_name = "cktap_ffi" + +[bindings.swift] +module_name = "CKTap" +cdylib_name = "cktap_ffi" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index b3961ec..07e018a 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -14,5 +14,10 @@ targets = [ "aarch64-apple-ios", "x86_64-apple-ios", "aarch64-apple-ios-sim", + + # Android + "aarch64-linux-android", + "x86_64-linux-android", + "armv7-linux-androideabi", ] components = ["clippy", "rustfmt"] \ No newline at end of file