diff --git a/build.gradle b/build.gradle index d36e81704..d73008870 100644 --- a/build.gradle +++ b/build.gradle @@ -8,17 +8,18 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:9.0.0-alpha13' - classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0' + classpath 'com.android.tools.build:gradle:9.0.0-rc02' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.10' classpath "com.vanniktech:gradle-maven-publish-plugin:0.34.0" } } apply plugin: 'com.android.fused-library' apply plugin: 'com.vanniktech.maven.publish' +apply from: "$rootDir/gradle/jacoco-root.gradle" ext { - splitVersion = '5.4.3-rc4' + splitVersion = '5.4.3-rc5' jacocoVersion = '0.8.8' } @@ -33,16 +34,49 @@ allprojects { } } +// The SonarQube Gradle plugin is NOT yet compatible with AGP 9.0 +// Using CLI scanner wrapper as workaround until plugin is updated -// Define exclusions for JaCoCo coverage -def coverageExclusions = [ - '**/R.class', - '**/R$*.class', - '**/BuildConfig.*', - '**/Manifest*.*', - '**/*Test*.*', - 'android/**/*.*' -] +// Sonar task that wraps the CLI scanner (uses sonar-project.properties) +tasks.register('sonar') { + group = 'verification' + description = 'Run SonarQube analysis (uses sonar-scanner CLI for AGP 9.0 compatibility)' + + dependsOn 'jacocoRootReport' + + doLast { + def sonarToken = System.getProperty('sonar.token') ?: project.findProperty('sonar.token') + def sonarHost = System.getProperty('sonar.host.url') ?: project.findProperty('sonar.host.url') ?: 'https://sonarcloud.io' + def sonarOrg = System.getProperty('sonar.organization') ?: project.findProperty('sonar.organization') + + if (!sonarToken) { + throw new GradleException('SonarQube token required') + } + + // Find sonar-scanner + def scannerPath = ['sonar-scanner', '/opt/homebrew/bin/sonar-scanner', '/usr/local/bin/sonar-scanner'] + .find { path -> + try { + def proc = new ProcessBuilder(path, '--version').redirectErrorStream(true).start() + return proc.waitFor() == 0 + } catch (Exception e) { return false } + } + + if (!scannerPath) { + throw new GradleException('sonar-scanner not found. Install with: brew install sonar-scanner') + } + + println "Running sonar-scanner..." + def cmd = [scannerPath, "-Dsonar.token=${sonarToken}", "-Dsonar.host.url=${sonarHost}"] + if (sonarOrg) cmd.add("-Dsonar.organization=${sonarOrg}") + cmd.add("-Dsonar.projectVersion=${splitVersion}") + + def proc = new ProcessBuilder(cmd).directory(rootDir).inheritIO().start() + if (proc.waitFor() != 0) { + throw new GradleException("sonar-scanner failed") + } + } +} androidFusedLibrary { namespace = 'io.split.android.android_client' @@ -50,9 +84,9 @@ androidFusedLibrary { } tasks.configureEach { task -> - if (task.name == 'packageLintJar' || - task.name.contains('LintJar') || - task.name.contains('LintModel')) { + if (task.name == 'packageLintJar' || + task.name.contains('LintJar') || + task.name.contains('LintModel')) { task.enabled = false } } @@ -63,35 +97,35 @@ afterEvaluate { doLast { def aarFile = file("${buildDir}/outputs/aar/android-client.aar") def proguardFile = file("${buildDir}/intermediates/merged_consumer_proguard_file/release/mergeReleaseConsumerProguardFiles/proguard.txt") - + if (aarFile.exists() && proguardFile.exists()) { // Create temp directory to extract AAR def tempDir = file("${buildDir}/tmp/aar-repack") tempDir.deleteDir() tempDir.mkdirs() - + // Extract AAR copy { from zipTree(aarFile) into tempDir } - + // Copy proguard.txt copy { from proguardFile into tempDir } - + // Remove lint.jar if it exists (we don't have custom lint rules) def lintJar = new File(tempDir, 'lint.jar') if (lintJar.exists()) { lintJar.delete() println "Removed empty lint.jar from AAR" } - + // Repack AAR ant.zip(destfile: aarFile, basedir: tempDir) - + println "Added consumer ProGuard rules to ${aarFile.name}" } } @@ -108,6 +142,68 @@ dependencies { include project(':logger') } +def javadocSourceProjects = providers.provider { + def includeConfig = configurations.findByName("include") + if (includeConfig == null) { + return [] + } + includeConfig.allDependencies + .withType(org.gradle.api.artifacts.ProjectDependency) + .collect { dep -> + def projectPath = null + if (dep.metaClass.hasProperty(dep, 'dependencyProject')) { + projectPath = dep.dependencyProject?.path + } else if (dep.metaClass.hasProperty(dep, 'dependencyProjectPath')) { + projectPath = dep.dependencyProjectPath + } else if (dep.metaClass.hasProperty(dep, 'path')) { + projectPath = dep.path + } + return projectPath ? project(projectPath) : null + } + .findAll { it != null } +} + +def javadocSourceDirsProvider = providers.provider { + files(javadocSourceProjects.get().collect { sourceProject -> + def androidExtension = sourceProject.extensions.findByName("android") + def sourceDirs = androidExtension?.sourceSets?.main?.java?.srcDirs ?: [] + return sourceDirs.findAll { it.exists() } + }) +} + +def sourcesJarTask = tasks.register('sourcesJar', Jar) { + archiveBaseName = 'android-client' + archiveVersion = splitVersion + archiveClassifier = 'sources' + destinationDirectory = layout.buildDirectory.dir('libs') + from(javadocSourceDirsProvider) +} + +tasks.register('javadocJar', Jar) { + archiveBaseName = 'android-client' + archiveVersion = splitVersion + archiveClassifier = 'javadoc' + destinationDirectory = layout.buildDirectory.dir('libs') + def javadocDir = layout.buildDirectory.dir('intermediates/java_doc_dir/release') + from(javadocDir) + doFirst { + if (!javadocDir.get().asFile.exists()) { + throw new GradleException("Javadoc directory not found: ${javadocDir.get().asFile}") + } + } +} + +afterEvaluate { + def agpJavadocTask = tasks.findByName('javaDocRelease') ?: + tasks.findByName('javaDocJar') ?: + tasks.findByName('javaDoc') + if (agpJavadocTask != null) { + tasks.named('javadocJar').configure { + dependsOn agpJavadocTask + } + } +} + def splitPOM = { name = 'Split Android SDK' description = 'Official Split Android SDK' @@ -144,34 +240,34 @@ mavenPublishing { tasks.register('signArtifacts') { description = 'Signs all Maven artifacts with GPG' group = 'publishing' - + doLast { def groupPath = project.group.toString().replace('.', '/') def artifactId = 'android-client' def versionStr = project.version.toString() def artifactDir = file("${buildDir}/publishing/mavenCentral/${groupPath}/${artifactId}/${versionStr}") - + println "Signing artifacts in: ${artifactDir}" - + if (!artifactDir.exists()) { println "WARNING: Artifact directory does not exist: ${artifactDir}" println "Skipping signing. Run publishAllPublicationsToMavenCentralRepository first." return } - + def keyId = project.findProperty('signing.keyId') def password = project.findProperty('signing.password') - + if (!keyId) { throw new GradleException("signing.keyId property is not set in gradle.properties") } if (!password) { throw new GradleException("signing.password property is not set in gradle.properties") } - + println "Using GPG key ID: ${keyId}" println "Using default GPG keyring" - + def artifactsToSign = [] artifactDir.eachFile { file -> def name = file.name @@ -180,15 +276,15 @@ tasks.register('signArtifacts') { artifactsToSign << file } } - + if (artifactsToSign.isEmpty()) { println "WARNING: No artifacts found to sign in ${artifactDir}" return } - + println "Found ${artifactsToSign.size()} artifact(s) to sign:" artifactsToSign.each { println " - ${it.name}" } - + def gpgCommand = null def gpgPaths = [ '/opt/homebrew/bin/gpg', // macOS Homebrew on Apple Silicon - try first @@ -196,7 +292,7 @@ tasks.register('signArtifacts') { '/usr/bin/gpg', '/opt/local/bin/gpg' // MacPorts ] - + for (path in gpgPaths) { def gpgFile = new File(path) if (gpgFile.exists() && gpgFile.canExecute()) { @@ -205,7 +301,7 @@ tasks.register('signArtifacts') { break } } - + if (gpgCommand == null) { try { def process = new ProcessBuilder(['gpg', '--version']) @@ -220,7 +316,7 @@ tasks.register('signArtifacts') { // GPG not in PATH } } - + if (gpgCommand == null) { throw new GradleException("GPG is not installed or not found in common locations.\n" + "Please install GPG:\n" + @@ -229,20 +325,20 @@ tasks.register('signArtifacts') { "Searched in: ${gpgPaths.join(', ')}\n" + "If GPG is installed elsewhere, you can configure the path in the task.") } - + println "Using GPG: ${gpgCommand}" - + artifactsToSign.each { artifact -> def ascFile = new File(artifact.parentFile, "${artifact.name}.asc") - + // Skip if already signed if (ascFile.exists()) { println "Signature already exists for ${artifact.name}, skipping" return } - + println "Signing: ${artifact.name}" - + try { def cmd = [ gpgCommand, @@ -256,29 +352,29 @@ tasks.register('signArtifacts') { '--armor', artifact.absolutePath ] - + // Set GPG_TTY to avoid agent issues def processEnv = new ProcessBuilder().environment() processEnv.put('GPG_TTY', '') - + def process = new ProcessBuilder(cmd) .redirectErrorStream(true) .start() - + def exitCode = process.waitFor() - + if (exitCode != 0) { def output = process.inputStream.text throw new GradleException("Failed to sign ${artifact.name}. Exit code: ${exitCode}\nOutput: ${output}") } - + // Verify the signature was created if (!ascFile.exists()) { throw new GradleException("Signature file was not created for ${artifact.name}") } - + println " ✓ Created signature: ${ascFile.name}" - + // Verify the signature def verifyCmd = [ gpgCommand, @@ -286,24 +382,24 @@ tasks.register('signArtifacts') { ascFile.absolutePath, artifact.absolutePath ] - + def verifyProcess = new ProcessBuilder(verifyCmd) .redirectErrorStream(true) .start() - + def verifyExitCode = verifyProcess.waitFor() - + if (verifyExitCode == 0) { println " ✓ Signature verified successfully" } else { println " ⚠ Warning: Could not verify signature" } - + } catch (Exception e) { throw new GradleException("Failed to sign artifact ${artifact.name}: ${e.message}", e) } } - + println "Successfully signed ${artifactsToSign.size()} artifact(s)" } } @@ -312,65 +408,64 @@ tasks.register('signArtifacts') { afterEvaluate { def signTask = tasks.findByName('signArtifacts') def publishTask = tasks.findByName('publishAllPublicationsToMavenCentralRepository') - + if (signTask && publishTask) { // signArtifacts depends on publishing (so artifacts exist) signTask.dependsOn(publishTask) - + // Make publish task finalize with signing publishTask.finalizedBy(signTask) - + println "Configured signArtifacts task to run after publishAllPublicationsToMavenCentralRepository" } } afterEvaluate { - def emptySourcesJarTask = tasks.findByName("emptySourcesJar") - if (emptySourcesJarTask != null) { - // Ensure only one sources artifact is published - publishing.publications.withType(MavenPublication) { publication -> - if (publication.name == "maven") { - // Keep only the sources from fusedLibraryComponent - publication.artifacts.removeAll { artifact -> - // Remove artifacts that match the emptySourcesJar task output - try { - def artifactFile = artifact.file - if (artifactFile != null && artifactFile.exists()) { - def emptySourcesFile = emptySourcesJarTask.archiveFile.get().asFile - artifactFile == emptySourcesFile - } else { - artifact.classifier == "sources" && artifact.extension == "jar" && - !publication.artifacts.any { - it != artifact && it.classifier == "sources" && - it.buildDependencies != null - } - } - } catch (Exception e) { - false - } - } - } - } - } - // Disable Gradle Module Metadata (.module file) to avoid variant ambiguity tasks.configureEach { task -> // Match tasks related to module metadata generation - if (task.name.startsWith('generateMetadataFileFor') || + if (task.name.startsWith('generateMetadataFileFor') || task.name == 'generateModuleMetadata' || task.class.name.endsWith('GenerateModuleMetadata')) { task.enabled = false } } - + // Remove empty lint.jar from publication to prevent consumer lint crashes // The fused library plugin generates an empty lint.jar without proper Lint-Registry-v2 manifest // This causes lint to crash when consumers use checkDependencies: true publishing.publications.withType(MavenPublication) { publication -> if (publication.name == "maven") { + publication.artifact(tasks.named('javadocJar')) + publication.artifact(tasks.named('sourcesJar')) publication.artifacts.removeAll { artifact -> artifact.file?.name?.endsWith('lint.jar') ?: false } } } } + +// The vanniktech plugin adds emptySourcesJar while AGP 9.0 also provides one from fusedLibraryComponent +// We want to keep the AGP one (merged_sources_jar) which has actual sources +gradle.taskGraph.whenReady { graph -> + graph.allTasks.findAll { it.name.startsWith('publishMavenPublication') }.each { publishTask -> + publishTask.doFirst { + def pub = publication + if (pub.name == "maven") { + def sourcesJarFile = tasks.named('sourcesJar').get().archiveFile.get().asFile + def sourcesArtifacts = pub.artifacts.findAll { it.classifier == "sources" && it.extension == "jar" } + sourcesArtifacts.findAll { it.file != null && it.file != sourcesJarFile }.each { artifact -> + pub.artifacts.remove(artifact) + println "Removed duplicate sources artifact: ${artifact.file?.absolutePath}" + } + + def javadocJarFile = tasks.named('javadocJar').get().archiveFile.get().asFile + def javadocArtifacts = pub.artifacts.findAll { it.classifier == "javadoc" && it.extension == "jar" } + javadocArtifacts.findAll { it.file != null && it.file != javadocJarFile }.each { artifact -> + pub.artifacts.remove(artifact) + println "Removed duplicate javadoc artifact: ${artifact.file?.absolutePath}" + } + } + } + } +} diff --git a/gradle/jacoco-root.gradle b/gradle/jacoco-root.gradle new file mode 100644 index 000000000..d53f9bbef --- /dev/null +++ b/gradle/jacoco-root.gradle @@ -0,0 +1,98 @@ +import org.gradle.testing.jacoco.tasks.JacocoReport +import org.gradle.api.tasks.testing.Test + +// Apply Jacoco at the root to support aggregate reporting +apply plugin: 'jacoco' + +// Define exclusions for JaCoCo coverage +def coverageExclusions = [ + '**/R.class', + '**/R$*.class', + '**/BuildConfig.*', + '**/Manifest*.*', + '**/*Test*.*', + 'android/**/*.*' +] + +// Aggregate Jacoco coverage report for all Android library modules +tasks.register('jacocoRootReport', JacocoReport) { + group = 'verification' + description = 'Generates an aggregate JaCoCo coverage report for all modules' + + def fileFilter = coverageExclusions + + // Collect class directories from all subprojects + def classDirs = subprojects.collectMany { proj -> + def b = proj.buildDir + + // Try multiple possible class directory locations for different AGP versions + // NOTE: If new modules use different compilation output directories, add them here + def possibleClassDirs = [ + new File(b, "intermediates/javac/debug/compileDebugJavaWithJavac/classes"), // AGP 9.0+ location (primary) + new File(b, "classes/java/main") // Standard fallback location + ] + + possibleClassDirs.findAll { it.exists() && it.isDirectory() }.collect { dir -> + proj.fileTree(dir: dir, excludes: fileFilter) + } + } + classDirectories.from = files(classDirs) + + // Collect source directories from all subprojects + def srcDirs = subprojects.collectMany { proj -> + [proj.file("src/main/java"), proj.file("src/main/kotlin")] + }.findAll { it.exists() } + sourceDirectories.from = files(srcDirs) + + // Collect execution data from all subprojects + def execFiles = subprojects.collectMany { proj -> + def b = proj.buildDir + [ + new File(b, "jacoco/testDebugUnitTest.exec"), + new File(b, "outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec") + ].findAll { it.exists() } + } + executionData.from = files(execFiles) + + doFirst { + logger.lifecycle("=== JaCoCo Root Report Generation ===") + logger.lifecycle("Execution data files:") + def execDataFiles = executionData.files + if (execDataFiles.isEmpty() || !execDataFiles.any { it.exists() }) { + logger.warn(" - No execution data files found - coverage report will be empty") + } else { + execDataFiles.each { file -> + if (file.exists()) { + logger.lifecycle(" - Found: $file (${file.length()} bytes)") + } else { + logger.lifecycle(" - Missing: $file") + } + } + } + logger.lifecycle("=======================================") + } + + reports { + xml.required = true + html.required = true + csv.required = false + + xml.outputLocation = file("$buildDir/reports/jacoco/jacocoRootReport/jacocoRootReport.xml") + html.outputLocation = file("$buildDir/reports/jacoco/jacocoRootReport/html") + } + + // Always regenerate the report + outputs.upToDateWhen { false } +} + +// Wire all module unit tests into the aggregate Jacoco report +subprojects { proj -> + // Only consider projects that apply the Jacoco plugin + plugins.withId('jacoco') { + tasks.withType(Test).matching { it.name == 'testDebugUnitTest' }.configureEach { testTask -> + rootProject.tasks.named('jacocoRootReport') { + dependsOn(testTask) + } + } + } +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2cfe32752..c8767b148 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip diff --git a/jacoco.gradle b/jacoco.gradle index 2ee07b86b..ecb43e1cd 100644 --- a/jacoco.gradle +++ b/jacoco.gradle @@ -93,12 +93,6 @@ tasks.register('jacocoTestReport', JacocoReport) { } } - // Log class directories - logger.lifecycle("Class directories:") - classDirectories.files.each { dir -> - logger.lifecycle(" - $dir (${dir.exists() ? 'exists' : 'missing'})") - } - // Log execution data files def execDataFiles = executionData.files logger.lifecycle("Execution data files:") diff --git a/main/build.gradle b/main/build.gradle index 3654b45fe..b16c65544 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -102,3 +102,7 @@ configurations.matching { it.name.toLowerCase().contains("test") }.all { force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0" } } + +tasks.withType(Test).configureEach { + maxParallelForks = Runtime.getRuntime().availableProcessors().intdiv(2) ?: 1 +} diff --git a/main/src/main/java/io/split/android/client/network/HttpClientImpl.java b/main/src/main/java/io/split/android/client/network/HttpClientImpl.java index 3b2a4be33..f41271796 100644 --- a/main/src/main/java/io/split/android/client/network/HttpClientImpl.java +++ b/main/src/main/java/io/split/android/client/network/HttpClientImpl.java @@ -159,6 +159,12 @@ public void close() { } + @VisibleForTesting + @Nullable + SSLSocketFactory getSslSocketFactory() { + return mSslSocketFactory; + } + private Proxy initializeProxy(HttpProxy proxy) { if (proxy != null) { return new Proxy( @@ -279,7 +285,7 @@ public HttpClient build() { if (mProxy != null) { mSslSocketFactory = createSslSocketFactoryFromProxy(mProxy); - } else { + } else if (LegacyTlsUpdater.couldBeOld()) { try { mSslSocketFactory = new Tls12OnlySocketFactory(); } catch (NoSuchAlgorithmException | KeyManagementException e) { @@ -287,6 +293,9 @@ public HttpClient build() { } catch (Exception e) { Logger.e("Unknown TLS v12 error: " + e.getLocalizedMessage()); } + } else { + // Use platform default + mSslSocketFactory = null; } } diff --git a/main/src/test/java/io/split/android/client/network/HttpClientTest.java b/main/src/test/java/io/split/android/client/network/HttpClientTest.java index 2daa5063b..3ecc24ee2 100644 --- a/main/src/test/java/io/split/android/client/network/HttpClientTest.java +++ b/main/src/test/java/io/split/android/client/network/HttpClientTest.java @@ -20,6 +20,8 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; @@ -403,6 +405,40 @@ public SplitAuthenticatedRequest authenticate(@NonNull SplitAuthenticatedRequest mProxyServer.shutdown(); } + @Test + public void buildUsesTls12FactoryWhenLegacyAndNoProxy() throws Exception { + Context context = mock(Context.class); + + try (MockedStatic legacyMock = Mockito.mockStatic(LegacyTlsUpdater.class)) { + legacyMock.when(LegacyTlsUpdater::couldBeOld).thenReturn(true); + + HttpClient legacyClient = new HttpClientImpl.Builder() + .setContext(context) + .setUrlSanitizer(mUrlSanitizerMock) + .build(); + + legacyMock.verify(() -> LegacyTlsUpdater.update(context)); + assertTrue(((HttpClientImpl) legacyClient).getSslSocketFactory() instanceof Tls12OnlySocketFactory); + } + } + + @Test + public void buildUsesDefaultSslWhenNotLegacyAndNoProxy() throws Exception { + Context context = mock(Context.class); + + try (MockedStatic legacyMock = Mockito.mockStatic(LegacyTlsUpdater.class)) { + legacyMock.when(LegacyTlsUpdater::couldBeOld).thenReturn(false); + + HttpClient modernClient = new HttpClientImpl.Builder() + .setContext(context) + .setUrlSanitizer(mUrlSanitizerMock) + .build(); + + legacyMock.verify(() -> LegacyTlsUpdater.update(context), Mockito.never()); + assertNull(((HttpClientImpl) modernClient).getSslSocketFactory()); + } + } + @Test public void copyStreamToByteArrayWithSimpleString() { diff --git a/sonar-project.properties b/sonar-project.properties index 930a7d2dc..5d10c5bdb 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,20 +2,36 @@ sonar.projectKey=splitio_android-client sonar.projectName=android-client -# Path to source directories -sonar.sources=src/main/java +# Path to source directories (multi-module) +# Root project contains modules: main, logger +sonar.sources=main/src/main/java,logger/src/main/java -# Path to compiled classes -sonar.java.binaries=build/intermediates/runtime_library_classes_dir/debug +# Path to compiled classes (multi-module) +# Include binary paths for all modules: main, logger +sonar.java.binaries=\ + main/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ + logger/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes -# Path to test directories -sonar.tests=src/test/java,src/androidTest/java,src/sharedTest/java +# Path to dependency/libraries jars (multi-module) +sonar.java.libraries=\ + main/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + main/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + main/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + main/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar,\ + logger/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + logger/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + logger/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + logger/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar + +# Path to test directories (multi-module) +# Only include test source folders that are guaranteed to exist in all environments +sonar.tests=main/src/test/java,main/src/androidTest/java,main/src/sharedTest/java,logger/src/test/java # Encoding of the source code sonar.sourceEncoding=UTF-8 -# Include test coverage reports - prioritize combined report -sonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml +# Include aggregate test coverage report from all modules +sonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml # Exclusions sonar.exclusions=**/R.class,**/R$*.class,**/BuildConfig.*,**/Manifest*.*,**/*Test*.*,android/**/*.*