From 4c2af56adca575b0456c7fa8649d3ab6e192e5c2 Mon Sep 17 00:00:00 2001 From: Alexander Borsuk Date: Wed, 4 Nov 2026 01:16:16 +0100 Subject: [PATCH] Validate SDK publications with isolated minified consumers Add Native, Google and FOSS consumer flavors for the published SDK modules and assert their debug/release dependency sets. Keep optional modules confined to consumers and construct navigation widget previews through ordinary host references. Inspect every release APK's final DEX for the HTTP/JNI types, method descriptors and staticness. Use the R8 mapping only to locate the concrete Call class, and add regressions for removed or changed JNI contracts. Exercise the minified Native consumer with a platform-only probe without test-specific target keep rules. Make compatibility checks reusable with explicit SDK/examples revisions, keep release checks owned by this repository, and bound resource/R8 worker concurrency. Document the coverage and remaining feature checks. Pin source integration and guide links to upstream master at f629c29ac7e523abf982a984ca089ba939b93c9e. Related: #2 Related: organicmaps/organicmaps#14025 Signed-off-by: Alexander Borsuk --- .github/workflows/android-check.yaml | 30 +++- AGENTS.md | 12 +- README.md | 61 ++++++- app/build.gradle.kts | 25 +++ .../organicmaps/example/ConsumerActivity.kt | 39 +++++ app/src/consumer/res/values/strings.xml | 6 + build.gradle.kts | 38 ++++- gradle.properties | 2 + gradle/libs.versions.toml | 1 + gradle/sdk-consumer-checks.gradle.kts | 157 ++++++++++++++++++ gradle/tests/jni-contracts.xml | 24 +++ runtime-checks/build.gradle.kts | 53 ++++++ runtime-checks/src/main/AndroidManifest.xml | 1 + .../example/checks/ConsumerTestRunner.java | 121 ++++++++++++++ settings.gradle.kts | 1 + 15 files changed, 554 insertions(+), 17 deletions(-) create mode 100644 app/src/consumer/java/app/organicmaps/example/ConsumerActivity.kt create mode 100644 app/src/consumer/res/values/strings.xml create mode 100644 gradle/sdk-consumer-checks.gradle.kts create mode 100644 gradle/tests/jni-contracts.xml create mode 100644 runtime-checks/build.gradle.kts create mode 100644 runtime-checks/src/main/AndroidManifest.xml create mode 100644 runtime-checks/src/main/java/app/organicmaps/example/checks/ConsumerTestRunner.java diff --git a/.github/workflows/android-check.yaml b/.github/workflows/android-check.yaml index eecc444..66b8e9d 100644 --- a/.github/workflows/android-check.yaml +++ b/.github/workflows/android-check.yaml @@ -3,6 +3,18 @@ on: push: branches: [master] pull_request: + workflow_call: + inputs: + sdk_version: + description: Published Maven Central SDK version for a release check + type: string + sdk_ref: + description: SDK source ref for development checks (ignored when sdk_version is set) + type: string + samples_ref: + description: Examples revision containing this workflow + required: true + type: string workflow_dispatch: inputs: sdk_version: @@ -14,7 +26,7 @@ on: env: # Baseline for source integration checks; release checks use sdk_version. - SDK_REF: 49a72dc936db4e27b65673100680677680c736ee + SDK_REF: f629c29ac7e523abf982a984ca089ba939b93c9e SAMPLES_SDK_VERSION: ${{ inputs.sdk_version || '0.0.0-sdk-check' }} SAMPLES_SDK_REPOSITORY: ${{ inputs.sdk_version && 'https://repo.maven.apache.org/maven2' || format('{0}/engine/android/build/sdk-check/repository', github.workspace) }} @@ -23,7 +35,7 @@ permissions: concurrency: # SDK releases and source revisions have independent compatibility results. - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ inputs.sdk_version || inputs.sdk_ref || 'baseline' }} + group: ${{ github.workflow }}-samples-${{ github.event.pull_request.number || github.ref }}-${{ inputs.sdk_version || inputs.sdk_ref || 'baseline' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: @@ -35,6 +47,8 @@ jobs: steps: - uses: actions/checkout@v6 with: + repository: organicmaps/sdk-android + ref: ${{ inputs.samples_ref || github.sha }} path: samples - uses: actions/checkout@v6 if: ${{ !inputs.sdk_version }} @@ -66,12 +80,16 @@ jobs: - name: Build, lint and check Kotlin style for all samples working-directory: samples run: ./gradlew -PsdkRepository="$SAMPLES_SDK_REPOSITORY" -PsdkVersion="$SAMPLES_SDK_VERSION" checkSamples + - name: Verify packaged SDK runtime + if: ${{ !inputs.sdk_version }} + working-directory: engine/android + run: bash ../.github/scripts/check-sdk-aar.sh sdk/build/outputs/aar/sdk-release.aar - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Exercise sample lifecycle and result-list gestures + - name: Exercise sample lifecycle and the minified release consumer uses: reactivecircus/android-emulator-runner@v2 with: api-level: 35 @@ -79,9 +97,11 @@ jobs: target: default profile: pixel_2 working-directory: samples - script: ./gradlew --continue -PsdkRepository="$SAMPLES_SDK_REPOSITORY" -PsdkVersion="$SAMPLES_SDK_VERSION" connectedSamples + script: ./gradlew --continue -PsdkRepository="$SAMPLES_SDK_REPOSITORY" -PsdkVersion="$SAMPLES_SDK_VERSION" connectedSamples verifyConnectedConsumer - uses: actions/upload-artifact@v7 if: always() with: name: sample-reports - path: samples/app/build/reports + path: | + samples/app/build/reports + samples/runtime-checks/build/reports diff --git a/AGENTS.md b/AGENTS.md index c94bfbf..d1f3afe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,13 @@ ## Project Structure & Module Organization -This repository demonstrates the Organic Maps Android SDK in one `app` module with independently installable `basic`, `offline`, and `routing` flavors. +The `app` module demonstrates the Organic Maps Android SDK with independently installable `basic`, `offline`, and `routing` tutorial flavors. The `consumerNative`, `consumerGoogle`, and `consumerFoss` flavors validate the published module combinations. The separate `runtime-checks` test module probes the minified Native consumer using only Android platform APIs. - `app/src/main/java/app/organicmaps/example/`: Kotlin activities and `ExampleApplication`, which owns SDK initialization and readiness. - `app/src/main/res/`: layouts, strings, themes, and drawable assets; the manifest is in `app/src/main/`. +- `app/src/consumer/`: shared consumer activity, resources, and navigation widget previews. +- `runtime-checks/src/main/`: platform-only release instrumentation runner, with no SDK or AndroidX dependencies. +- `gradle/sdk-consumer-checks.gradle.kts`: final-APK JNI checks, verifier regressions, and resolved SDK module assertions. - `gradle/libs.versions.toml`: dependency versions; `.github/workflows/android-check.yaml`: CI. ## Build, Test, and Development Commands @@ -19,11 +22,14 @@ Build fresh SDK artifacts from a sibling Organic Maps checkout: ./gradlew -PsdkRepository=../organicmaps/android/build/sdk-check/repository -PsdkVersion=0.0.0-sdk-check checkSamples ``` -`checkSamples` checks Kotlin style, builds debug and minified release APKs for all flavors, and runs Android lint. Keep the same repository/version flags when running other sample tasks: +`checkSamples` checks Kotlin style, builds and lints all six debug and minified release variants, builds the tutorial instrumentation APKs and release probe, checks optional-module isolation, and verifies HTTP/JNI contracts in every final release APK with the Android build tools' `dexdump`. Keep the same repository/version flags when running other sample tasks: - `./gradlew :app:assembleBasicDebug`: build the basic sample. - `./gradlew :app:installOfflineDebug`: install offline search; use `installRoutingDebug` for routing. - `./gradlew ktlintCheck` / `./gradlew ktlintFormat`: check or fix Kotlin formatting. +- `./gradlew :app:verifyReleaseJniContracts`: inspect the final release DEX types, method descriptors, and staticness. +- `./gradlew :app:verifySdkConsumerDependencies`: check SDK module isolation in all debug and release configurations. +- `./gradlew verifyConnectedConsumer`: run the platform-only probe against `consumerNativeRelease` on a matching device. ## Coding Style & Naming Conventions @@ -31,7 +37,7 @@ Follow `.editorconfig`: four-space indentation for Kotlin and Gradle Kotlin scri ## Testing Guidelines -AndroidX instrumentation tests live in `app/src/androidTest/`, `app/src/androidTestOffline/`, and `app/src/androidTestRouting/`. Run `checkSamples` before submitting changes and `connectedSamples` on a matching device or emulator. Verify rotation, light/dark themes, location permissions, cancelled downloads, offline search, missing-map routing, and background/foreground transitions. Repeat relevant checks with minified release APKs. See `README.md` for sample walkthroughs. +AndroidX tutorial instrumentation tests live in `app/src/androidTest/`, `app/src/androidTestOffline/`, and `app/src/androidTestRouting/`; the platform-only release probe lives in `runtime-checks/src/main/`. Run `checkSamples` before submitting changes and `connectedSamples verifyConnectedConsumer` on a matching device or emulator. The Google/FOSS consumers receive build and lint coverage; actual provider behavior requires an appropriate Google/microG environment. Car is included for dependency/build coverage. Verify rotation, light/dark themes, location permissions, cancelled downloads, offline search, missing-map routing, and background/foreground transitions. Repeat relevant checks with minified offline/routing APKs; the Native probe does not cover those feature scenarios. See `README.md` for sample walkthroughs. ## Commit & Pull Request Guidelines diff --git a/README.md b/README.md index c69fdd5..7c1facb 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Downloads continue independently of the screen and offer cancellation and retry. The SDK is a side product of the main Organic Maps application. Its application and SDK implementation are the source of truth; API stability is not guaranteed. These examples must adapt when SDK changes break integration. See the -[SDK integration guide](https://github.com/organicmaps/organicmaps/blob/49a72dc936db4e27b65673100680677680c736ee/android/sdk/README.md) +[SDK integration guide](https://github.com/organicmaps/organicmaps/blob/f629c29ac7e523abf982a984ca089ba939b93c9e/android/sdk/README.md) for initialization, lifecycle, permissions, packaging, and API contracts. ## Build and run @@ -23,15 +23,15 @@ for initialization, lifecycle, permissions, packaging, and API contracts. Use **JDK 21**, **Android SDK Platform 37**, and an **API 21+** device or emulator. The examples consume native libraries from SDK AARs and do not need an NDK. -For development with SDK source, publish its artifacts locally. The merged revision +For development with SDK source, publish its artifacts locally. The pinned revision below provides `publishSdkForChecks`, documented in [organicmaps/organicmaps#14025](https://github.com/organicmaps/organicmaps/pull/14025). Building that checkout requires the upstream -[native build environment](https://github.com/organicmaps/organicmaps/blob/49a72dc936db4e27b65673100680677680c736ee/docs/INSTALL.md). +[native build environment](https://github.com/organicmaps/organicmaps/blob/f629c29ac7e523abf982a984ca089ba939b93c9e/docs/INSTALL.md). With an Organic Maps checkout in the sibling `organicmaps` directory: ```sh -git -C ../organicmaps fetch origin 49a72dc936db4e27b65673100680677680c736ee +git -C ../organicmaps fetch origin f629c29ac7e523abf982a984ca089ba939b93c9e git -C ../organicmaps checkout --detach FETCH_HEAD git -C ../organicmaps submodule update --init --recursive --depth 1 (cd ../organicmaps/android && ./gradlew -Parm64 -PsdkVersion=0.0.0-sdk-check publishSdkForChecks) @@ -87,6 +87,59 @@ Keep the baseline SDK version/source pin current after adopting a release and exercise minified release APKs on a device for JNI/R8 checks. Resource shrinking is disabled here and needs separate validation of resources resolved by name. +## Published SDK consumer checks + +Together, the `consumerNative`, `consumerGoogle`, and `consumerFoss` variants resolve, +build, and lint all published SDK modules. Car and lane/speed-limit widget dependencies +are confined to these consumers; the three tutorial apps use only the core SDK and +bundled World maps, including the transitive `location-core` module. +[ConsumerActivity](app/src/consumer/java/app/organicmaps/example/ConsumerActivity.kt) +constructs navigation widget previews through ordinary host references. + +`checkSamples` checks the resolved SDK module set for all debug/release configurations +to prevent optional dependencies from leaking into the tutorials or mixing providers. +It also uses the Android build tools' `dexdump` to inspect every final release APK, +including all DEX files, for the HTTP/JNI types and exact static `runAsync` and concrete +instance `cancel` method descriptors. The R8 mapping locates the obfuscated concrete +Call class; method signatures are verified in DEX. Focused verifier regressions reject +removed/renamed methods, changed descriptors and incorrect staticness. + +`checkSamples` also builds the platform-only release probe. Run that probe against +`consumerNativeRelease` on a device matching the published ABI: + +```sh +./gradlew -PsdkRepository=../organicmaps/android/build/sdk-check/repository -PsdkVersion=0.0.0-sdk-check verifyConnectedConsumer +``` + +The [probe module](runtime-checks/) has no SDK or AndroidX dependencies and adds no +keep rules to the target. It checks the HTTP descriptor, native renderer readiness, +JNI-populated region data and host-created widgets, and fails if no test executes. +The Google/FOSS variants receive build/lint coverage; actual Google Play Services or +microG provider behavior requires an appropriate environment. Car receives dependency +and build coverage. Minified offline/routing feature scenarios remain separate from +this Native smoke test, as tracked in [issue #2](https://github.com/organicmaps/sdk-android/issues/2). + +The reusable workflow accepts `sdk_version` for Maven Central release checks, +`sdk_ref` for optional source integration, and `samples_ref` for the examples revision. +For a dedicated manual or post-release workflow, call it at the same examples commit +that you pass as `samples_ref`: + +```yaml +jobs: + check-sdk-consumers: + uses: organicmaps/sdk-android/.github/workflows/android-check.yaml@SAMPLES_COMMIT_SHA + permissions: + contents: read + with: + samples_ref: SAMPLES_COMMIT_SHA + sdk_version: PUBLISHED_SDK_VERSION +``` + +Replace both `SAMPLES_COMMIT_SHA` values with the same commit containing the reusable +workflow, and `PUBLISHED_SDK_VERSION` with a version available on Maven Central. +To check SDK source instead, omit `sdk_version` and optionally supply `sdk_ref`. +Consumer compatibility runs in this repository and does not gate SDK changes. + ## License and support See [LICENSE](LICENSE). Report sample issues in diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3068a56..348a0cc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,8 @@ plugins { alias(libs.plugins.android.application) } +val consumerFlavors = listOf("consumerNative", "consumerGoogle", "consumerFoss") + android { namespace = "app.organicmaps.example" compileSdk = providers.gradleProperty("propCompileSdkVersion").get().toInt() @@ -45,6 +47,19 @@ android { } } + consumerFlavors.forEach { sample -> + productFlavors.create(sample) { + dimension = "sample" + applicationIdSuffix = ".$sample" + manifestPlaceholders["sampleActivity"] = "app.organicmaps.example.ConsumerActivity" + resValue("string", "app_name", "OM SDK Consumer (${sample.removePrefix("consumer")})") + } + sourceSets.named(sample) { + kotlin.directories += "src/consumer/java" + res.srcDir("src/consumer/res") + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -77,6 +92,14 @@ dependencies { // Bundles overview maps; detailed offline search and routing still require region downloads. implementation("app.organicmaps.sdk:maps-world:$sdkVersion") + consumerFlavors.forEach { sample -> + listOf("car", "widgets-lanes", "widgets-speedlimit").forEach { artifact -> + add("${sample}Implementation", "app.organicmaps.sdk:$artifact:$sdkVersion") + } + } + "consumerGoogleImplementation"("app.organicmaps.sdk:location-google:$sdkVersion") + "consumerFossImplementation"("app.organicmaps.sdk:location-foss:$sdkVersion") + coreLibraryDesugaring(libs.android.tools.desugaring) implementation(libs.androidx.appcompat) @@ -86,3 +109,5 @@ dependencies { androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.junit) } + +apply(from = rootProject.file("gradle/sdk-consumer-checks.gradle.kts")) diff --git a/app/src/consumer/java/app/organicmaps/example/ConsumerActivity.kt b/app/src/consumer/java/app/organicmaps/example/ConsumerActivity.kt new file mode 100644 index 0000000..0bea770 --- /dev/null +++ b/app/src/consumer/java/app/organicmaps/example/ConsumerActivity.kt @@ -0,0 +1,39 @@ +package app.organicmaps.example + +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.LinearLayout +import android.widget.TextView +import app.organicmaps.sdk.routing.LaneInfo +import app.organicmaps.sdk.routing.LaneWay +import app.organicmaps.sdk.widgets.lanes.LanesView +import app.organicmaps.sdk.widgets.speedlimit.SpeedLimitView + +/** A standalone host for the published SDK modules and navigation widget previews. */ +class ConsumerActivity : MainActivity() { + override fun onCoreInitialized() { + super.onCoreInitialized() + val panel = samplePanel() + panel.addView(TextView(this).apply { setText(R.string.consumer_widgets) }) + val widgetSize = (80 * resources.displayMetrics.density).toInt() + panel.addView( + LanesView(this, null).apply { + id = R.id.consumer_lanes + setLanes(arrayOf(LaneInfo(arrayOf(LaneWay.Left, LaneWay.Through), LaneWay.Through))) + }, + LinearLayout.LayoutParams(MATCH_PARENT, widgetSize), + ) + panel.addView( + SpeedLimitView(this).apply { + id = R.id.consumer_speed_limit + setSpeedLimit(50, false) + }, + LinearLayout.LayoutParams(widgetSize, widgetSize), + ) + } + + override fun onRenderingInitializationFinished() { + // The external probe observes the native callback through a platform View, without naming host methods. + findViewById(R.id.map).tag = true + } +} diff --git a/app/src/consumer/res/values/strings.xml b/app/src/consumer/res/values/strings.xml new file mode 100644 index 0000000..721d7c2 --- /dev/null +++ b/app/src/consumer/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Navigation widget previews + + + diff --git a/build.gradle.kts b/build.gradle.kts index 788ccb1..99ae54c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.test) apply false } val ktlint = configurations.create("ktlint") @@ -15,23 +16,44 @@ tasks.register("ktlintCheck") { group = "verification" classpath = ktlint mainClass.set("com.pinterest.ktlint.Main") - args("--editorconfig=.editorconfig", "app/src/**/*.kt", "*.gradle.kts", "app/*.gradle.kts") + args( + "--editorconfig=.editorconfig", + "app/src/**/*.kt", + "*.gradle.kts", + "*/build.gradle.kts", + "gradle/**/*.gradle.kts", + ) } tasks.register("ktlintFormat") { group = "formatting" classpath = ktlint mainClass.set("com.pinterest.ktlint.Main") - args("--format", "--editorconfig=.editorconfig", "app/src/**/*.kt", "*.gradle.kts", "app/*.gradle.kts") + args( + "--format", + "--editorconfig=.editorconfig", + "app/src/**/*.kt", + "*.gradle.kts", + "*/build.gradle.kts", + "gradle/**/*.gradle.kts", + ) } val sampleFlavors = listOf("Basic", "Offline", "Routing") +val consumerFlavors = listOf("ConsumerNative", "ConsumerGoogle", "ConsumerFoss") tasks.register("checkSamples") { group = "verification" description = "Build and lint all independently installable samples, including minified releases." - dependsOn("ktlintCheck", ":app:assembleDebug", ":app:assembleRelease") - sampleFlavors.forEach { sample -> - dependsOn(":app:lint${sample}Debug", ":app:lint${sample}Release", ":app:assemble${sample}DebugAndroidTest") + dependsOn( + "ktlintCheck", + ":app:assembleDebug", + ":app:verifyReleaseJniContracts", + ":app:verifySdkConsumerDependencies", + ":runtime-checks:assembleRelease", + ) + (sampleFlavors + consumerFlavors).forEach { sample -> + dependsOn(":app:lint${sample}Debug", ":app:lint${sample}Release") } + sampleFlavors.forEach { sample -> dependsOn(":app:assemble${sample}DebugAndroidTest") } } tasks.register("connectedSamples") { @@ -39,3 +61,9 @@ tasks.register("connectedSamples") { description = "Run each sample's instrumentation tests on a connected device or emulator." sampleFlavors.forEach { sample -> dependsOn(":app:connected${sample}DebugAndroidTest") } } + +tasks.register("verifyConnectedConsumer") { + group = "verification" + description = "Exercise a minified consumer without adding test dependencies or keep rules to its APK." + dependsOn(":runtime-checks:verifyConnectedConsumer") +} diff --git a/gradle.properties b/gradle.properties index 61a12df..d38287a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,7 @@ org.gradle.jvmargs=-Xmx2048m -Xms512m org.gradle.caching=true +# Bound resource and R8 concurrency for the consumer matrix within the configured heap. +org.gradle.workers.max=2 android.useAndroidX=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e322f0..fa47547 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,3 +17,4 @@ androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "andro [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-test = { id = "com.android.test", version.ref = "agp" } diff --git a/gradle/sdk-consumer-checks.gradle.kts b/gradle/sdk-consumer-checks.gradle.kts new file mode 100644 index 0000000..159a8fe --- /dev/null +++ b/gradle/sdk-consumer-checks.gradle.kts @@ -0,0 +1,157 @@ +import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import javax.xml.parsers.DocumentBuilderFactory +import org.w3c.dom.Element + +buildscript { + // Applied scripts need the existing plugin classpath to use AGP's public artifact API. + dependencies { classpath(files(rootProject.buildscript.configurations.getByName("classpath"))) } +} + +fun Element.elements(tag: String): List = + (0 until childNodes.length).mapNotNull { childNodes.item(it) as? Element }.filter { it.tagName == tag } + +fun verifyJniContracts(xml: String, mapping: String, variant: String) { + val parser = DocumentBuilderFactory.newInstance().apply { + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + }.newDocumentBuilder() + val packages = parser.parse(xml.byteInputStream()).getElementsByTagName("package") + val classes = buildMap { + for (index in 0 until packages.length) { + val pkg = packages.item(index) as Element + pkg.elements("class").forEach { type -> + val name = listOf(pkg.getAttribute("name"), type.getAttribute("name")) + .filter { it.isNotEmpty() }.joinToString(".") + put(name, type) + } + } + } + val http = "app.organicmaps.sdk.util.HttpClient" + listOf(http, "$http\$Params", "okhttp3.Call").forEach { type -> + if (type !in classes) throw GradleException("JNI type missing in $variant: $type") + } + + fun requireMethod(type: String, name: String, result: String, parameters: List, isStatic: Boolean) { + val found = classes[type]?.elements("method")?.any { method -> + method.getAttribute("name") == name && method.getAttribute("return") == result && + method.getAttribute("static") == isStatic.toString() && method.getAttribute("abstract") == "false" && + method.elements("parameter").map { it.getAttribute("type") } == parameters + } == true + if (!found) { + val kind = if (isStatic) "static" else "concrete instance" + throw GradleException( + "JNI method missing in $variant: $kind $type.$name(${parameters.joinToString()}): $result", + ) + } + } + + requireMethod(http, "runAsync", "okhttp3.Call", listOf("$http\$Params", "long"), true) + // JNI gets cancel() from the returned object's class. Only use the mapping to locate that class; + // original method signatures in mapping.txt do not establish the final DEX descriptor. + val realCall = Regex("(?m)^okhttp3\\.internal\\.connection\\.RealCall -> ([^:]+):$") + .find(mapping)?.groupValues?.get(1) ?: throw GradleException("JNI concrete Call type missing in $variant") + requireMethod(realCall, "cancel", "void", emptyList(), false) +} + +val verifierTests = tasks.register("testJniContractVerifier") { + group = "verification" + description = "Check that the APK verifier rejects missing, renamed and changed JNI contracts." + val fixture = rootProject.layout.projectDirectory.file("gradle/tests/jni-contracts.xml") + inputs.file(fixture) + doLast { + val xml = fixture.asFile.readText() + val mapping = "okhttp3.internal.connection.RealCall -> a:" + verifyJniContracts(xml, mapping, "valid fixture") + val invalid = mapOf( + "removed runAsync" to xml.replace(Regex("(?s)"), ""), + "renamed runAsync" to xml.replace("name=\"runAsync\"", "name=\"renamed\""), + "instance runAsync" to xml.replace("static=\"true\"", "static=\"false\""), + "changed return descriptor" to xml.replace("return=\"okhttp3.Call\"", "return=\"a\""), + "changed parameter descriptor" to xml.replace("type=\"long\"", "type=\"int\""), + "renamed HTTP type" to xml.replace("name=\"HttpClient\"", "name=\"Renamed\""), + "renamed Params type" to xml.replace("name=\"HttpClient\$Params\"", "name=\"Renamed\""), + "removed cancellation" to xml.replace(Regex("(?s)"), ""), + "static cancellation" to xml.replace("name=\"cancel\" static=\"false\"", "name=\"cancel\" static=\"true\""), + ) + invalid.forEach { (name, candidate) -> + val rejected = try { + verifyJniContracts(candidate, mapping, name) + false + } catch (_: GradleException) { + true + } + if (!rejected) throw GradleException("The JNI verifier accepted $name") + } + } +} + +val verifyReleaseJniContracts = tasks.register("verifyReleaseJniContracts") { + group = "verification" + description = "Verify the final HTTP/JNI types, method descriptors and staticness in every release APK." + dependsOn(verifierTests) +} + +val android = extensions.getByType() +val androidComponents = extensions.getByType() +val dexdump = androidComponents.sdkComponents.sdkDirectory.map { sdk -> + val suffix = if (System.getProperty("os.name").startsWith("Windows")) ".exe" else "" + sdk.file("build-tools/${android.buildToolsVersion}/dexdump$suffix") +} +androidComponents.onVariants(androidComponents.selector().withBuildType("release")) { variant -> + val apks = variant.artifacts.get(SingleArtifact.APK) + val mapping = variant.artifacts.get(SingleArtifact.OBFUSCATION_MAPPING_FILE) + val loader = variant.artifacts.getBuiltArtifactsLoader() + val verify = tasks.register("verify${variant.name.replaceFirstChar { it.uppercase() }}JniContracts") { + group = "verification" + inputs.dir(apks) + inputs.file(mapping) + inputs.file(dexdump) + doLast { + val artifacts = + loader.load(apks.get()) ?: throw GradleException("No release APK metadata for ${variant.name}") + if (artifacts.elements.isEmpty()) throw GradleException("No release APKs for ${variant.name}") + artifacts.elements.forEach { artifact -> + // dexdump reads all classes*.dex entries from the packaged APK, including secondary DEX files. + val xml = providers.exec { + commandLine(dexdump.get().asFile, "-l", "xml", artifact.outputFile) + }.standardOutput.asText.get() + verifyJniContracts(xml, mapping.get().asFile.readText(), variant.name) + logger.lifecycle("${variant.name}: verified HTTP/JNI contracts in ${File(artifact.outputFile).name}") + } + } + } + verifyReleaseJniContracts.configure { dependsOn(verify) } +} + +tasks.register("verifySdkConsumerDependencies") { + group = "verification" + description = "Verify the SDK module set and optional-module isolation in every debug and release variant." + doLast { + val core = setOf("sdk", "location-core", "maps-world") + val optional = setOf("car", "widgets-lanes", "widgets-speedlimit") + android.productFlavors.forEach { flavor -> + val expected = when (flavor.name) { + "basic", "offline", "routing" -> core + "consumerNative" -> core + optional + "consumerGoogle" -> core + optional + "location-google" + "consumerFoss" -> core + optional + "location-foss" + else -> throw GradleException("No expected SDK module set for ${flavor.name}") + } + listOf("Debug", "Release").forEach { buildType -> + val variant = "${flavor.name}$buildType" + val actual = configurations.getByName( + "${variant}RuntimeClasspath", + ).incoming.resolutionResult.allComponents + .mapNotNull { it.moduleVersion?.takeIf { module -> module.group == "app.organicmaps.sdk" }?.name } + .toSet() + if (actual != expected) { + throw GradleException( + "Unexpected SDK modules in $variant: expected ${expected.sorted()}, got ${actual.sorted()}", + ) + } + logger.lifecycle("$variant: ${actual.sorted().joinToString()}") + } + } + } +} diff --git a/gradle/tests/jni-contracts.xml b/gradle/tests/jni-contracts.xml new file mode 100644 index 0000000..64586bb --- /dev/null +++ b/gradle/tests/jni-contracts.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/runtime-checks/build.gradle.kts b/runtime-checks/build.gradle.kts new file mode 100644 index 0000000..08d12a9 --- /dev/null +++ b/runtime-checks/build.gradle.kts @@ -0,0 +1,53 @@ +import javax.xml.parsers.DocumentBuilderFactory + +plugins { + alias(libs.plugins.android.test) +} + +android { + namespace = "app.organicmaps.example.checks" + compileSdk = providers.gradleProperty("propCompileSdkVersion").get().toInt() + enableKotlin = false + targetProjectPath = ":app" + + defaultConfig { + minSdk = providers.gradleProperty("propMinSdkVersion").get().toInt() + targetSdk = providers.gradleProperty("propTargetSdkVersion").get().toInt() + testInstrumentationRunner = "app.organicmaps.example.checks.ConsumerTestRunner" + missingDimensionStrategy("sample", "consumerNative") + } + + buildTypes { + create("release") { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + signingConfig = signingConfigs.getByName("debug") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +androidComponents { + beforeVariants(selector().withBuildType("debug")) { it.enable = false } +} + +// The target APK already packages its dependencies; resolve only that artifact. +configurations.matching { it.name.endsWith("TestedApks") }.configureEach { + isTransitive = false +} + +tasks.register("verifyConnectedConsumer") { + group = "verification" + dependsOn(":app:verifyReleaseJniContracts", "connectedReleaseAndroidTest") + doLast { + val reports = fileTree(layout.buildDirectory.dir("outputs/androidTest-results/connected/release")) { + include("**/TEST-*.xml") + } + val parser = DocumentBuilderFactory.newInstance().newDocumentBuilder() + val count = reports.files.sumOf { parser.parse(it).getElementsByTagName("testcase").length } + if (count == 0) throw GradleException("No minified Maven-consumer instrumentation tests were executed") + } +} diff --git a/runtime-checks/src/main/AndroidManifest.xml b/runtime-checks/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/runtime-checks/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/runtime-checks/src/main/java/app/organicmaps/example/checks/ConsumerTestRunner.java b/runtime-checks/src/main/java/app/organicmaps/example/checks/ConsumerTestRunner.java new file mode 100644 index 0000000..ecd6a4c --- /dev/null +++ b/runtime-checks/src/main/java/app/organicmaps/example/checks/ConsumerTestRunner.java @@ -0,0 +1,121 @@ +package app.organicmaps.example.checks; + +import android.app.Activity; +import android.app.Instrumentation; +import android.content.Intent; +import android.os.Bundle; +import android.os.SystemClock; +import android.util.Log; +import android.view.View; + +/** + * Platform-only probe for the minified release APK. Sharing a third-party test runner's dependencies with a + * separately optimized target can require extra keep rules, which would weaken this consumer check. + * Resolve SDK/HTTP classes through the target loader so test-APK dependencies cannot satisfy a missing target type. + */ +public final class ConsumerTestRunner extends Instrumentation +{ + @Override + public void onCreate(Bundle arguments) + { + super.onCreate(arguments); + start(); + } + + @Override + public void onStart() + { + Bundle status = new Bundle(); + status.putString("id", "InstrumentationTestRunner"); + status.putString("class", getClass().getName()); + status.putString("test", "initializesAndRendersFromPublishedAar"); + status.putInt("numtests", 1); + status.putInt("current", 1); + sendStatus(1, status); + Activity activity = null; + Throwable failure = null; + try + { + // JNI uses this literal external return type and method name; target-app R8 must preserve both. + ClassLoader loader = getTargetContext().getClassLoader(); + Class http = loader.loadClass("app.organicmaps.sdk.util.HttpClient"); + Class params = loader.loadClass("app.organicmaps.sdk.util.HttpClient$Params"); + Class call = http.getDeclaredMethod("runAsync", params, long.class).getReturnType(); + if (!"okhttp3.Call".equals(call.getName())) + throw new AssertionError("JNI HTTP return type was renamed to " + call.getName()); + if (call.getMethod("cancel").getReturnType() != void.class) + throw new AssertionError("JNI HTTP cancellation must have descriptor ()V"); + + Intent intent = + getTargetContext().getPackageManager().getLaunchIntentForPackage(getTargetContext().getPackageName()); + if (intent == null) + throw new AssertionError("The SDK consumer has no launch activity"); + if (!Activity.class.isAssignableFrom(loader.loadClass(intent.getComponent().getClassName()))) + throw new AssertionError("The consumer launcher is not an Activity"); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + activity = startActivitySync(intent); + Activity target = activity; + int mapId = getTargetContext().getResources().getIdentifier("map", "id", getTargetContext().getPackageName()); + int lanesId = getTargetContext().getResources().getIdentifier("consumer_lanes", "id", getTargetContext().getPackageName()); + int speedLimitId = getTargetContext().getResources().getIdentifier("consumer_speed_limit", "id", getTargetContext().getPackageName()); + long deadline = SystemClock.uptimeMillis() + 60000; + boolean[] ready = {false}; + while (!ready[0] && SystemClock.uptimeMillis() < deadline) + { + runOnMainSync(() -> { + View map = target.findViewById(mapId); + View lanes = target.findViewById(lanesId); + View speedLimit = target.findViewById(speedLimitId); + ready[0] = map != null && Boolean.TRUE.equals(map.getTag()) && lanes != null && lanes.getWidth() > 0 + && speedLimit != null && speedLimit.getWidth() > 0; + }); + if (!ready[0]) + SystemClock.sleep(100); + } + if (!ready[0]) + throw new AssertionError("The published SDK did not initialize its renderer"); + + // Region fields come from JNI; widgets are referenced and constructed by the target host itself. + Throwable[] sdkError = {null}; + runOnMainSync(() -> { + try + { + Class manager = loader.loadClass("app.organicmaps.sdk.downloader.MapManager"); + Object root = manager.getMethod("nativeGetRoot").invoke(null); + Class country = loader.loadClass("app.organicmaps.sdk.downloader.CountryItem"); + Object region = country.getMethod("fill", String.class).invoke(null, root); + Object name = country.getField("name").get(region); + if (!(name instanceof String) || ((String) name).isEmpty()) + throw new AssertionError("JNI did not populate the region name"); + } + catch (Throwable error) + { + sdkError[0] = error; + } + }); + if (sdkError[0] != null) + throw new AssertionError("Published SDK region data or widgets are unusable", sdkError[0]); + } + catch (Throwable error) + { + failure = error; + } + finally + { + if (activity != null) + runOnMainSync(activity::finish); + } + if (failure == null) + { + sendStatus(0, status); + status.putString("stream", "\nOK (1 test)\n"); + finish(Activity.RESULT_OK, status); + } + else + { + status.putString("stack", Log.getStackTraceString(failure)); + sendStatus(-2, status); + finish(Activity.RESULT_CANCELED, status); + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 818c42b..6f4ff27 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,3 +26,4 @@ dependencyResolutionManagement { rootProject.name = "organicmaps-sdk-example" include(":app") +include(":runtime-checks")