diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18180a7..e69adb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,8 @@ jobs: - uses: actions/checkout@v7 - run: rustup toolchain install stable --profile minimal --component rustfmt - run: cargo +stable fmt --check + # The Android module's version, which the runtime handshake compares, follows the crate's. + - run: ci/check-versions.sh clippy: name: Clippy ${{ matrix.target }} @@ -394,14 +396,20 @@ jobs: (cd android && ./gradlew --no-daemon assembleDebug) # The module's build output stays out of the crate. test ! -e ../../android/build - - name: Pushes to the open app and to one whose process was killed + mkdir -p "$RUNNER_TEMP/apk" && cp android/app/build/outputs/apk/debug/app-debug.apk "$RUNNER_TEMP/apk/sources.apk" + - name: Build the same APK against the module published as the pushups-android AAR + run: | + ci/maven/publish.sh local + (cd examples/winit/android && ./gradlew --no-daemon clean assembleDebug -Ppushups.aar="$GITHUB_WORKSPACE/ci/maven/build/repo") + cp examples/winit/android/app/build/outputs/apk/debug/app-debug.apk "$RUNNER_TEMP/apk/aar.apk" + - name: Pushes to the open app and to one whose process was killed, with the module from sources and from the AAR uses: reactivecircus/android-emulator-runner@v2 with: api-level: 35 target: google_apis arch: x86_64 disable-animations: true - script: ci/android/example.sh examples/winit/android/app/build/outputs/apk/debug/app-debug.apk "$RUNNER_TEMP/fcm-service-account.json" android.app.NativeActivity + script: ci/android/example.sh "$RUNNER_TEMP/apk/sources.apk" "$RUNNER_TEMP/fcm-service-account.json" android.app.NativeActivity && ci/android/example.sh "$RUNNER_TEMP/apk/aar.apk" "$RUNNER_TEMP/fcm-service-account.json" android.app.NativeActivity example-tauri-android: name: Example Tauri app through tauri-plugin-pushups on an Android emulator diff --git a/.github/workflows/release-aar.yml b/.github/workflows/release-aar.yml new file mode 100644 index 0000000..aeab5a1 --- /dev/null +++ b/.github/workflows/release-aar.yml @@ -0,0 +1,39 @@ +name: Android AAR + +# A `v` tag publishes io.github.lucacappelletti94:pushups-android at that version to Maven +# Central. Run by hand, it uploads the same signed bundle for Central's validation and drops it. +"on": + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + aar: + name: Publish the Android module to Maven Central + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + - run: rustup toolchain install stable --profile minimal + - name: The tag, the crate and the Android module carry one version + if: startsWith(github.ref, 'refs/tags/') + run: ci/check-versions.sh "$GITHUB_REF_NAME" + - name: Build, sign and upload + env: + MAVEN_SIGNING_KEY: ${{ secrets.MAVEN_SIGNING_KEY }} + MAVEN_SIGNING_KEY_PASSWORD: ${{ secrets.MAVEN_SIGNING_KEY_PASSWORD }} + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + run: ci/maven/publish.sh ${{ startsWith(github.ref, 'refs/tags/') && 'release' || 'dry-run' }} diff --git a/README.md b/README.md index fbdb921..ee32f7f 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,16 @@ gradle.beforeProject { } ``` +The module is also on Maven Central as `io.github.lucacappelletti94:pushups-android`, released with every version of the crate, for a Gradle project that would rather add one dependency than include the sources. Its version must be the version of the `pushups` crate the app links, since the Kotlin calls into that crate's Rust. + +```kotlin +dependencies { + implementation("io.github.lucacappelletti94:pushups-android:0.1.0") +} +``` + +In every route the module checks at start that the app's `pushups` crate is its own version, and a mismatch, such as an AAR of one release beside a crate of another, leaves push off and makes `install` return `Error::AndroidModuleMismatch` naming both versions. + The app compiles its Firebase configuration in, from the `google-services.json` the Firebase console gives, with no Google services Gradle plugin. A process the push started with no UI can run Rust at once through a background handler. The push also waits on disk for the app's handler, which gets it when the UI opens. ```rust diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 81e0d65..facbc21 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -5,6 +5,11 @@ + + + diff --git a/android/src/main/kotlin/rs/pushups/Native.kt b/android/src/main/kotlin/rs/pushups/Native.kt index b410107..a4f3c63 100644 --- a/android/src/main/kotlin/rs/pushups/Native.kt +++ b/android/src/main/kotlin/rs/pushups/Native.kt @@ -5,6 +5,13 @@ import android.util.Log /** Rust-implemented exports in the app's cdylib, symbols Java_rs_pushups_Native_*. */ object Native { + /** + * Hands Rust this module's version and returns whether it equals the crate's, which every + * other export depends on. Its signature never changes, so any two versions can make this call. + */ + @JvmStatic + external fun handshake(moduleVersion: String): Boolean + @JvmStatic external fun init(context: Context, filesDir: String) diff --git a/android/src/main/kotlin/rs/pushups/PushupsProvider.kt b/android/src/main/kotlin/rs/pushups/PushupsProvider.kt index 4770ff4..9e2284c 100644 --- a/android/src/main/kotlin/rs/pushups/PushupsProvider.kt +++ b/android/src/main/kotlin/rs/pushups/PushupsProvider.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.ContentValues import android.database.Cursor import android.net.Uri +import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.SystemClock import android.util.Log @@ -22,32 +23,33 @@ class PushupsProvider : ContentProvider() { override fun onCreate(): Boolean { val appContext = context?.applicationContext ?: return false Pushups.context = appContext - val libName = resolveLibName(appContext) + val info = try { + appContext.packageManager.getPackageInfo( + appContext.packageName, + PackageManager.GET_ACTIVITIES or PackageManager.GET_META_DATA + ) + } catch (e: PackageManager.NameNotFoundException) { + Log.e(TAG, "package info", e) + return true + } + val libName = resolveLibName(info) if (libName == null) { Log.w(TAG, "no native library name, pushups stays off") return true } - if (!loadAndInit(appContext, libName)) return true + val moduleVersion = info.applicationInfo?.metaData?.getString("pushups.module_version") ?: "" + if (!loadAndInit(appContext, libName, moduleVersion)) return true initFirebase(appContext) (appContext as Application).registerActivityLifecycleCallbacks(ProcessState) return true } - private fun resolveLibName(appContext: Context): String? { - val info = try { - appContext.packageManager.getPackageInfo( - appContext.packageName, - PackageManager.GET_ACTIVITIES or PackageManager.GET_META_DATA - ) - } catch (e: PackageManager.NameNotFoundException) { - Log.e(TAG, "package info", e) - return null - } + private fun resolveLibName(info: PackageInfo): String? { info.applicationInfo?.metaData?.getString("pushups.lib_name")?.let { return it } return info.activities?.firstNotNullOfOrNull { it.metaData?.getString("android.app.lib_name") } } - private fun loadAndInit(appContext: Context, libName: String): Boolean { + private fun loadAndInit(appContext: Context, libName: String, moduleVersion: String): Boolean { var loaded = true try { val loadStart = SystemClock.elapsedRealtime() @@ -57,7 +59,7 @@ class PushupsProvider : ContentProvider() { Log.e(TAG, "load $libName", e) loaded = false } - if (loaded) { + if (loaded && handshake(libName, moduleVersion)) { try { Native.init(appContext, appContext.filesDir.absolutePath) ProcessState.loaded = true @@ -68,6 +70,19 @@ class PushupsProvider : ContentProvider() { return ProcessState.loaded } + /** + * Whether the loaded library's `pushups` is the version of this module. Another version's + * exports may differ, so on a mismatch no other export is called and `install` reports it. + */ + private fun handshake(libName: String, moduleVersion: String): Boolean = try { + Native.handshake(moduleVersion).also { matches -> + if (!matches) Log.e(TAG, "pushups module $moduleVersion does not match lib$libName.so, pushups stays off") + } + } catch (e: UnsatisfiedLinkError) { + Log.e(TAG, "lib$libName.so has no pushups of module $moduleVersion's version, pushups stays off", e) + false + } + private fun initFirebase(appContext: Context) { try { val values = FirebaseConfig.values(appContext.packageName) diff --git a/ci/android/run.sh b/ci/android/run.sh index dcbecf5..170ea0f 100755 --- a/ci/android/run.sh +++ b/ci/android/run.sh @@ -94,9 +94,13 @@ run_variant() { wake adb uninstall "$package" >/dev/null 2>&1 || true adb install -t "$build/pushups/outputs/apk/androidTest/debug/pushups-debug-androidTest.apk" >/dev/null + adb logcat -c timeout 900 adb shell am instrument -w -e class "rs.pushups.ci.$class" "${arguments[@]}" \ -e coverage true -e coverageFile "$files/jacoco.ec" "$package/androidx.test.runner.AndroidJUnitRunner" \ | tee "$out/$variant.txt" || true + # The device's side of a failure, which the runner's report does not show. + grep -q '^OK (' "$out/$variant.txt" || adb logcat -d -v time \ + | grep -E 'pushups|FirebaseMessaging|FirebaseApp|NotificationService|ActivityTaskManager|AndroidRuntime' | tail -300 adb exec-out run-as "$package" cat files/pushups.profraw > "$out/$variant.profraw" adb exec-out run-as "$package" cat files/jacoco.ec > "$out/$variant.ec" } @@ -114,6 +118,10 @@ run_variant unifiedpush-fallback "config-real background-handler" default Unifie adb shell pm enable io.heckel.ntfy >/dev/null # A tap into a running singleTop Activity, plain and androidx. run_variant single-top "config-real background-handler" single-top SingleTopTapTest +# A Kotlin module of another version than the crate, which the handshake stops before any other export. +run_variant module-mismatch config-real module-mismatch ModuleMismatchTest crate "$(sed -n 's/^version = "\(.*\)"$/\1/p' "$root/Cargo.toml" | head -1)" +# A library without the handshake, as one built with a crate older than the module. +run_variant no-handshake config-real no-handshake LibraryWithoutPushupsTest # Rust: every variant's profile against its own library, kept to the crate's own sources, with # paths relative to the repository. diff --git a/ci/android/src/androidTest/kotlin/rs/pushups/ci/LibraryWithoutPushupsTest.kt b/ci/android/src/androidTest/kotlin/rs/pushups/ci/LibraryWithoutPushupsTest.kt new file mode 100644 index 0000000..17babb5 --- /dev/null +++ b/ci/android/src/androidTest/kotlin/rs/pushups/ci/LibraryWithoutPushupsTest.kt @@ -0,0 +1,28 @@ +package rs.pushups.ci + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.AfterClass +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * An app whose named library has no `pushups` handshake, as a library built with a crate older + * than the module has none. The provider leaves push off without calling any other export, and + * `install`, from the probe the test loads itself, reports the module as never having started. + */ +@RunWith(AndroidJUnit4::class) +class LibraryWithoutPushupsTest { + + companion object { + @AfterClass + @JvmStatic + fun finish() = writeCoverage() + } + + @Test + fun pushStaysOffAndInstallReportsTheModuleMissing() { + System.loadLibrary("pushups_probe") + assertEquals("the pushups Android module is not in the app", Probe.install()) + } +} diff --git a/ci/android/src/androidTest/kotlin/rs/pushups/ci/ModuleMismatchTest.kt b/ci/android/src/androidTest/kotlin/rs/pushups/ci/ModuleMismatchTest.kt new file mode 100644 index 0000000..0e2ccb3 --- /dev/null +++ b/ci/android/src/androidTest/kotlin/rs/pushups/ci/ModuleMismatchTest.kt @@ -0,0 +1,33 @@ +package rs.pushups.ci + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.AfterClass +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * An app whose Kotlin module is another version than its Rust crate, as an AAR of one release + * next to a crate of another would be. The provider stops at the version handshake, and `install` + * names both versions instead of failing later in JNI. `run.sh` passes the crate's version as + * `crate`. + */ +@RunWith(AndroidJUnit4::class) +class ModuleMismatchTest { + + companion object { + @AfterClass + @JvmStatic + fun finish() = writeCoverage() + } + + @Test + fun installNamesBothVersions() { + val crate = InstrumentationRegistry.getArguments().getString("crate") + assertEquals( + "the pushups Android module is version 0.0.0-mismatch, but the app's pushups crate is $crate", + Probe.install(), + ) + } +} diff --git a/ci/android/src/androidTest/manifests/module-mismatch/AndroidManifest.xml b/ci/android/src/androidTest/manifests/module-mismatch/AndroidManifest.xml new file mode 100644 index 0000000..0450c01 --- /dev/null +++ b/ci/android/src/androidTest/manifests/module-mismatch/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/ci/android/src/androidTest/manifests/no-handshake/AndroidManifest.xml b/ci/android/src/androidTest/manifests/no-handshake/AndroidManifest.xml new file mode 100644 index 0000000..fa87f80 --- /dev/null +++ b/ci/android/src/androidTest/manifests/no-handshake/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/ci/check-versions.sh b/ci/check-versions.sh new file mode 100755 index 0000000..8484a75 --- /dev/null +++ b/ci/check-versions.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Fails unless the Android module's `pushups.module_version` equals the crate's version, which the +# version handshake compares at runtime. With a tag argument (`v`), the tag must equal it too. +# +# Usage: ci/check-versions.sh [tag] +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +crate=$(cd "$root" && cargo pkgid -p pushups | sed 's/.*[#@]//') +module=$(sed -n '/pushups.module_version/{n;s/.*android:value="\([^"]*\)".*/\1/p}' "$root/android/src/main/AndroidManifest.xml") +echo "crate $crate, Android module $module${1:+, tag $1}" +[ "$module" = "$crate" ] || { echo "android/src/main/AndroidManifest.xml says $module, Cargo.toml says $crate" >&2; exit 1; } +[ -z "${1:-}" ] || [ "$1" = "v$crate" ] || { echo "tag $1 is not v$crate" >&2; exit 1; } diff --git a/ci/maven/.gitignore b/ci/maven/.gitignore new file mode 100644 index 0000000..3d0fb34 --- /dev/null +++ b/ci/maven/.gitignore @@ -0,0 +1,3 @@ +/build +/.gradle +/.kotlin diff --git a/ci/maven/build.gradle.kts b/ci/maven/build.gradle.kts new file mode 100644 index 0000000..cbdc700 --- /dev/null +++ b/ci/maven/build.gradle.kts @@ -0,0 +1,91 @@ +import com.android.build.api.dsl.LibraryExtension + +// The versions the module is built with in `ci/android`, so the AAR's Kotlin metadata stays +// readable by the Kotlin 2.0 that `dx` 0.7 apps compile with. +plugins { + id("com.android.library") version "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "2.0.20" apply false +} + +val ci = layout.projectDirectory +// The crate's version, which the AAR always carries. +val crateVersion = ci.file("../../Cargo.toml").asFile.readLines() + .first { it.startsWith("version = ") } + .substringAfter('"').substringBefore('"') + +project(":pushups") { + // Keeps build output out of the crate's own `android/` directory. + layout.buildDirectory.set(ci.dir("build/pushups")) + apply(plugin = "maven-publish") + apply(plugin = "signing") + + pluginManager.withPlugin("com.android.library") { + extensions.configure { + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } + } + // Registered once AGP is applied, so it runs after AGP's own hook creates the component. + afterEvaluate { configurePublication() } + } +} + +/** The publication of the module's release variant, its POM, the bundle repository and the signing. */ +fun Project.configurePublication() { + run { + extensions.configure { + publications { + create("release") { + from(components["release"]) + groupId = "io.github.lucacappelletti94" + artifactId = "pushups-android" + version = crateVersion + pom { + name.set("pushups-android") + description.set( + "The Android module of the pushups Rust crate: FCM and UnifiedPush services handing pushes to the app's Rust. Use it with the pushups crate of the same version." + ) + url.set("https://github.com/LucaCappelletti94/pushups") + licenses { + license { + name.set("MIT") + url.set("https://github.com/LucaCappelletti94/pushups/blob/main/LICENSE") + } + } + developers { + developer { + id.set("LucaCappelletti94") + name.set("Luca Cappelletti") + email.set("cappelletti.luca94@gmail.com") + } + } + scm { + url.set("https://github.com/LucaCappelletti94/pushups") + connection.set("scm:git:https://github.com/LucaCappelletti94/pushups.git") + developerConnection.set("scm:git:ssh://git@github.com/LucaCappelletti94/pushups.git") + } + } + } + } + repositories { + // The bundle `publish.sh` uploads to Maven Central, and the repository apps test against. + maven { + name = "bundle" + url = uri(ci.dir("build/repo")) + } + } + } + + // Signed when `publish.sh` passes the key, as Central requires. Local proofs run unsigned. + val key = providers.environmentVariable("MAVEN_SIGNING_KEY").orNull + if (key != null) { + extensions.configure { + useInMemoryPgpKeys(key, providers.environmentVariable("MAVEN_SIGNING_KEY_PASSWORD").get()) + sign(extensions.getByType().publications["release"]) + } + } + } +} diff --git a/ci/maven/gradle.properties b/ci/maven/gradle.properties new file mode 100644 index 0000000..2e11322 --- /dev/null +++ b/ci/maven/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/ci/maven/gradle/wrapper/gradle-wrapper.jar b/ci/maven/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/ci/maven/gradle/wrapper/gradle-wrapper.jar differ diff --git a/ci/maven/gradle/wrapper/gradle-wrapper.properties b/ci/maven/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2e11132 --- /dev/null +++ b/ci/maven/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/ci/maven/gradlew b/ci/maven/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/ci/maven/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# 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/platforms/jvm/plugins-application/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 -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || 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 + + + +# 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" ) + + 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, 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# 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/ci/maven/publish.sh b/ci/maven/publish.sh new file mode 100755 index 0000000..0c309bd --- /dev/null +++ b/ci/maven/publish.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Builds the Android module as io.github.lucacappelletti94:pushups-android at the crate's version +# into ci/maven/build/repo, and with `dry-run` or `release` uploads it to Maven Central. +# +# Usage: ci/maven/publish.sh local the repository only, unsigned, for apps to build against +# ci/maven/publish.sh dry-run signed, uploaded for validation, then dropped unpublished +# ci/maven/publish.sh release signed, uploaded and published once Central validates it +# +# dry-run and release need MAVEN_SIGNING_KEY, MAVEN_SIGNING_KEY_PASSWORD, MAVEN_CENTRAL_USERNAME +# and MAVEN_CENTRAL_PASSWORD (the Central Portal user token). +set -euo pipefail +mode=${1:?local, dry-run or release} +here=$(cd "$(dirname "$0")" && pwd) +api=https://central.sonatype.com/api/v1/publisher +# Validation takes minutes, publishing up to half an hour. +STATUS_BOUND_S=2700 + +case $mode in + local) unset MAVEN_SIGNING_KEY ;; + dry-run | release) : "${MAVEN_SIGNING_KEY:?}" "${MAVEN_SIGNING_KEY_PASSWORD:?}" "${MAVEN_CENTRAL_USERNAME:?}" "${MAVEN_CENTRAL_PASSWORD:?}" ;; + *) echo "unknown mode $mode" >&2; exit 2 ;; +esac + +"$here/../check-versions.sh" +rm -rf "$here/build/repo" +(cd "$here" && ./gradlew --no-daemon --quiet :pushups:publishReleasePublicationToBundleRepository) +[ "$mode" = local ] && exit 0 + +version=$(cd "$here/../.." && cargo pkgid -p pushups | sed 's/.*[#@]//') +bundle=$here/build/pushups-android-$version.zip +# Central's bundle is the Maven layout with signatures and checksums, without repository metadata. +(cd "$here/build/repo" && rm -f "$bundle" && find io -type f ! -name 'maven-metadata.xml*' | sort | zip -q "$bundle" -@) +token=$(printf '%s:%s' "$MAVEN_CENTRAL_USERNAME" "$MAVEN_CENTRAL_PASSWORD" | base64 -w0) +[ -z "${GITHUB_ACTIONS:-}" ] || echo "::add-mask::$token" +type=AUTOMATIC +[ "$mode" = dry-run ] && type=USER_MANAGED +id=$(curl -fsS -m 300 -H "Authorization: Bearer $token" -F "bundle=@$bundle" \ + "$api/upload?publishingType=$type&name=pushups-android-$version") +echo "deployment $id ($type)" + +deadline=$((SECONDS + STATUS_BOUND_S)) +while [ $SECONDS -lt $deadline ]; do + status=$(curl -fsS -m 60 -X POST -H "Authorization: Bearer $token" "$api/status?id=$id") + state=$(printf '%s' "$status" | python3 -c 'import json,sys; print(json.load(sys.stdin)["deploymentState"])') + echo "state $state" + case $state in + FAILED) + printf '%s\n' "$status" >&2 + exit 1 + ;; + VALIDATED) + if [ "$mode" = dry-run ]; then + curl -fsS -m 60 -X DELETE -H "Authorization: Bearer $token" "$api/deployment/$id" + echo "validated and dropped, nothing published" + exit 0 + fi + ;; + PUBLISHED) + echo "published io.github.lucacappelletti94:pushups-android:$version" + exit 0 + ;; + esac + sleep 15 +done +echo "deployment $id still not done after $STATUS_BOUND_S s" >&2 +exit 1 diff --git a/ci/maven/settings.gradle.kts b/ci/maven/settings.gradle.kts new file mode 100644 index 0000000..4e3cbd7 --- /dev/null +++ b/ci/maven/settings.gradle.kts @@ -0,0 +1,20 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "pushups-maven" + +// The crate's Gradle module, published from here so the module itself carries no publishing setup. +include(":pushups") +project(":pushups").projectDir = file("../../android") diff --git a/examples/winit/android/app/build.gradle.kts b/examples/winit/android/app/build.gradle.kts index b3514bc..8db80e2 100644 --- a/examples/winit/android/app/build.gradle.kts +++ b/examples/winit/android/app/build.gradle.kts @@ -22,5 +22,9 @@ android { } dependencies { - implementation(project(":pushups")) + if (providers.gradleProperty("pushups.aar").isPresent) { + implementation("io.github.lucacappelletti94:pushups-android:${gradle.extensions.extraProperties["pushupsVersion"]}") + } else { + implementation(project(":pushups")) + } } diff --git a/examples/winit/android/settings.gradle.kts b/examples/winit/android/settings.gradle.kts index f669654..2104553 100644 --- a/examples/winit/android/settings.gradle.kts +++ b/examples/winit/android/settings.gradle.kts @@ -18,17 +18,25 @@ include(":app") // The Android module of the `pushups` crate cargo resolved for the app's Rust library, found // through `cargo metadata`, so the Kotlin always comes from the crate version the Rust links. -val pushupsDir = run { +val pushups = run { val metadata = providers.exec { commandLine("cargo", "metadata", "--format-version", "1", "--manifest-path", file("../Cargo.toml").path) }.standardOutput.asText.get() @Suppress("UNCHECKED_CAST") val packages = (groovy.json.JsonSlurper().parseText(metadata) as Map)["packages"] as List> - File(packages.single { it["name"] == "pushups" }["manifest_path"] as String).parentFile + packages.single { it["name"] == "pushups" } } -include(":pushups") -project(":pushups").projectDir = pushupsDir.resolve("android") -// Keeps the module's build output out of the crate's directory, which cargo's registry shares. -gradle.beforeProject { - if (path == ":pushups") layout.buildDirectory.set(rootDir.resolve("build/pushups")) +// `app` depends on the AAR of the same version when `-Ppushups.aar=` names +// where it is published, else on the module's sources. +gradle.extensions.extraProperties["pushupsVersion"] = pushups["version"] +val aar = providers.gradleProperty("pushups.aar").orNull +if (aar != null) { + dependencyResolutionManagement.repositories.maven { url = uri(aar) } +} else { + include(":pushups") + project(":pushups").projectDir = File(pushups["manifest_path"] as String).parentFile.resolve("android") + // Keeps the module's build output out of the crate's directory, which cargo's registry shares. + gradle.beforeProject { + if (path == ":pushups") layout.buildDirectory.set(rootDir.resolve("build/pushups")) + } } diff --git a/src/android.rs b/src/android.rs index 1cc1f84..8d7f9bb 100644 --- a/src/android.rs +++ b/src/android.rs @@ -42,10 +42,19 @@ static VAPID: OnceLock = OnceLock::new(); static PERMISSIONS: Mutex>> = Mutex::new(BTreeMap::new()); static NEXT_PERMISSION: AtomicI64 = AtomicI64::new(0); +/// The module version the handshake saw, when it differed from the crate's. +static MISMATCHED_MODULE: OnceLock = OnceLock::new(); + const QUEUE_FILE: &str = "pushups-queue"; fn runtime() -> Result<&'static Runtime, Error> { - RUNTIME.get().ok_or(Error::AndroidModuleMissing) + RUNTIME.get().ok_or_else(|| match MISMATCHED_MODULE.get() { + Some(module) => Error::AndroidModuleMismatch { + module: module.clone(), + crate_version: env!("CARGO_PKG_VERSION").to_owned(), + }, + None => Error::AndroidModuleMissing, + }) } pub(crate) struct Android; @@ -127,6 +136,25 @@ fn receive(entry: Entry) { DISPATCHER.flush(); } +/// `Native.handshake(moduleVersion)`, from `PushupsProvider.onCreate` before any other export. +/// Whether the Kotlin module is this crate's version. Its signature never changes, so a module +/// and a crate of any two versions can make this call. +#[unsafe(no_mangle)] +pub extern "system" fn Java_rs_pushups_Native_handshake<'caller>( + mut unowned: EnvUnowned<'caller>, + _class: JClass<'caller>, + module_version: JString<'caller>, +) -> jboolean { + let module = unowned + .with_env(|env| -> jni::errors::Result { module_version.try_to_string(env) }) + .resolve::(); + if module == env!("CARGO_PKG_VERSION") { + return true; + } + let _ = MISMATCHED_MODULE.set(module); + false +} + /// `Native.init(context, filesDir)`, from `PushupsProvider.onCreate`. #[unsafe(no_mangle)] pub extern "system" fn Java_rs_pushups_Native_init<'caller>( diff --git a/src/error.rs b/src/error.rs index e94d627..dc3b77a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -12,6 +12,17 @@ pub enum Error { /// `ContentProvider` never handed the process to Rust. #[error("the pushups Android module is not in the app")] AndroidModuleMissing, + /// The app's Gradle module is another version of `pushups` than its Rust crate, such as an + /// AAR of one release beside a crate of another, so the module handed nothing to Rust. + #[error( + "the pushups Android module is version {module}, but the app's pushups crate is {crate_version}" + )] + AndroidModuleMismatch { + /// The version the Gradle module was released with. + module: String, + /// The version of the `pushups` crate in the app. + crate_version: String, + }, /// The target's part of [`Config`](crate::Config) is missing, a /// [`WebPushConfig`](crate::WebPushConfig) on the web, a [`LinuxConfig`](crate::LinuxConfig) /// on Linux or a [`WnsConfig`](crate::WnsConfig) on Windows. diff --git a/src/lib.rs b/src/lib.rs index aefce33..1834d5d 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -121,8 +121,9 @@ pub use token::{Token, WebPushKeys}; /// /// [`Error::Unsupported`] on a target without a push backend, a browser without the Push API, /// or Windows without the Windows App SDK push API. [`Error::AndroidModuleMissing`] on Android -/// when the app does not include the crate's Gradle module. [`Error::NotConfigured`] on the -/// web without a [`WebPushConfig`], on Linux without a [`LinuxConfig`] and on Windows without +/// when the app does not include the crate's Gradle module, and [`Error::AndroidModuleMismatch`] +/// when it includes another version of it. [`Error::NotConfigured`] on the web without a +/// [`WebPushConfig`], on Linux without a [`LinuxConfig`] and on Windows without /// a [`WnsConfig`]. [`Error::Platform`] on Linux without a session bus or when another process /// of the app owns its bus name, and on Windows when the `remote_id` is not a GUID or the /// runtime does not start.