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