Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/sonarcloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ jobs:
args: >
-Dsonar.organization=formbricks
-Dsonar.projectKey=formbricks_android
-Dsonar.java.binaries=android/build/tmp/kotlin-classes/debug
-Dsonar.java.binaries=android/build/intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes
-Dsonar.sources=android/src/main/java
-Dsonar.tests=android/src/androidTest/java
-Dsonar.coverage.jacoco.xmlReportPaths=android/build/reports/jacoco/jacocoAndroidTestReport/jacocoAndroidTestReport.xml
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,24 @@ Formbricks.setLanguage("de")
Formbricks.logout()
```

### Dark mode

Surveys render light by default. Call `setAppearance` to change it:

```kotlin
Formbricks.setAppearance(FormbricksAppearance.DARK) // LIGHT, DARK or SYSTEM
```

- Works before or after `setup`, or pass it in the config with `.setAppearance(FormbricksAppearance.DARK)`. A string overload (`"dark"`) is available too.
- An open survey switches in place; the typed answer and current question stay.
- `SYSTEM` follows **your app's** night mode (including `AppCompatDelegate.setDefaultNightMode`), not the phone's, and updates live.
- If your app keeps its theme outside AppCompat (for example a Compose `darkTheme` flag), call `setAppearance(LIGHT)` or `setAppearance(DARK)` yourself when it changes.
- To keep an open survey's answers when the night mode changes, let the host Activity handle `uiMode` in `android:configChanges`. Otherwise Android recreates the Activity and the survey reloads, as it does on rotation.
- Kept across `logout()`, forgotten on app restart, never sent to the server. An unknown value is logged and falls back to light.
- Needs a Formbricks server that supports dark mode; an older server keeps surveys light.

Custom CSS configured in Formbricks needs no SDK call; it arrives with the workspace state.

## Contributing

We welcome issues and pull requests on our GitHub repository.
Expand Down
2 changes: 1 addition & 1 deletion android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ tasks.register<JacocoReport>("jacocoAndroidTestReport") {
)

val debugTree = fileTree(mapOf(
"dir" to layout.buildDirectory.dir("tmp/kotlin-classes/debug").get().asFile,
"dir" to layout.buildDirectory.dir("intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes").get().asFile,
"excludes" to fileFilter
))

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package com.formbricks.android.helper

import android.content.Context
import android.content.res.Configuration
import androidx.appcompat.app.AppCompatDelegate
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.formbricks.android.Formbricks
import com.formbricks.android.model.workspace.CustomCss
import com.formbricks.android.model.workspace.Settings
import com.google.gson.Gson
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class AppearanceInstrumentedTest {
private val night = Configuration.UI_MODE_NIGHT_YES
private val day = Configuration.UI_MODE_NIGHT_NO

@Before
@After
fun reset() {
Appearance.reset()
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
}

/** A context whose configuration says [nightFlag], like the Activity after the WebView reset. */
private fun contextWithNight(nightFlag: Int): Context {
val base = InstrumentationRegistry.getInstrumentation().targetContext
val config = Configuration(base.resources.configuration).apply {
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or nightFlag
}
return base.createConfigurationContext(config)
}

@Test
fun defaultsToLightWhateverTheAppTheme() {
assertEquals(FormbricksAppearance.LIGHT, Appearance.current)
assertEquals("light", Appearance.resolve(night))
}

@Test
fun lightAndDarkWinOverTheAppTheme() {
Appearance.set(FormbricksAppearance.LIGHT)
assertEquals("light", Appearance.resolve(night))
Appearance.set(FormbricksAppearance.DARK)
assertEquals("dark", Appearance.resolve(day))
}

@Test
fun systemFollowsTheAppNightModeAndNeverResolvesToSystem() {
Appearance.set(FormbricksAppearance.SYSTEM)
assertEquals("dark", Appearance.resolve(night or 0x01))
assertEquals("light", Appearance.resolve(day or 0x01))
assertEquals("light", Appearance.resolve(0))
}

@Test
fun systemResolvesFromAContextConfiguration() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
Appearance.set(FormbricksAppearance.SYSTEM)
val expected = if (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
) "dark" else "light"
assertEquals(expected, Appearance.resolve(context))
}

@Test
fun systemPrefersAppCompatsForcedNightModeOverTheConfiguration() {
Appearance.set(FormbricksAppearance.SYSTEM)
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
assertEquals("dark", Appearance.resolve(contextWithNight(day)))
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
assertEquals("light", Appearance.resolve(contextWithNight(night)))
}

@Test
fun systemFallsBackToTheConfigurationWhenAppCompatFollowsTheSystem() {
Appearance.set(FormbricksAppearance.SYSTEM)
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
assertEquals("dark", Appearance.resolve(contextWithNight(night)))
assertEquals("light", Appearance.resolve(contextWithNight(day)))
}

@Test
fun forcedLightAndDarkIgnoreAppCompat() {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
assertEquals("light", Appearance.resolve(contextWithNight(day), FormbricksAppearance.LIGHT))
}

@Test
fun publicApiWorksBeforeSetupFromEnumOrString() {
Formbricks.setAppearance(FormbricksAppearance.DARK)
assertEquals(FormbricksAppearance.DARK, Appearance.current)
Formbricks.setAppearance("system")
assertEquals(FormbricksAppearance.SYSTEM, Appearance.current)
}

@Test
fun anUnknownStringFallsBackToLight() {
Appearance.set("dark")
Appearance.set("sepia")
assertEquals(FormbricksAppearance.LIGHT, Appearance.current)
assertNull(FormbricksAppearance.from(null))
assertEquals(FormbricksAppearance.DARK, FormbricksAppearance.from(" Dark "))
}

@Test
fun configBuilderCarriesTheAppearance() {
val withIt = FormbricksConfig.Builder("https://app.formbricks.com", "ws")
.setAppearance(FormbricksAppearance.DARK)
.build()
assertEquals(FormbricksAppearance.DARK, withIt.appearance)
assertNull(FormbricksConfig.Builder("https://app.formbricks.com", "ws").build().appearance)
}

@Test
fun listenersHearChangesUntilRemoved() {
var calls = 0
val listener = { calls++; Unit }
Appearance.addListener(listener)
Appearance.set(FormbricksAppearance.DARK)
assertEquals(1, calls)
Appearance.removeListener(listener)
Appearance.set(FormbricksAppearance.LIGHT)
assertEquals(1, calls)
}

@Test
fun switchScriptToleratesAnOlderRenderer() {
assertEquals("window.formbricksSurveys?.setAppearance?.('dark');", Appearance.switchScript("dark"))
}

@Test
fun noCustomCssSendsNoKey() {
assertNull(CustomCss.props(null, null))
assertNull(CustomCss.props(CustomCss("", null), CustomCss()))
}

@Test
fun customCssIsForwardedUntouchedWithEmptyFieldsOmitted() {
val props = CustomCss.props(CustomCss(light = ".a{color:red}"), CustomCss(dark = ".b{margin:0}"))!!
assertEquals(".a{color:red}", props["workspace"].asJsonObject["light"].asString)
assertFalse(props["workspace"].asJsonObject.has("dark"))
assertEquals(".b{margin:0}", props["survey"].asJsonObject["dark"].asString)
}

@Test
fun settingsDecodeWithAndWithoutCustomCss() {
val with = Gson().fromJson("""{"customCss":{"light":".a{}"}}""", Settings::class.java)
assertTrue(with.customCss?.light == ".a{}")
assertNull(Gson().fromJson("{}", Settings::class.java).customCss)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.formbricks.android.manager.UserManager
import com.formbricks.android.model.workspace.WorkspaceDataHolder
import com.formbricks.android.model.workspace.WorkspaceResponseData
import com.formbricks.android.model.workspace.WorkspaceData
import com.formbricks.android.model.workspace.CustomCss
import com.formbricks.android.model.workspace.Settings
import com.formbricks.android.model.workspace.Survey
import com.formbricks.android.model.workspace.SurveyOverlay
Expand Down Expand Up @@ -389,5 +390,53 @@ class FormbricksViewModelInstrumentedTest {
.invoke(viewModel, wsHolder, surveyId) as String
}

@Test
fun testGetJson_appearanceAndCustomCss() {
val survey = Survey(
id = "s1", triggers = null, recontactDays = null, displayLimit = null, delay = null,
displayPercentage = null, displayOption = null, segment = null, styling = null, languages = null,
customCss = CustomCss(dark = ".s{margin:0}")
)
val settings = Settings(
id = "proj1", recontactDays = null, clickOutsideClose = null, overlay = null, placement = null,
inAppSurveyBranding = null, styling = null, customCss = CustomCss(light = ".w{color:red}")
)
val holder = WorkspaceDataHolder(
data = WorkspaceResponseData(
data = WorkspaceData(surveys = listOf(survey), actionClasses = null, settings = settings),
expiresAt = null
),
originalResponseMap = mapOf()
)
val viewModel = FormbricksViewModel()
val method = viewModel.javaClass.getDeclaredMethod(
"getJson", WorkspaceDataHolder::class.java, String::class.java, String::class.java
).apply { isAccessible = true }

val json = com.google.gson.JsonParser.parseString(method.invoke(viewModel, holder, "s1", "dark") as String).asJsonObject

assertEquals("dark", json["appearance"].asString)
assertEquals(".w{color:red}", json["customCss"].asJsonObject["workspace"].asJsonObject["light"].asString)
assertEquals(".s{margin:0}", json["customCss"].asJsonObject["survey"].asJsonObject["dark"].asString)
}

@Test
fun testGetJson_defaultsToLightAndSendsNoCustomCss() {
val json = com.google.gson.JsonParser.parseString(invokeGetJsonWithNoSurveys()).asJsonObject
assertEquals("light", json["appearance"].asString)
assertFalse(json.has("customCss"))
}

// endregion
@Test
fun testHtml_postsOnSurveyRenderedOnlyAfterRenderSurvey() {
// The fragment holds appearance changes until this message, so it must follow renderSurvey.
val html = FormbricksViewModel::class.java.getDeclaredField("htmlTemplate")
.apply { isAccessible = true }
.get(FormbricksViewModel()) as String
val render = html.indexOf("window.formbricksSurveys.renderSurvey(surveyProps);")
val rendered = html.indexOf("""FormbricksJavascript.message(JSON.stringify({ event: "onSurveyRendered" }));""")
assertTrue("renderSurvey call missing", render >= 0)
assertTrue("onSurveyRendered must be posted after renderSurvey", rendered > render)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class WebAppInterfaceInstrumentedTest {
override fun onFinished() { finished = true }
override fun onFilePick(data: FileUploadData) { filePick = data }
override fun onSurveyLibraryLoadError() { surveyLibraryLoadError = true }
var surveyRendered = false
override fun onSurveyRendered() { surveyRendered = true }
}

@Before
Expand Down Expand Up @@ -86,6 +88,13 @@ class WebAppInterfaceInstrumentedTest {
assertEquals(true, callback.filePick?.fileUploadParams?.allowMultipleFiles)
}

@Test
fun testMessage_onSurveyRendered() {
assertFalse(callback.surveyRendered)
webAppInterface.message("{\"event\":\"onSurveyRendered\"}")
assertTrue(callback.surveyRendered)
}

@Test
fun testMessage_onSurveyLibraryLoadError() {
val json = "{\"event\":\"onSurveyLibraryLoadError\"}"
Expand Down
29 changes: 29 additions & 0 deletions android/src/main/java/com/formbricks/android/Formbricks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import android.os.Looper
import androidx.annotation.Keep
import androidx.fragment.app.FragmentManager
import com.formbricks.android.api.FormbricksApi
import com.formbricks.android.helper.Appearance
import com.formbricks.android.helper.FormbricksAppearance
import com.formbricks.android.helper.FormbricksConfig
import com.formbricks.android.logger.Logger
import com.formbricks.android.manager.EmbeddedDataManager
Expand Down Expand Up @@ -66,6 +68,10 @@ object Formbricks {
*
*/
fun setup(context: Context, config: FormbricksConfig, forceRefresh: Boolean = false) {
// Local state, not part of the SDK config, and must be in place before the first survey
// renders, so it is applied ahead of everything else, even on a repeat setup.
config.appearance?.let { Appearance.set(it) }

if (isInitialized && !forceRefresh) {
val error = SDKError.sdkIsAlreadyInitialized
Logger.e(error)
Expand Down Expand Up @@ -271,6 +277,29 @@ object Formbricks {
UserManager.setLanguage(language)
}

/**
* Sets how surveys render: [FormbricksAppearance.LIGHT] (default), [FormbricksAppearance.DARK],
* or [FormbricksAppearance.SYSTEM] to follow the app's own night mode, not the phone's.
*
* Works before or after [setup], and switches an open survey in place without losing answers.
* Kept across [logout], forgotten when the app restarts, and never sent to the server.
*
* ```
* Formbricks.setAppearance(FormbricksAppearance.DARK)
* ```
*/
fun setAppearance(appearance: FormbricksAppearance) {
Appearance.set(appearance)
}

/**
* String overload of [setAppearance] ("light", "dark" or "system"). An unknown value is logged
* and falls back to light.
*/
fun setAppearance(appearance: String) {
Appearance.set(appearance)
}

/**
* Tracks an action with the given [String]. The SDK will process the action and it will present the survey if any of them can be triggered.
* The SDK must be initialized before calling this method.
Expand Down
Loading
Loading