diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index dbe7355..71fc84d 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -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 diff --git a/README.md b/README.md index e0affef..a890654 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 3bfa545..9c45e49 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -268,7 +268,7 @@ tasks.register("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 )) diff --git a/android/src/androidTest/java/com/formbricks/android/helper/AppearanceInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/helper/AppearanceInstrumentedTest.kt new file mode 100644 index 0000000..7aad684 --- /dev/null +++ b/android/src/androidTest/java/com/formbricks/android/helper/AppearanceInstrumentedTest.kt @@ -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) + } +} diff --git a/android/src/androidTest/java/com/formbricks/android/webview/FormbricksViewModelInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/webview/FormbricksViewModelInstrumentedTest.kt index 4235a2a..a59a679 100644 --- a/android/src/androidTest/java/com/formbricks/android/webview/FormbricksViewModelInstrumentedTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/webview/FormbricksViewModelInstrumentedTest.kt @@ -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 @@ -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) + } } diff --git a/android/src/androidTest/java/com/formbricks/android/webview/WebAppInterfaceInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/webview/WebAppInterfaceInstrumentedTest.kt index 74ef243..c3b1aa3 100644 --- a/android/src/androidTest/java/com/formbricks/android/webview/WebAppInterfaceInstrumentedTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/webview/WebAppInterfaceInstrumentedTest.kt @@ -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 @@ -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\"}" diff --git a/android/src/main/java/com/formbricks/android/Formbricks.kt b/android/src/main/java/com/formbricks/android/Formbricks.kt index 6282c09..a5c1973 100644 --- a/android/src/main/java/com/formbricks/android/Formbricks.kt +++ b/android/src/main/java/com/formbricks/android/Formbricks.kt @@ -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 @@ -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) @@ -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. diff --git a/android/src/main/java/com/formbricks/android/helper/Appearance.kt b/android/src/main/java/com/formbricks/android/helper/Appearance.kt new file mode 100644 index 0000000..a0816d4 --- /dev/null +++ b/android/src/main/java/com/formbricks/android/helper/Appearance.kt @@ -0,0 +1,87 @@ +package com.formbricks.android.helper + +import android.content.Context +import android.content.res.Configuration +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import com.formbricks.android.logger.Logger +import java.util.concurrent.CopyOnWriteArraySet + +/** + * The in-memory appearance state (ENG-3452). Never persisted, never sent to the server, and left + * alone by `logout()`, so a fresh app launch starts light (ENG-3551). + */ +internal object Appearance { + @Volatile + var current: FormbricksAppearance = FormbricksAppearance.LIGHT + private set + + private val listeners = CopyOnWriteArraySet<() -> Unit>() + + /** Falls back to light, and logs, for a value that is not a known appearance. */ + fun set(value: String?) { + val parsed = FormbricksAppearance.from(value) + if (parsed == null) { + Logger.e(RuntimeException("Unknown appearance \"$value\", falling back to light")) + } + set(parsed ?: FormbricksAppearance.LIGHT) + } + + fun set(appearance: FormbricksAppearance) { + current = appearance + listeners.forEach { it() } + } + + fun addListener(listener: () -> Unit) { + listeners.add(listener) + } + + fun removeListener(listener: () -> Unit) { + listeners.remove(listener) + } + + /** + * What the renderer understands: always "light" or "dark", never "system". [context] should be + * the Activity the survey is shown in. An explicit AppCompat night mode (the activity's local + * one, else the default) wins over the configuration: creating the first WebView in a process + * resets the Activity's configuration to the phone's, losing the app's own night mode. + */ + fun resolve(context: Context, appearance: FormbricksAppearance = current): String = + resolve(appCompatUiMode(context) ?: context.resources.configuration.uiMode, appearance) + + /** AppCompat's forced night mode as a `uiMode` night flag, or null when it follows the system. */ + private fun appCompatUiMode(context: Context): Int? { + val local = (context as? AppCompatActivity)?.delegate?.localNightMode + ?: AppCompatDelegate.MODE_NIGHT_UNSPECIFIED + val mode = if (local != AppCompatDelegate.MODE_NIGHT_UNSPECIFIED) { + local + } else { + AppCompatDelegate.getDefaultNightMode() + } + return when (mode) { + AppCompatDelegate.MODE_NIGHT_YES -> Configuration.UI_MODE_NIGHT_YES + AppCompatDelegate.MODE_NIGHT_NO -> Configuration.UI_MODE_NIGHT_NO + else -> null + } + } + + fun resolve(uiMode: Int, appearance: FormbricksAppearance = current): String = when (appearance) { + FormbricksAppearance.LIGHT -> "light" + FormbricksAppearance.DARK -> "dark" + FormbricksAppearance.SYSTEM -> + if (uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) "dark" else "light" + } + + /** + * JavaScript that flips an open survey in place. Optional chaining: a server whose renderer + * predates `setAppearance` leaves the survey light instead of throwing. + */ + fun switchScript(resolved: String): String = + "window.formbricksSurveys?.setAppearance?.('$resolved');" + + /** Test-only: back to the cold-start state. */ + fun reset() { + current = FormbricksAppearance.LIGHT + listeners.clear() + } +} diff --git a/android/src/main/java/com/formbricks/android/helper/FormbricksAppearance.kt b/android/src/main/java/com/formbricks/android/helper/FormbricksAppearance.kt new file mode 100644 index 0000000..80d9943 --- /dev/null +++ b/android/src/main/java/com/formbricks/android/helper/FormbricksAppearance.kt @@ -0,0 +1,19 @@ +package com.formbricks.android.helper + +import androidx.annotation.Keep + +/** + * How surveys render. [SYSTEM] follows the host app's own theme (its night mode, including + * `AppCompatDelegate.setDefaultNightMode`), not the phone's. + */ +@Keep +enum class FormbricksAppearance(internal val value: String) { + LIGHT("light"), + DARK("dark"), + SYSTEM("system"); + + internal companion object { + fun from(value: String?): FormbricksAppearance? = + entries.firstOrNull { it.value == value?.trim()?.lowercase() } + } +} diff --git a/android/src/main/java/com/formbricks/android/helper/FormbricksConfig.kt b/android/src/main/java/com/formbricks/android/helper/FormbricksConfig.kt index cf9cb82..f0d68cf 100644 --- a/android/src/main/java/com/formbricks/android/helper/FormbricksConfig.kt +++ b/android/src/main/java/com/formbricks/android/helper/FormbricksConfig.kt @@ -18,7 +18,9 @@ class FormbricksConfig private constructor( val loggingEnabled: Boolean, val fragmentManager: FragmentManager?, /** True if this config was built using the deprecated `environmentId` entry point. */ - val usedDeprecatedEnvironmentId: Boolean + val usedDeprecatedEnvironmentId: Boolean, + /** How surveys render; null leaves whatever [com.formbricks.android.Formbricks.setAppearance] already set. */ + val appearance: FormbricksAppearance? = null ) { /** Backward-compatible alias for [workspaceId]. */ @Deprecated( @@ -34,6 +36,7 @@ class FormbricksConfig private constructor( private var loggingEnabled = false private var fragmentManager: FragmentManager? = null private var usedDeprecatedEnvironmentId: Boolean = false + private var appearance: FormbricksAppearance? = null fun setUserId(userId: String): Builder { this.userId = userId @@ -85,6 +88,12 @@ class FormbricksConfig private constructor( return this } + /** Sets how surveys render: light (default), dark, or system. */ + fun setAppearance(appearance: FormbricksAppearance): Builder { + this.appearance = appearance + return this + } + fun build(): FormbricksConfig { return FormbricksConfig( appUrl = appUrl, @@ -93,7 +102,8 @@ class FormbricksConfig private constructor( attributes = attributes, loggingEnabled = loggingEnabled, fragmentManager = fragmentManager, - usedDeprecatedEnvironmentId = usedDeprecatedEnvironmentId + usedDeprecatedEnvironmentId = usedDeprecatedEnvironmentId, + appearance = appearance ) } diff --git a/android/src/main/java/com/formbricks/android/model/javascript/EventType.kt b/android/src/main/java/com/formbricks/android/model/javascript/EventType.kt index 99751f8..48a4574 100644 --- a/android/src/main/java/com/formbricks/android/model/javascript/EventType.kt +++ b/android/src/main/java/com/formbricks/android/model/javascript/EventType.kt @@ -10,5 +10,7 @@ enum class EventType { @SerializedName("onFilePick") ON_FILE_PICK, @SerializedName("onSurveyLibraryLoadError") ON_SURVEY_LIBRARY_LOAD_ERROR, /** The survey card moved or resized; carries its rect. See [com.formbricks.android.webview.SurveyTouchRegion]. */ - @SerializedName("onCardRectChange") ON_CARD_RECT_CHANGE + @SerializedName("onCardRectChange") ON_CARD_RECT_CHANGE, + /** renderSurvey returned, so `formbricksSurveys.setAppearance` can be called. */ + @SerializedName("onSurveyRendered") ON_SURVEY_RENDERED } \ No newline at end of file diff --git a/android/src/main/java/com/formbricks/android/model/workspace/CustomCss.kt b/android/src/main/java/com/formbricks/android/model/workspace/CustomCss.kt new file mode 100644 index 0000000..1afcab1 --- /dev/null +++ b/android/src/main/java/com/formbricks/android/model/workspace/CustomCss.kt @@ -0,0 +1,34 @@ +package com.formbricks.android.model.workspace + +import com.google.gson.JsonObject +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +/** Compiled custom CSS for one scope; either mode may be absent. Forwarded to the renderer untouched. */ +@Serializable +data class CustomCss( + @SerializedName("light") val light: String? = null, + @SerializedName("dark") val dark: String? = null +) { + /** The scope's non-empty strings, or null when there is nothing to send. */ + internal fun toJson(): JsonObject? { + val json = JsonObject() + if (!light.isNullOrEmpty()) json.addProperty("light", light) + if (!dark.isNullOrEmpty()) json.addProperty("dark", dark) + return if (json.size() == 0) null else json + } + + internal companion object { + /** + * The renderer's `customCss` prop, or null (no key at all) when neither scope has CSS. Empty + * fields are omitted rather than sent as null: the renderer rejects the whole prop on a + * null inside a scope. + */ + fun props(workspace: CustomCss?, survey: CustomCss?): JsonObject? { + val json = JsonObject() + workspace?.toJson()?.let { json.add("workspace", it) } + survey?.toJson()?.let { json.add("survey", it) } + return if (json.size() == 0) null else json + } + } +} diff --git a/android/src/main/java/com/formbricks/android/model/workspace/Settings.kt b/android/src/main/java/com/formbricks/android/model/workspace/Settings.kt index 0532953..378d9dc 100644 --- a/android/src/main/java/com/formbricks/android/model/workspace/Settings.kt +++ b/android/src/main/java/com/formbricks/android/model/workspace/Settings.kt @@ -11,5 +11,6 @@ data class Settings( @SerializedName("overlay") val overlay: SurveyOverlay?, @SerializedName("placement") val placement: String?, @SerializedName("inAppSurveyBranding") val inAppSurveyBranding: Boolean?, - @SerializedName("styling") val styling: Styling? + @SerializedName("styling") val styling: Styling?, + @SerializedName("customCss") val customCss: CustomCss? = null ) diff --git a/android/src/main/java/com/formbricks/android/model/workspace/Survey.kt b/android/src/main/java/com/formbricks/android/model/workspace/Survey.kt index 5ab5874..bfd25fa 100644 --- a/android/src/main/java/com/formbricks/android/model/workspace/Survey.kt +++ b/android/src/main/java/com/formbricks/android/model/workspace/Survey.kt @@ -37,7 +37,8 @@ data class Survey( @SerializedName("projectOverwrites") val projectOverwrites: SurveyProjectOverwrites? = null, // Whether interacting with this survey can change some live survey's segment // membership. Absent unless the workspace uses survey-interaction targeting. - @SerializedName("interactionRefresh") val interactionRefresh: InteractionRefresh? = null + @SerializedName("interactionRefresh") val interactionRefresh: InteractionRefresh? = null, + @SerializedName("customCss") val customCss: CustomCss? = null ) /// Defines the overlay style displayed behind a survey modal. diff --git a/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt b/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt index b898fe7..3667ee1 100644 --- a/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt +++ b/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt @@ -4,8 +4,10 @@ import android.annotation.SuppressLint import android.app.Activity.RESULT_OK import android.app.Dialog import android.content.Intent +import android.content.res.Configuration import android.graphics.Color import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper @@ -19,6 +21,7 @@ import android.webkit.ConsoleMessage import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest +import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import android.widget.FrameLayout @@ -28,6 +31,7 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import com.formbricks.android.R import com.formbricks.android.databinding.FragmentFormbricksBinding +import com.formbricks.android.helper.Appearance import com.formbricks.android.logger.Logger import com.formbricks.android.manager.SurveyManager import com.formbricks.android.model.error.SDKError @@ -61,6 +65,15 @@ class FormbricksFragment : BottomSheetDialogFragment() { /** Only set on the no-overlay path. */ private var passthroughLayout: SurveyPassthroughLayout? = null + /** What the open survey currently shows ("light" / "dark"), so only a real change is sent. */ + private var appliedAppearance = "light" + /** Until the survey has rendered there is no `formbricksSurveys.setAppearance` to call. */ + private var surveyRendered = false + private val appearanceListener: () -> Unit = { + // The host may call setAppearance from any thread; the WebView is main-thread only. + Handler(Looper.getMainLooper()).post { syncAppearance() } + } + /** Scoped to this showing, so each interaction refreshes segments at most once. */ private val interactionForwarder = SurveyInteractionForwarder() @@ -119,6 +132,14 @@ class FormbricksFragment : BottomSheetDialogFragment() { safeDismiss() } + override fun onSurveyRendered() { + // JavaScript interface calls arrive on a WebView background thread. + Handler(Looper.getMainLooper()).post { + surveyRendered = true + syncAppearance() // sends only if the value changed while loading + } + } + override fun onCardRectChange(rect: CardRect?) { // JavaScript interface calls arrive on a WebView background thread. Handler(Looper.getMainLooper()).post { @@ -253,9 +274,21 @@ class FormbricksFragment : BottomSheetDialogFragment() { } } + // Stop the WebView from darkening the survey itself: it renders its own dark theme. + // API 33+ darkens algorithmically only when asked, 29-32 through force dark. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + it.settings.isAlgorithmicDarkeningAllowed = false + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + @Suppress("DEPRECATION") + it.settings.forceDark = WebSettings.FORCE_DARK_OFF + } + it.setInitialScale(1) it.addJavascriptInterface(webAppInterface, WebAppInterface.INTERFACE_NAME) - viewModel.loadHtml(surveyId) + surveyRendered = false + Appearance.addListener(appearanceListener) // before resolving, so no change slips between + appliedAppearance = Appearance.resolve(requireActivity()) + viewModel.loadHtml(surveyId, appliedAppearance) } } @@ -277,7 +310,24 @@ class FormbricksFragment : BottomSheetDialogFragment() { requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { safeDismiss() } } + /** Sends the resolved appearance into the open survey, only when it actually changed. */ + private fun syncAppearance() { + val activity = activity ?: return + if (!surveyRendered || !isAdded || view == null) return // hold until the renderer exists + val resolved = Appearance.resolve(activity) + if (resolved == appliedAppearance) return + appliedAppearance = resolved + binding.formbricksWebview.evaluateJavascript(Appearance.switchScript(resolved), null) + } + + /** The app's night mode changed without recreating the Activity, which keeps the answers. */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + syncAppearance() + } + override fun onDestroyView() { + Appearance.removeListener(appearanceListener) // The FragmentManager only removes views it placed itself; this one it did not. (view?.parent as? ViewGroup)?.removeView(view) passthroughLayout = null diff --git a/android/src/main/java/com/formbricks/android/webview/FormbricksViewModel.kt b/android/src/main/java/com/formbricks/android/webview/FormbricksViewModel.kt index bbcf6bd..7d9b955 100644 --- a/android/src/main/java/com/formbricks/android/webview/FormbricksViewModel.kt +++ b/android/src/main/java/com/formbricks/android/webview/FormbricksViewModel.kt @@ -10,6 +10,7 @@ import com.formbricks.android.extensions.guard import com.formbricks.android.manager.EmbeddedDataManager import com.formbricks.android.manager.SurveyManager import com.formbricks.android.manager.UserManager +import com.formbricks.android.model.workspace.CustomCss import com.formbricks.android.model.workspace.WorkspaceDataHolder import com.formbricks.android.model.workspace.SurveyOverlay import com.formbricks.android.model.workspace.getSettingsStylingJson @@ -94,6 +95,7 @@ class FormbricksViewModel : ViewModel() { }; window.formbricksSurveys.renderSurvey(surveyProps); + FormbricksJavascript.message(JSON.stringify({ event: "onSurveyRendered" })); }; function attachFilePickerOverride() { @@ -139,9 +141,14 @@ class FormbricksViewModel : ViewModel() { """ - fun loadHtml(surveyId: String) { + /** + * Builds the survey page. [appearance] is what it opens with ("light" or "dark"); it is part of + * the page, so a change while the survey is open goes through `evaluateJavascript` instead of + * loading again, which would lose the respondent's answers. + */ + fun loadHtml(surveyId: String, appearance: String = "light") { val workspace = SurveyManager.workspaceDataHolder.guard { return } - val json = getJson(workspace, surveyId) + val json = getJson(workspace, surveyId, appearance) // Base64-encode the payload before embedding it in the HTML. Base64 output is // limited to [A-Za-z0-9+/=], so survey content can no longer contain characters // (backticks, `${...}`, quotes, "") that would break out of the @@ -151,7 +158,10 @@ class FormbricksViewModel : ViewModel() { html.postValue(htmlString) } - private fun getJson(workspaceDataHolder: WorkspaceDataHolder, surveyId: String): String { + private fun getJson(workspaceDataHolder: WorkspaceDataHolder, surveyId: String): String = + getJson(workspaceDataHolder, surveyId, "light") + + private fun getJson(workspaceDataHolder: WorkspaceDataHolder, surveyId: String, appearance: String): String { val jsonObject = JsonObject() workspaceDataHolder.getSurveyJson(surveyId).let { jsonObject.add("survey", it) } jsonObject.addProperty("isBrandingEnabled", workspaceDataHolder.data?.data?.settings?.inAppSurveyBranding ?: true) @@ -162,6 +172,7 @@ class FormbricksViewModel : ViewModel() { jsonObject.addProperty("environmentId", Formbricks.workspaceId) jsonObject.addProperty("contactId", UserManager.contactId) jsonObject.addProperty("isWebEnvironment", false) + jsonObject.addProperty("appearance", appearance) // The Embedded Data bag, snapshotted here - loadHtml runs when the survey is actually // presented, after any configured delay - and frozen for the survey's life. Passed raw and // unfiltered: the ingest contract (allow-list, coercion, `locked`, size caps) lives in the @@ -182,6 +193,8 @@ class FormbricksViewModel : ViewModel() { jsonObject.addProperty("languageCode", "default") } + CustomCss.props(settings?.customCss, matchedSurvey?.customCss)?.let { jsonObject.add("customCss", it) } + val hasCustomStyling = matchedSurvey?.styling != null val placement = matchedSurvey?.projectOverwrites?.placement ?: settings?.placement diff --git a/android/src/main/java/com/formbricks/android/webview/WebAppInterface.kt b/android/src/main/java/com/formbricks/android/webview/WebAppInterface.kt index cc934cf..f18847f 100644 --- a/android/src/main/java/com/formbricks/android/webview/WebAppInterface.kt +++ b/android/src/main/java/com/formbricks/android/webview/WebAppInterface.kt @@ -38,6 +38,10 @@ class WebAppInterface(private val callback: WebAppCallback?) { fun onCardRectChange(rect: CardRect?) { // Intentionally empty; FormbricksFragment overrides it. } + + fun onSurveyRendered() { + // Intentionally empty; FormbricksFragment overrides it. + } } /** @@ -57,6 +61,7 @@ class WebAppInterface(private val callback: WebAppCallback?) { EventType.ON_FILE_PICK -> { callback?.onFilePick(FileUploadData.from(data)) } EventType.ON_SURVEY_LIBRARY_LOAD_ERROR -> { callback?.onSurveyLibraryLoadError() } EventType.ON_CARD_RECT_CHANGE -> { callback?.onCardRectChange(CardRectData.from(data).rect) } + EventType.ON_SURVEY_RENDERED -> callback?.onSurveyRendered() } } catch (e: Exception) { Logger.e(RuntimeException(e.message))