diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 0000000..243564d --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,83 @@ +name: SonarCloud Analysis +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + merge_group: + +permissions: + contents: read + +jobs: + sonarcloud: + name: SonarCloud + runs-on: ubuntu-latest + + steps: + - name: Harden Runner + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + with: + egress-policy: audit + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + + - name: Set up JDK 17 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + java-version: "17" + distribution: "temurin" + cache: gradle + + - name: Setup Android SDK + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2 + + # Setup KVM for hardware acceleration + - name: Setup KVM + run: | + sudo apt-get update + sudo apt-get install -y qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils + sudo adduser $USER kvm + sudo chown $USER /dev/kvm + sudo chmod 777 /dev/kvm + + # Accept Android SDK licenses + - name: Accept Android SDK licenses + run: yes | sdkmanager --licenses || true + + # Install required SDK components + - name: Install SDK components + run: | + sdkmanager "platform-tools" "platforms;android-33" "system-images;android-33;google_apis;x86_64" + sdkmanager --install "emulator" + + - name: Run Instrumented Tests + uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # v2.34.0 + with: + api-level: 33 + target: google_apis + arch: x86_64 + profile: pixel_6 + force-avd-creation: true + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -accel on -no-snapshot + disable-animations: true + script: adb start-server && adb wait-for-device && until adb shell getprop sys.boot_completed 2>/dev/null | grep -q '^1$'; do echo "Waiting for boot completion..."; sleep 5; done && adb devices && ./gradlew jacocoAndroidTestReport && (adb emu kill || true) && sleep 5 + + - name: SonarCloud Scan + uses: SonarSource/sonarqube-scan-action@v5.2.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + projectBaseDir: . + args: > + -Dsonar.organization=formbricks + -Dsonar.projectKey=formbricks_android + -Dsonar.java.binaries=android/build/tmp/kotlin-classes/debug + -Dsonar.sources=android/src/main/java + -Dsonar.tests=android/src/androidTest/java + -Dsonar.coverage.jacoco.xmlReportPaths=android/build/reports/jacoco/jacocoAndroidTestReport/jacocoAndroidTestReport.xml + -Dsonar.verbose=true diff --git a/.gitignore b/.gitignore index faf530b..e6d5107 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,19 @@ .externalNativeBuild .cxx local.properties + +# IDE +.vscode/ +.settings/ + +# Coverage +/tools/ +*.exec +*.ec +/coverage/ +/reports/ +generate-*-coverage.sh + +# Generated files +**/build/ +**/reports/ diff --git a/README.md b/README.md index ec2d9a5..50e5e59 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ dependencies { } ``` -Enable DataBinding in your app’s module build.gradle.kts: +Enable DataBinding in your app's module build.gradle.kts: ```kotlin android { @@ -63,6 +63,42 @@ Formbricks.logout() We welcome issues and pull requests on our GitHub repository. +## Testing and Code Coverage + +### Running Tests + +To run the instrumented tests, make sure you have an Android emulator running or a physical device connected, then execute: + +```bash +./gradlew connectedDebugAndroidTest +``` + +### Generating Coverage Reports + +The SDK uses JaCoCo for code coverage reporting. To generate a coverage report for instrumented tests: + +1. Make sure you have an Android emulator running or a physical device connected +2. Run the provided script: + ```bash + ./generate-instrumented-coverage.sh + ``` + This will: + - Run the instrumented tests + - Generate a JaCoCo coverage report + - Open the HTML report in your default browser + +Alternatively, you can run the Gradle task directly: + +```bash +./gradlew jacocoAndroidTestReport +``` + +The coverage report will be generated at: + +``` +android/build/reports/jacoco/jacocoAndroidTestReport/html/index.html +``` + ## License This SDK is released under the MIT License. diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 5b29994..9976aa8 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -8,12 +8,21 @@ plugins { id("org.jetbrains.dokka") version "1.9.10" id("jacoco") id("com.vanniktech.maven.publish") version "0.31.0" + id("org.sonarqube") version "4.4.1.3373" } +// Import JaCoCo configuration +// apply(from = "../jacoco.gradle.kts") + version = "1.1.0" val groupId = "com.formbricks" val artifactId = "android" +// Configure JaCoCo version +jacoco { + toolVersion = "0.8.11" +} + android { namespace = "com.formbricks.android" compileSdk = 35 @@ -28,6 +37,7 @@ android { buildTypes { getByName("debug") { enableAndroidTestCoverage = true + isTestCoverageEnabled = true // For backward compatibility } release { isMinifyEnabled = true @@ -62,15 +72,6 @@ android { } } -tasks.withType().configureEach { - extensions.configure { - isIncludeNoLocationClasses = true - excludes = listOf( - "jdk.internal.*", - ) - } -} - dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.annotation) @@ -91,7 +92,6 @@ dependencies { implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.databinding.common) - testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } @@ -126,4 +126,63 @@ mavenPublishing { url = "https://github.com/formbricks/android" } } +} + +// Add JaCoCo tasks +tasks.register("jacocoAndroidTestReport") { + dependsOn("connectedDebugAndroidTest") + + reports { + xml.required.set(true) + html.required.set(true) + } + + val fileFilter = listOf( + "**/R.class", + "**/R\$*.class", + "**/BuildConfig.*", + "**/Manifest*.*", + "**/*Test*.*", + "android/databinding/**/*.class", + "android/databinding/*Binding.*", + "android/BuildConfig.*", + "**/*\$*.*", + "**/Lambda\$*.class", + "**/Lambda.class", + "**/*Lambda.class", + "**/*Lambda*.class", + "**/*_MembersInjector.class", + "**/Dagger*Component.class", + "**/Dagger*Component\$*.class", + "**/*Module_*Factory.class" + ) + + val debugTree = fileTree(mapOf( + "dir" to layout.buildDirectory.dir("tmp/kotlin-classes/debug").get().asFile, + "excludes" to fileFilter + )) + + val mainSrc = "${project.projectDir}/src/main/java" + + sourceDirectories.setFrom(files(mainSrc)) + classDirectories.setFrom(files(debugTree)) + executionData.setFrom(fileTree(mapOf( + "dir" to layout.buildDirectory.get().asFile, + "includes" to listOf( + "outputs/code_coverage/debugAndroidTest/connected/**/*.ec", + "outputs/code_coverage/debugAndroidTest/connected/**/*.exec" + ) + ))) +} + +// Configure Sonar +sonar { + properties { + property("sonar.coverage.jacoco.xmlReportPaths", + layout.buildDirectory.file("reports/jacoco/jacocoAndroidTestReport/jacocoAndroidTestReport.xml").get().asFile.path) + } +} + +tasks.sonar { + dependsOn("jacocoAndroidTestReport") } \ No newline at end of file diff --git a/android/src/androidTest/resources/Environment.json b/android/src/androidTest/assets/Environment.json similarity index 99% rename from android/src/androidTest/resources/Environment.json rename to android/src/androidTest/assets/Environment.json index 4e04700..962cb8b 100644 --- a/android/src/androidTest/resources/Environment.json +++ b/android/src/androidTest/assets/Environment.json @@ -346,6 +346,7 @@ "triggers": [ { "actionClass": { + "id": "cm6ow6hht000isf0k39hbmi5f", "name": "Clicked the demo button" } } diff --git a/android/src/androidTest/resources/User.json b/android/src/androidTest/assets/User.json similarity index 100% rename from android/src/androidTest/resources/User.json rename to android/src/androidTest/assets/User.json diff --git a/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt index b72f5b8..4f4ee70 100644 --- a/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt @@ -5,6 +5,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.formbricks.android.api.FormbricksApi import com.formbricks.android.helper.FormbricksConfig +import com.formbricks.android.logger.Logger import com.formbricks.android.manager.SurveyManager import com.formbricks.android.manager.UserManager import org.junit.Assert.assertEquals @@ -44,7 +45,7 @@ class FormbricksInstrumentedTest { @Test fun testFormbricks() { val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.formbricks.formbrickssdk.test", appContext.packageName) + assertEquals("com.formbricks.android.test", appContext.packageName) // Everything should be in the default state assertFalse(Formbricks.isInitialized) @@ -90,7 +91,7 @@ class FormbricksInstrumentedTest { (FormbricksApi.service as MockFormbricksApiService).isErrorResponseNeeded = true assertFalse(SurveyManager.hasApiError) SurveyManager.refreshEnvironmentIfNeeded(true) - waitForSeconds(1) + waitForSeconds(3) // Increased wait time to 3 seconds assertTrue(SurveyManager.hasApiError) (FormbricksApi.service as MockFormbricksApiService).isErrorResponseNeeded = false @@ -113,9 +114,24 @@ class FormbricksInstrumentedTest { // Track a known event, thus, the survey should be shown. SurveyManager.isShowingSurvey = false + + // Track the event but don't show the survey + val firstSurveyBeforeTrack = SurveyManager.filteredSurveys.firstOrNull() + assertNotNull("Should have a survey before tracking", firstSurveyBeforeTrack) + assertEquals("Should have the correct survey ID", surveyID, firstSurveyBeforeTrack?.id) + + val actionClasses = SurveyManager.environmentDataHolder?.data?.data?.actionClasses ?: listOf() + val clickDemoButtonAction = actionClasses.firstOrNull { it.key == "click_demo_button" } + assertNotNull("Should have click_demo_button action class", clickDemoButtonAction) + + val triggers = firstSurveyBeforeTrack?.triggers ?: listOf() + val matchingTrigger = triggers.firstOrNull { it.actionClass?.id == clickDemoButtonAction?.id } + assertNotNull("Survey should have matching trigger", matchingTrigger) + + // Now track the event Formbricks.track("click_demo_button") waitForSeconds(1) - assertTrue(SurveyManager.isShowingSurvey) + assertTrue("Survey should be marked as showing", SurveyManager.isShowingSurvey) // Validate display and response SurveyManager.onNewDisplay(surveyID) diff --git a/android/src/androidTest/java/com/formbricks/android/MockFormbricksApiService.kt b/android/src/androidTest/java/com/formbricks/android/MockFormbricksApiService.kt index 419c83c..ea09895 100644 --- a/android/src/androidTest/java/com/formbricks/android/MockFormbricksApiService.kt +++ b/android/src/androidTest/java/com/formbricks/android/MockFormbricksApiService.kt @@ -1,23 +1,32 @@ package com.formbricks.android +import androidx.test.platform.app.InstrumentationRegistry import com.formbricks.android.model.environment.EnvironmentDataHolder import com.formbricks.android.model.environment.EnvironmentResponse import com.formbricks.android.model.user.PostUserBody import com.formbricks.android.model.user.UserResponse import com.formbricks.android.network.FormbricksApiService import com.google.gson.Gson +import com.formbricks.android.model.error.SDKError class MockFormbricksApiService: FormbricksApiService() { private val gson = Gson() - private val environmentJson = MockFormbricksApiService::class.java.getResource("/Environment.json")!!.readText() - private val userJson = MockFormbricksApiService::class.java.getResource("/User.json")!!.readText() - private val environment = gson.fromJson(environmentJson, EnvironmentResponse::class.java) - private val user = gson.fromJson(userJson, UserResponse::class.java) + private val environment: EnvironmentResponse + private val user: UserResponse var isErrorResponseNeeded = false + init { + val context = InstrumentationRegistry.getInstrumentation().context + val environmentJson = context.assets.open("Environment.json").bufferedReader().readText() + val userJson = context.assets.open("User.json").bufferedReader().readText() + + environment = gson.fromJson(environmentJson, EnvironmentResponse::class.java) + user = gson.fromJson(userJson, UserResponse::class.java) + } + override fun getEnvironmentStateObject(environmentId: String): Result { return if (isErrorResponseNeeded) { - Result.failure(RuntimeException()) + Result.failure(SDKError.unableToRefreshEnvironment) } else { Result.success(EnvironmentDataHolder(environment.data, mapOf())) } @@ -25,11 +34,9 @@ class MockFormbricksApiService: FormbricksApiService() { override fun postUser(environmentId: String, body: PostUserBody): Result { return if (isErrorResponseNeeded) { - Result.failure(RuntimeException()) + Result.failure(SDKError.unableToPostResponse) } else { Result.success(user) } - } - } \ No newline at end of file diff --git a/android/src/main/java/com/formbricks/android/manager/SurveyManager.kt b/android/src/main/java/com/formbricks/android/manager/SurveyManager.kt index 38021b7..2e77330 100644 --- a/android/src/main/java/com/formbricks/android/manager/SurveyManager.kt +++ b/android/src/main/java/com/formbricks/android/manager/SurveyManager.kt @@ -135,7 +135,9 @@ object SurveyManager { val actionClass = codeActionClasses.firstOrNull { it.key == action } val firstSurveyWithActionClass = filteredSurveys.firstOrNull { survey -> val triggers = survey.triggers ?: listOf() - triggers.firstOrNull { it.actionClass?.name.equals(actionClass?.name) } != null + triggers.firstOrNull { trigger -> + trigger.actionClass?.name == actionClass?.name + } != null } if (firstSurveyWithActionClass == null) { diff --git a/android/src/main/java/com/formbricks/android/model/environment/ActionClassReference.kt b/android/src/main/java/com/formbricks/android/model/environment/ActionClassReference.kt index 712ee28..e299d74 100644 --- a/android/src/main/java/com/formbricks/android/model/environment/ActionClassReference.kt +++ b/android/src/main/java/com/formbricks/android/model/environment/ActionClassReference.kt @@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable @Serializable data class ActionClassReference( + @SerializedName("id") val id: String?, @SerializedName("name") val name: String? ) \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 20e2a01..cc6a08e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,7 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true + +# Enable build config generation +android.defaults.buildfeatures.buildconfig=true \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..5531bb5 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,23 @@ +sonar.projectKey=formbricks_android +sonar.organization=formbricks + +# Sources +sonar.sources=android/src/main/java +sonar.java.source=11 + +# Tests +sonar.tests=android/src/androidTest/java +sonar.junit.reportPaths=android/build/outputs/androidTest-results/connected + +# Coverage +sonar.coverage.jacoco.xmlReportPaths=android/build/reports/jacoco/jacocoAndroidTestReport/jacocoAndroidTestReport.xml + +# Encoding of the source code. Default is default system encoding +sonar.sourceEncoding=UTF-8 + +# Exclusions +sonar.exclusions=**/test/**/*,**/androidTest/**/*,**/*.js,**/*.json +sonar.coverage.exclusions=**/test/**/*,**/androidTest/**/*,**/*.js,**/*.json + +# Debug +sonar.verbose=true \ No newline at end of file