From 97944bdebe821ea39ea0fced3aed3ea13b320b45 Mon Sep 17 00:00:00 2001 From: Dhruwang Date: Thu, 8 Oct 2026 11:59:28 +0530 Subject: [PATCH 1/6] feat: add setAppearance for dark mode and forward custom CSS to the survey renderer [ENG-3645] --- README.md | 17 ++++ android/build.gradle.kts | 6 ++ .../java/com/formbricks/android/Formbricks.kt | 29 ++++++ .../formbricks/android/helper/Appearance.kt | 67 +++++++++++++ .../android/helper/FormbricksAppearance.kt | 19 ++++ .../android/helper/FormbricksConfig.kt | 14 ++- .../android/model/workspace/CustomCss.kt | 34 +++++++ .../android/model/workspace/Settings.kt | 3 +- .../android/model/workspace/Survey.kt | 3 +- .../android/webview/FormbricksFragment.kt | 41 +++++++- .../android/webview/FormbricksViewModel.kt | 15 ++- .../com/formbricks/android/AppearanceTest.kt | 93 +++++++++++++++++++ 12 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 android/src/main/java/com/formbricks/android/helper/Appearance.kt create mode 100644 android/src/main/java/com/formbricks/android/helper/FormbricksAppearance.kt create mode 100644 android/src/main/java/com/formbricks/android/model/workspace/CustomCss.kt create mode 100644 android/src/test/java/com/formbricks/android/AppearanceTest.kt diff --git a/README.md b/README.md index e0affef..c04a2e0 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,23 @@ 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. +- 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..57f3dc3 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -127,6 +127,11 @@ android { pickFirsts += "**/formbrickssdk/DataBinderMapperImpl.class" } } + // Logger calls android.util.Log, which is a stub on the JVM; unit tests only need it not to throw. + testOptions { + unitTests.isReturnDefaultValues = true + } + buildFeatures { dataBinding = true viewBinding = true @@ -164,6 +169,7 @@ dependencies { implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.databinding.common) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } 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..9372196 --- /dev/null +++ b/android/src/main/java/com/formbricks/android/helper/Appearance.kt @@ -0,0 +1,67 @@ +package com.formbricks.android.helper + +import android.content.Context +import android.content.res.Configuration +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, whose configuration carries the app's night mode. + */ + fun resolve(context: Context, appearance: FormbricksAppearance = current): String = + resolve(context.resources.configuration.uiMode, appearance) + + 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/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..1201a17 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,13 @@ 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" + 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() @@ -253,9 +264,20 @@ 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) + appliedAppearance = Appearance.resolve(requireActivity()) + viewModel.loadHtml(surveyId, appliedAppearance) + Appearance.addListener(appearanceListener) } } @@ -277,7 +299,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 (!isAdded || view == null) return + 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..844bb4e 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 @@ -139,9 +140,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 +157,7 @@ class FormbricksViewModel : ViewModel() { html.postValue(htmlString) } - private fun getJson(workspaceDataHolder: WorkspaceDataHolder, surveyId: String): String { + 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 +168,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 +189,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/test/java/com/formbricks/android/AppearanceTest.kt b/android/src/test/java/com/formbricks/android/AppearanceTest.kt new file mode 100644 index 0000000..ed3000a --- /dev/null +++ b/android/src/test/java/com/formbricks/android/AppearanceTest.kt @@ -0,0 +1,93 @@ +package com.formbricks.android + +import android.content.res.Configuration +import com.formbricks.android.helper.Appearance +import com.formbricks.android.helper.FormbricksAppearance +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 + +class AppearanceTest { + private val night = Configuration.UI_MODE_NIGHT_YES + private val day = Configuration.UI_MODE_NIGHT_NO + + @Before + @After + fun reset() = Appearance.reset() + + @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 stringOverloadAcceptsKnownValuesAndFallsBackToLightOtherwise() { + Appearance.set("dark") + assertEquals(FormbricksAppearance.DARK, Appearance.current) + Appearance.set("sepia") + assertEquals(FormbricksAppearance.LIGHT, Appearance.current) + } + + @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{}") + val without = Gson().fromJson("{}", Settings::class.java) + assertNull(without.customCss) + } +} From 999d94f8b87a3faeb20e3de8ccffe2fbb342dcdb Mon Sep 17 00:00:00 2001 From: Dhruwang Date: Thu, 8 Oct 2026 12:47:14 +0530 Subject: [PATCH 2/6] fix: keep getJson's two-argument form, cover the appearance props [ENG-3645] --- .../FormbricksViewModelInstrumentedTest.kt | 38 +++++++++++++++++++ .../android/webview/FormbricksViewModel.kt | 3 ++ 2 files changed, 41 insertions(+) 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..e9b0eef 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,42 @@ 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 } 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 844bb4e..db8629a 100644 --- a/android/src/main/java/com/formbricks/android/webview/FormbricksViewModel.kt +++ b/android/src/main/java/com/formbricks/android/webview/FormbricksViewModel.kt @@ -157,6 +157,9 @@ class FormbricksViewModel : ViewModel() { html.postValue(htmlString) } + 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) } From e3f1ab0d184c134d9d6b4432f4de150d6c510eea Mon Sep 17 00:00:00 2001 From: Dhruwang Date: Thu, 8 Oct 2026 13:07:45 +0530 Subject: [PATCH 3/6] test: run the appearance tests as instrumented tests so Sonar sees their coverage [ENG-3645] --- android/build.gradle.kts | 6 --- .../helper/AppearanceInstrumentedTest.kt} | 46 +++++++++++++++---- 2 files changed, 38 insertions(+), 14 deletions(-) rename android/src/{test/java/com/formbricks/android/AppearanceTest.kt => androidTest/java/com/formbricks/android/helper/AppearanceInstrumentedTest.kt} (64%) diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 57f3dc3..3bfa545 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -127,11 +127,6 @@ android { pickFirsts += "**/formbrickssdk/DataBinderMapperImpl.class" } } - // Logger calls android.util.Log, which is a stub on the JVM; unit tests only need it not to throw. - testOptions { - unitTests.isReturnDefaultValues = true - } - buildFeatures { dataBinding = true viewBinding = true @@ -169,7 +164,6 @@ dependencies { implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.databinding.common) - testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/android/src/test/java/com/formbricks/android/AppearanceTest.kt b/android/src/androidTest/java/com/formbricks/android/helper/AppearanceInstrumentedTest.kt similarity index 64% rename from android/src/test/java/com/formbricks/android/AppearanceTest.kt rename to android/src/androidTest/java/com/formbricks/android/helper/AppearanceInstrumentedTest.kt index ed3000a..850e280 100644 --- a/android/src/test/java/com/formbricks/android/AppearanceTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/helper/AppearanceInstrumentedTest.kt @@ -1,8 +1,9 @@ -package com.formbricks.android +package com.formbricks.android.helper import android.content.res.Configuration -import com.formbricks.android.helper.Appearance -import com.formbricks.android.helper.FormbricksAppearance +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 @@ -13,8 +14,10 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith -class AppearanceTest { +@RunWith(AndroidJUnit4::class) +class AppearanceInstrumentedTest { private val night = Configuration.UI_MODE_NIGHT_YES private val day = Configuration.UI_MODE_NIGHT_NO @@ -45,11 +48,39 @@ class AppearanceTest { } @Test - fun stringOverloadAcceptsKnownValuesAndFallsBackToLightOtherwise() { - Appearance.set("dark") + 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 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 @@ -87,7 +118,6 @@ class AppearanceTest { fun settingsDecodeWithAndWithoutCustomCss() { val with = Gson().fromJson("""{"customCss":{"light":".a{}"}}""", Settings::class.java) assertTrue(with.customCss?.light == ".a{}") - val without = Gson().fromJson("{}", Settings::class.java) - assertNull(without.customCss) + assertNull(Gson().fromJson("{}", Settings::class.java).customCss) } } From 505f98c1bf0484222f0878080595d4aecb9f201d Mon Sep 17 00:00:00 2001 From: Dhruwang Date: Thu, 8 Oct 2026 16:57:55 +0530 Subject: [PATCH 4/6] fix(ci): point JaCoCo and Sonar at AGP 9's built-in Kotlin class output [ENG-3645] The report read android/build/tmp/kotlin-classes/debug, which AGP 9 no longer writes, so every coverage run produced an empty report (main shows 0.0% too) and any PR adding coverable lines failed the new-coverage gate. --- .github/workflows/sonarcloud.yml | 2 +- android/build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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 )) From fa0fae6b3c3f0e5945563d8fafde0b9c2b01f006 Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:59:10 +0200 Subject: [PATCH 5/6] fix: send appearance changes made while the survey loads [ENG-3645] setAppearance called before the renderer existed was a no-op, yet it was recorded as applied, so the survey stayed light and repeats were dropped. The page now reports onSurveyRendered and the fragment holds the change until then; the listener is registered before the first resolve. --- .../FormbricksViewModelInstrumentedTest.kt | 11 +++++++++++ .../webview/WebAppInterfaceInstrumentedTest.kt | 9 +++++++++ .../android/model/javascript/EventType.kt | 4 +++- .../android/webview/FormbricksFragment.kt | 15 +++++++++++++-- .../android/webview/FormbricksViewModel.kt | 1 + .../formbricks/android/webview/WebAppInterface.kt | 5 +++++ 6 files changed, 42 insertions(+), 3 deletions(-) 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 e9b0eef..a59a679 100644 --- a/android/src/androidTest/java/com/formbricks/android/webview/FormbricksViewModelInstrumentedTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/webview/FormbricksViewModelInstrumentedTest.kt @@ -428,4 +428,15 @@ class FormbricksViewModelInstrumentedTest { } // 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/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/webview/FormbricksFragment.kt b/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt index 1201a17..3667ee1 100644 --- a/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt +++ b/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt @@ -67,6 +67,8 @@ class FormbricksFragment : BottomSheetDialogFragment() { /** 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() } @@ -130,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 { @@ -275,9 +285,10 @@ class FormbricksFragment : BottomSheetDialogFragment() { it.setInitialScale(1) it.addJavascriptInterface(webAppInterface, WebAppInterface.INTERFACE_NAME) + surveyRendered = false + Appearance.addListener(appearanceListener) // before resolving, so no change slips between appliedAppearance = Appearance.resolve(requireActivity()) viewModel.loadHtml(surveyId, appliedAppearance) - Appearance.addListener(appearanceListener) } } @@ -302,7 +313,7 @@ class FormbricksFragment : BottomSheetDialogFragment() { /** Sends the resolved appearance into the open survey, only when it actually changed. */ private fun syncAppearance() { val activity = activity ?: return - if (!isAdded || view == null) return + if (!surveyRendered || !isAdded || view == null) return // hold until the renderer exists val resolved = Appearance.resolve(activity) if (resolved == appliedAppearance) return appliedAppearance = resolved 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 db8629a..7d9b955 100644 --- a/android/src/main/java/com/formbricks/android/webview/FormbricksViewModel.kt +++ b/android/src/main/java/com/formbricks/android/webview/FormbricksViewModel.kt @@ -95,6 +95,7 @@ class FormbricksViewModel : ViewModel() { }; window.formbricksSurveys.renderSurvey(surveyProps); + FormbricksJavascript.message(JSON.stringify({ event: "onSurveyRendered" })); }; function attachFilePickerOverride() { 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)) From 1edb1bb216acd1bfc3f1254de9f64aba4bbada72 Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:12:54 +0200 Subject: [PATCH 6/6] fix: resolve SYSTEM from AppCompat's night mode before the configuration [ENG-3645] Creating the first WebView resets the Activity's configuration to the phone's, so an app forced dark via AppCompatDelegate got a light survey. An explicit AppCompat night mode (local, else default) now wins. --- README.md | 1 + .../helper/AppearanceInstrumentedTest.kt | 39 ++++++++++++++++++- .../formbricks/android/helper/Appearance.kt | 24 +++++++++++- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c04a2e0..a890654 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ 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. diff --git a/android/src/androidTest/java/com/formbricks/android/helper/AppearanceInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/helper/AppearanceInstrumentedTest.kt index 850e280..7aad684 100644 --- a/android/src/androidTest/java/com/formbricks/android/helper/AppearanceInstrumentedTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/helper/AppearanceInstrumentedTest.kt @@ -1,6 +1,8 @@ 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 @@ -23,7 +25,19 @@ class AppearanceInstrumentedTest { @Before @After - fun reset() = Appearance.reset() + 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() { @@ -57,6 +71,29 @@ class AppearanceInstrumentedTest { 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) diff --git a/android/src/main/java/com/formbricks/android/helper/Appearance.kt b/android/src/main/java/com/formbricks/android/helper/Appearance.kt index 9372196..a0816d4 100644 --- a/android/src/main/java/com/formbricks/android/helper/Appearance.kt +++ b/android/src/main/java/com/formbricks/android/helper/Appearance.kt @@ -2,6 +2,8 @@ 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 @@ -40,10 +42,28 @@ internal object Appearance { /** * What the renderer understands: always "light" or "dark", never "system". [context] should be - * the Activity the survey is shown in, whose configuration carries the app's night mode. + * 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(context.resources.configuration.uiMode, appearance) + 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"