Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions .github/workflows/release-aar.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Android AAR

# A `v<version>` 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' }}
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application>
<!-- The crate version this Kotlin was released with, which the Rust side checks at start. -->
<meta-data
android:name="pushups.module_version"
android:value="0.1.0" />

<service
android:name="rs.pushups.PushupsMessagingService"
android:exported="false">
Expand Down
7 changes: 7 additions & 0 deletions android/src/main/kotlin/rs/pushups/Native.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
43 changes: 29 additions & 14 deletions android/src/main/kotlin/rs/pushups/PushupsProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions ci/android/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
@@ -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(),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application>
<meta-data
android:name="pushups.lib_name"
android:value="pushups_probe" />
<!-- A module of another version than the crate, as an AAR of another release would bring. -->
<meta-data
android:name="pushups.module_version"
android:value="0.0.0-mismatch"
tools:replace="android:value" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<!-- A library the provider loads that holds no pushups at all, liblog from the NDK, so
the handshake finds no export, as with a crate older than the module. -->
<meta-data
android:name="pushups.lib_name"
android:value="log" />
</application>
</manifest>
12 changes: 12 additions & 0 deletions ci/check-versions.sh
Original file line number Diff line number Diff line change
@@ -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<version>`), 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; }
3 changes: 3 additions & 0 deletions ci/maven/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/build
/.gradle
/.kotlin
Loading
Loading