SonarScanner 7.3.1 double-registers KMP Android source sets → can't be indexed twice (AGP 9.2)

Versions

  • SonarScanner for Gradle: 7.3.1.8318
  • SonarQube: Community Build 26.6.0.123539 (reproduced locally)
  • Android Gradle Plugin: 9.2.1
  • Gradle: 9.5.1
  • Kotlin: 2.3.0
  • Module type: Kotlin Multiplatform (org.jetbrains.kotlin.multiplatform) with an androidTarget() producing an AAR via the Android Library plugin (com.android.library) + org.jetbrains.compose. Source sets: commonMain, androidMain, androidUnitTest (plus jvmMain/iosMain/wasmJsMain).

Summary

For KMP modules that have an Android target, the scanner registers each Android-specific source set into sonar.sources/sonar.tests twice, once via the Kotlin source set and once via the Android variant’s source provider. The duplicate directory aborts analysis with:

File <module>/src/androidUnitTest/kotlin/.../SomeTest.kt can't be indexed twice.
Please check that inclusion/exclusion patterns produce disjoint sets for main and test files

The scanner’s own per-module output shows the duplicate directly:

Indexing files of module 'neem'
  Base dir: .../design-system/neem
  Source paths: src/androidMain/kotlin, src/commonMain/kotlin, src/iosMain/...
  Test paths:   src/androidUnitTest/kotlin, src/androidUnitTest/kotlin     <- same dir listed twice

androidMain is likewise duplicated in Source paths, producing the same error for main source files (e.g. androidMain/.../PlatformNativeToast.android.kt), not just tests.

Steps to reproduce

  1. A multi-module Gradle build with at least one KMP module applying org.jetbrains.kotlin.multiplatform with androidTarget() + com.android.library (AGP 9.2.1), containing files under both src/androidMain/kotlin and src/androidUnitTest/kotlin.
  2. Apply org.sonarqube 7.3.1.8318 on the root project.
  3. Run ./gradlew assembleDebug then ./gradlew sonar.
  4. :sonar fails at the indexing phase with “can’t be indexed twice” for an androidUnitTest (or androidMain) file.

This appears to be scanner-side, not user misconfiguration — no sonar.sources/sonar.tests is set manually; the duplicate entries are produced entirely by the plugin’s own source-set computation.

Workarounds tried

  • :cross_mark: Re-setting sonar.sources/sonar.tests per module to a de-duplicated explicit list — the value is appended, not replaced, so the directory still appears twice (the Android variant provider re-adds it).
  • :cross_mark: -Dsonar.gradle.scanAll=true — no effect; the duplicate lives in the Gradle-computed source sets, which scanAll don’t dedupe. Analysis fails identically.
  • :white_check_mark: Only working workaround: per-module sonar.exclusions + sonar.test.exclusions of **/src/androidMain/**,**/src/androidUnitTest/**,**/src/test/**. This drops androidMain production code from analysis — a real coverage loss (~5k lines of Android UI code in one module, in our case).

Expected behavior

The scanner should register each KMP Android source-set directory once in sonar.sources/sonar.tests (de-duplicate the Kotlin-source-set vs Android-variant-provider contributions). So, KMP Android modules are analysed without excluding androidMain.

Hello @Harshit_Pandey,

Thanks for reaching out and reporting this issue.

I’ve been able to reproduce your error and created SCANGRADLE-429 to track this issue in our backlog.

Best,
Sebastian Zumbrunn

I’ve added this temporary workaround to my project while this is fixed:

    afterEvaluate {
        val resolverTask = tasks.withType<SonarResolverTask>().firstOrNull() ?: return@afterEvaluate

        val adjustSonarTask = tasks.register("adjustSonar") {
            description = "Filters duplicate KMP Kotlin dirs from the SonarQube resolver file"
            dependsOn(resolverTask)

            // Declare the resolver file as both input and output — the task reads it,
            // filters it, and writes back the cleaned version. This enables Gradle's
            // up-to-date checks: the task is skipped when the resolver output hasn't changed.
            // The input is marked optional because the resolver file doesn't exist at
            // configuration time — it's produced by sonarResolver at execution time.
            val resolverFile = resolverTask.outputFile
            inputs.file(resolverFile).optional(true)
            outputs.file(resolverFile).optional(true)
            onlyIf { resolverFile.exists() }
            doLast {
                if (!resolverFile.exists()) {
                    logger.info("adjustSonar \[${project.path}\]: resolver file not found, skipping")
                    return@doLast
                }

                try {
                    val props = ResolutionSerializer.read(resolverFile)
                    if (!props.isPresent) return@doLast
                    val pp = props.get()

                    val originalSourcesCount = pp.androidSources?.size ?: 0
                    val originalTestsCount = pp.androidTests?.size ?: 0

                    // Remove androidMain/kotlin and androidUnitTest/kotlin — these are
                    // already registered by SonarPropertyComputer.configureForKotlin()
                    val filteredSources = pp.androidSources
                        ?.filterNot { it.contains("androidMain${File.separator}kotlin") }
                        ?: emptyList()

                    val filteredTests = pp.androidTests
                        ?.filterNot { it.contains("androidUnitTest${File.separator}kotlin") || it.contains("androidInstrumentedTest${File.separator}kotlin") }
                        ?: emptyList()

                    val sourcesRemoved = originalSourcesCount - filteredSources.size
                    val testsRemoved = originalTestsCount - filteredTests.size

                    if (sourcesRemoved == 0 && testsRemoved == 0) {
                        logger.info("adjustSonar \[${pp.projectName}\]: no duplicate KMP kotlin dirs found, skipping")
                        return@doLast
                    }

                    val newProps = ProjectProperties.Builder(pp.projectName, pp.isRootProject)
                        .compileClasspath(pp.compileClasspath ?: emptyList())
                        .testCompileClasspath(pp.testCompileClasspath ?: emptyList())
                        .mainLibraries(pp.mainLibraries ?: emptyList())
                        .testLibraries(pp.testLibraries ?: emptyList())
                        .androidSources(filteredSources)
                        .androidTests(filteredTests)
                        .build()
                    ResolutionSerializer.write(resolverFile, newProps)
                    logger.lifecycle(
                        "adjustSonar \[${pp.projectName}\]: removed $sourcesRemoved androidMain/kotlin source(s) and $testsRemoved androidUnitTest/kotlin test(s) from resolver file"
                    )
                } catch (e: Exception) {
                    logger.warn("adjustSonar \[${project.path}\]: failed to process resolver file: ${e.message}", e)
                }
            }
        }

        // Ensure resolver files are cleaned up before the root SonarTask reads them.
        rootProject.tasks.matching { it.name == "sonar" }.configureEach {
            dependsOn(adjustSonarTask)
        }
    }
}

Any update on when this fix will be merged? The MR has been approved and stable for quite a while now.

Hey @Daniel_Dello,

We’re currently planning to merge and release it next week.

Best,
Sebastian Zumbrunn

Hey again,
The fix has been released as part of Scanner for Gradle 7.4.0.8496

Cheers,
Sebastian Zumbrunn

Thank you! Just tried the new plugin and this issue of duplicate registration seems to be gone, but now I seen to be facing a different problem with my JVM only modules:

Probably related to this thread: SonarScanner for Gradle 7.4.0.8496 analysis failing - #2 by stephane-barbaray_ec