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")