From 06590fe20ab3883a0f38849768c1dec8a2bfeb2e Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Thu, 12 Feb 2026 09:15:06 +0530 Subject: [PATCH 1/9] removes native overlay --- .../manager/SurveyManagerInstrumentedTest.kt | 147 ------------------ .../java/com/formbricks/android/Formbricks.kt | 5 +- .../android/manager/SurveyManager.kt | 12 +- .../android/webview/FormbricksFragment.kt | 23 +-- 4 files changed, 4 insertions(+), 183 deletions(-) diff --git a/android/src/androidTest/java/com/formbricks/android/manager/SurveyManagerInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/manager/SurveyManagerInstrumentedTest.kt index bf6aec4..9e6bac4 100644 --- a/android/src/androidTest/java/com/formbricks/android/manager/SurveyManagerInstrumentedTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/manager/SurveyManagerInstrumentedTest.kt @@ -432,155 +432,8 @@ class SurveyManagerInstrumentedTest { assertEquals(survey6.id, result5[0].id) } - // region resolveOverlay tests - - @Test - fun testResolveOverlay_nullSurvey_nullEnvironment_returnsNone() { - setBackingEnvironmentDataHolder(null) - val result = SurveyManager.resolveOverlay(null) - assertEquals(SurveyOverlay.NONE, result) - } - - @Test - fun testResolveOverlay_nullSurvey_projectOverlayDark_returnsDark() { - setBackingEnvironmentDataHolder(createEnvHolder(projectOverlay = SurveyOverlay.DARK)) - val result = SurveyManager.resolveOverlay(null) - assertEquals(SurveyOverlay.DARK, result) - } - - @Test - fun testResolveOverlay_nullSurvey_projectOverlayLight_returnsLight() { - setBackingEnvironmentDataHolder(createEnvHolder(projectOverlay = SurveyOverlay.LIGHT)) - val result = SurveyManager.resolveOverlay(null) - assertEquals(SurveyOverlay.LIGHT, result) - } - - @Test - fun testResolveOverlay_nullSurvey_projectOverlayNone_returnsNone() { - setBackingEnvironmentDataHolder(createEnvHolder(projectOverlay = SurveyOverlay.NONE)) - val result = SurveyManager.resolveOverlay(null) - assertEquals(SurveyOverlay.NONE, result) - } - - @Test - fun testResolveOverlay_nullSurvey_projectOverlayNull_returnsNone() { - setBackingEnvironmentDataHolder(createEnvHolder(projectOverlay = null)) - val result = SurveyManager.resolveOverlay(null) - assertEquals(SurveyOverlay.NONE, result) - } - - @Test - fun testResolveOverlay_surveyWithoutProjectOverwrites_fallsBackToProject() { - setBackingEnvironmentDataHolder(createEnvHolder(projectOverlay = SurveyOverlay.DARK)) - val survey = createTestSurvey() // projectOverwrites defaults to null - val result = SurveyManager.resolveOverlay(survey) - assertEquals(SurveyOverlay.DARK, result) - } - - @Test - fun testResolveOverlay_surveyOverwritesOverlayNull_fallsBackToProject() { - setBackingEnvironmentDataHolder(createEnvHolder(projectOverlay = SurveyOverlay.DARK)) - val survey = createSurveyWithOverwrites(overlay = null) - val result = SurveyManager.resolveOverlay(survey) - assertEquals(SurveyOverlay.DARK, result) - } - - @Test - fun testResolveOverlay_surveyOverwritesOverlayLight_overridesProjectDark() { - setBackingEnvironmentDataHolder(createEnvHolder(projectOverlay = SurveyOverlay.DARK)) - val survey = createSurveyWithOverwrites(overlay = SurveyOverlay.LIGHT) - val result = SurveyManager.resolveOverlay(survey) - assertEquals(SurveyOverlay.LIGHT, result) - } - - @Test - fun testResolveOverlay_surveyOverwritesOverlayDark_overridesProjectNone() { - setBackingEnvironmentDataHolder(createEnvHolder(projectOverlay = SurveyOverlay.NONE)) - val survey = createSurveyWithOverwrites(overlay = SurveyOverlay.DARK) - val result = SurveyManager.resolveOverlay(survey) - assertEquals(SurveyOverlay.DARK, result) - } - - @Test - fun testResolveOverlay_surveyOverwritesOverlayNone_overridesProjectDark() { - setBackingEnvironmentDataHolder(createEnvHolder(projectOverlay = SurveyOverlay.DARK)) - val survey = createSurveyWithOverwrites(overlay = SurveyOverlay.NONE) - val result = SurveyManager.resolveOverlay(survey) - assertEquals(SurveyOverlay.NONE, result) - } - - @Test - fun testResolveOverlay_environmentDataHolderDataNull_returnsNone() { - // data=null forces the ?.data?.project?.overlay chain to short-circuit at each null check - val envHolder = EnvironmentDataHolder(data = null, originalResponseMap = mapOf()) - setBackingEnvironmentDataHolder(envHolder) - val result = SurveyManager.resolveOverlay(null) - assertEquals(SurveyOverlay.NONE, result) - } - - @Test - fun testResolveOverlay_surveyWithOverwritesNoOverlay_environmentDataNull_returnsNone() { - val envHolder = EnvironmentDataHolder(data = null, originalResponseMap = mapOf()) - setBackingEnvironmentDataHolder(envHolder) - val survey = createSurveyWithOverwrites(overlay = null) - val result = SurveyManager.resolveOverlay(survey) - assertEquals(SurveyOverlay.NONE, result) - } - - // endregion - // region helper methods - private fun setBackingEnvironmentDataHolder(value: EnvironmentDataHolder?) { - val field = SurveyManager::class.java.getDeclaredField("backingEnvironmentDataHolder") - field.isAccessible = true - field.set(SurveyManager, value) - } - - private fun createEnvHolder(projectOverlay: SurveyOverlay?): EnvironmentDataHolder { - val project = Project( - id = "proj1", - recontactDays = null, - clickOutsideClose = null, - overlay = projectOverlay, - placement = null, - inAppSurveyBranding = null, - styling = null - ) - val envData = EnvironmentData( - surveys = emptyList(), - actionClasses = null, - project = project - ) - val envResponseData = EnvironmentResponseData( - data = envData, - expiresAt = null - ) - return EnvironmentDataHolder( - data = envResponseData, - originalResponseMap = mapOf() - ) - } - - private fun createSurveyWithOverwrites(overlay: SurveyOverlay?): Survey { - return Survey( - id = "test", - name = "Test Survey", - triggers = null, - recontactDays = null, - displayLimit = null, - delay = null, - displayPercentage = null, - displayOption = null, - segment = null, - styling = null, - languages = null, - projectOverwrites = SurveyProjectOverwrites(overlay = overlay) - ) - } - - // endregion - private fun createTestSurvey( id: String = "test", displayOption: String? = null, diff --git a/android/src/main/java/com/formbricks/android/Formbricks.kt b/android/src/main/java/com/formbricks/android/Formbricks.kt index f490359..3d3704e 100644 --- a/android/src/main/java/com/formbricks/android/Formbricks.kt +++ b/android/src/main/java/com/formbricks/android/Formbricks.kt @@ -10,7 +10,6 @@ import com.formbricks.android.helper.FormbricksConfig import com.formbricks.android.logger.Logger import com.formbricks.android.manager.SurveyManager import com.formbricks.android.manager.UserManager -import com.formbricks.android.model.environment.SurveyOverlay import com.formbricks.android.model.error.SDKError import com.formbricks.android.webview.FormbricksFragment import java.lang.RuntimeException @@ -217,7 +216,7 @@ object Formbricks { } /// Assembles the survey fragment and presents it - internal fun showSurvey(id: String, overlay: SurveyOverlay = SurveyOverlay.NONE) { + internal fun showSurvey(id: String) { if (fragmentManager == null) { val error = SDKError.fragmentManagerIsNotSet Logger.e(error) @@ -225,7 +224,7 @@ object Formbricks { } fragmentManager?.let { - FormbricksFragment.show(it, surveyId = id, overlay = overlay) + FormbricksFragment.show(it, surveyId = id) } } diff --git a/android/src/main/java/com/formbricks/android/manager/SurveyManager.kt b/android/src/main/java/com/formbricks/android/manager/SurveyManager.kt index 16e1a2b..d015508 100644 --- a/android/src/main/java/com/formbricks/android/manager/SurveyManager.kt +++ b/android/src/main/java/com/formbricks/android/manager/SurveyManager.kt @@ -10,7 +10,6 @@ import com.formbricks.android.model.environment.EnvironmentDataHolder import com.formbricks.android.model.environment.SegmentFilterResource import com.formbricks.android.model.environment.SegmentFilterResourceDeserializer import com.formbricks.android.model.environment.Survey -import com.formbricks.android.model.environment.SurveyOverlay import com.formbricks.android.model.error.SDKError import com.formbricks.android.model.user.Display import com.google.gson.Gson @@ -193,11 +192,10 @@ object SurveyManager { val surveyName = firstSurveyWithActionClass.name Logger.d("Delaying survey \"$surveyName\" by $timeout seconds") } - val overlay = resolveOverlay(firstSurveyWithActionClass) stopDisplayTimer() displayTimer.schedule(object : TimerTask() { override fun run() { - Formbricks.showSurvey(it, overlay) + Formbricks.showSurvey(it) } }, Date(System.currentTimeMillis() + timeout.toLong() * 1000)) @@ -394,12 +392,4 @@ object SurveyManager { return selected.language.code } - /** - * Resolves the overlay style for the given survey, falling back to the project-level default. - * Survey-level `projectOverwrites.overlay` takes precedence over `project.overlay`. - */ - fun resolveOverlay(survey: Survey?): SurveyOverlay { - survey?.projectOverwrites?.overlay?.let { return it } - return environmentDataHolder?.data?.data?.project?.overlay ?: SurveyOverlay.NONE - } } \ 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 5ee121e..ae95e57 100644 --- a/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt +++ b/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt @@ -29,7 +29,6 @@ import com.formbricks.android.R import com.formbricks.android.databinding.FragmentFormbricksBinding import com.formbricks.android.logger.Logger import com.formbricks.android.manager.SurveyManager -import com.formbricks.android.model.environment.SurveyOverlay import com.formbricks.android.model.error.SDKError import com.formbricks.android.model.javascript.FileUploadData import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -41,7 +40,6 @@ import java.io.InputStream class FormbricksFragment : BottomSheetDialogFragment() { private lateinit var binding: FragmentFormbricksBinding private lateinit var surveyId: String - private var overlay: SurveyOverlay = SurveyOverlay.NONE private val viewModel: FormbricksViewModel by viewModels() private var isDismissing = false @@ -125,9 +123,6 @@ class FormbricksFragment : BottomSheetDialogFragment() { super.onCreate(savedInstanceState) arguments?.let { surveyId = it.getString(ARG_SURVEY_ID) ?: throw IllegalArgumentException("Survey ID is required") - overlay = it.getString(ARG_OVERLAY)?.let { name -> - try { SurveyOverlay.valueOf(name) } catch (_: Exception) { SurveyOverlay.NONE } - } ?: SurveyOverlay.NONE } } @@ -195,11 +190,6 @@ class FormbricksFragment : BottomSheetDialogFragment() { super.onReceivedError(view, request, error) Logger.d("WebView Error: ${error?.description}") } - - override fun onPageCommitVisible(view: WebView?, url: String?) { - dialog?.window?.setDimAmount(dimAmountFor(overlay)) - super.onPageCommitVisible(view, url) - } } it.setOnFocusChangeListener { _, hasFocus -> @@ -272,22 +262,11 @@ class FormbricksFragment : BottomSheetDialogFragment() { companion object { private val TAG: String by lazy { FormbricksFragment::class.java.simpleName } private const val ARG_SURVEY_ID = "survey_id" - private const val ARG_OVERLAY = "overlay" - - /// Returns the appropriate dim amount for the given overlay style. - fun dimAmountFor(overlay: SurveyOverlay): Float { - return when (overlay) { - SurveyOverlay.DARK -> 0.6f - SurveyOverlay.LIGHT -> 0.3f - SurveyOverlay.NONE -> 0.0f - } - } - fun show(childFragmentManager: FragmentManager, surveyId: String, overlay: SurveyOverlay = SurveyOverlay.NONE) { + fun show(childFragmentManager: FragmentManager, surveyId: String) { val fragment = FormbricksFragment().apply { arguments = Bundle().apply { putString(ARG_SURVEY_ID, surveyId) - putString(ARG_OVERLAY, overlay.name) } } fragment.show(childFragmentManager, TAG) From 2b8cc1dc02d6b497f4baea6bd6c39f03860a146a Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Thu, 12 Feb 2026 14:28:29 +0530 Subject: [PATCH 2/9] fixes test --- .../android/manager/SurveyManagerInstrumentedTest.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/android/src/androidTest/java/com/formbricks/android/manager/SurveyManagerInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/manager/SurveyManagerInstrumentedTest.kt index 9e6bac4..c27d2d0 100644 --- a/android/src/androidTest/java/com/formbricks/android/manager/SurveyManagerInstrumentedTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/manager/SurveyManagerInstrumentedTest.kt @@ -434,6 +434,12 @@ class SurveyManagerInstrumentedTest { // region helper methods + private fun setBackingEnvironmentDataHolder(value: EnvironmentDataHolder?) { + val field = SurveyManager::class.java.getDeclaredField("backingEnvironmentDataHolder") + field.isAccessible = true + field.set(SurveyManager, value) + } + private fun createTestSurvey( id: String = "test", displayOption: String? = null, From a8842dee12bf3ad468b543632e96b1d3eb939842 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 16 Feb 2026 14:07:05 +0530 Subject: [PATCH 3/9] attribute data type --- .../android/FormbricksInstrumentedTest.kt | 5 +- .../java/com/formbricks/android/Formbricks.kt | 102 ++++++++++++++---- .../formbricks/android/api/FormbricksApi.kt | 3 +- .../android/helper/FormbricksConfig.kt | 36 ++++++- .../android/manager/SurveyManager.kt | 2 +- .../formbricks/android/manager/UserManager.kt | 30 +++--- .../android/model/user/AttributeValue.kt | 75 +++++++++++++ .../android/model/user/PostUserBody.kt | 4 +- .../android/model/user/UserResponseData.kt | 4 +- .../network/FormbricksRetrofitBuilder.kt | 12 +-- .../android/network/queue/UpdateQueue.kt | 17 +-- 11 files changed, 232 insertions(+), 58 deletions(-) create mode 100644 android/src/main/java/com/formbricks/android/model/user/AttributeValue.kt diff --git a/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt index 600a9fd..fb7ae52 100644 --- a/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt @@ -8,6 +8,7 @@ import com.formbricks.android.helper.FormbricksConfig import com.formbricks.android.logger.Logger import com.formbricks.android.manager.SurveyManager import com.formbricks.android.manager.UserManager +import com.formbricks.android.model.user.AttributeValue import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals @@ -57,7 +58,7 @@ class FormbricksInstrumentedTest { // Use methods before init should have no effect Formbricks.setUserId("userId") Formbricks.setLanguage("de") - Formbricks.setAttributes(mapOf("testA" to "testB")) + Formbricks.setAttributes(mapOf("testA" to AttributeValue.string("testB"))) Formbricks.setAttribute("test", "testKey") assertNull(UserManager.userId) assertEquals("default", Formbricks.language) @@ -73,7 +74,7 @@ class FormbricksInstrumentedTest { waitForSeconds(1) // Should be ignored, becuase we don't have user ID yet - Formbricks.setAttributes(mapOf("testA" to "testB")) + Formbricks.setAttributes(mapOf("testA" to AttributeValue.string("testB"))) Formbricks.setAttribute("test", "testKey") assertNull(UserManager.userId) diff --git a/android/src/main/java/com/formbricks/android/Formbricks.kt b/android/src/main/java/com/formbricks/android/Formbricks.kt index 3d3704e..6eee903 100644 --- a/android/src/main/java/com/formbricks/android/Formbricks.kt +++ b/android/src/main/java/com/formbricks/android/Formbricks.kt @@ -11,8 +11,13 @@ import com.formbricks.android.logger.Logger import com.formbricks.android.manager.SurveyManager import com.formbricks.android.manager.UserManager import com.formbricks.android.model.error.SDKError +import com.formbricks.android.model.user.AttributeValue import com.formbricks.android.webview.FormbricksFragment import java.lang.RuntimeException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone @Keep object Formbricks { @@ -52,13 +57,6 @@ object Formbricks { return } - // Validate HTTPS URL - if (!config.appUrl.startsWith("https://", ignoreCase = true)) { - val error = RuntimeException("Only HTTPS URLs are allowed for security reasons. HTTP URLs are not permitted. Provided URL: ${config.appUrl}") - Logger.e(error) - return - } - applicationContext = context appUrl = config.appUrl @@ -68,7 +66,10 @@ object Formbricks { config.userId?.let { UserManager.set(it) } config.attributes?.let { UserManager.setAttributes(it) } - config.attributes?.get("language")?.let { UserManager.setLanguage(it) } + config.attributes?.get("language")?.stringValue?.let { + UserManager.setLanguage(it) + language = it + } FormbricksApi.initialize() SurveyManager.refreshEnvironmentIfNeeded(force = forceRefresh) @@ -79,6 +80,11 @@ object Formbricks { /** * Sets the user id for the current user with the given [String]. + * + * - If the same userId is already set, this is a no-op. + * - If a different userId is already set, the previous user state is cleaned up first + * before setting the new userId. + * * The SDK must be initialized before calling this method. * * ``` @@ -93,21 +99,28 @@ object Formbricks { return } - if(UserManager.userId != null) { - val error = RuntimeException("A userId is already set ${UserManager.userId} - please call logout first before setting a new one") - Logger.e(error) + // If the same userId is already set, no-op + val existing = UserManager.userId + if (existing != null && existing == userId) { + Logger.d("UserId is already set to the same value, skipping") return } + // If a different userId is set, clean up the previous user state first + if (existing != null && existing.isNotEmpty()) { + Logger.d("Different userId is being set, cleaning up previous user state") + UserManager.logout() + } + UserManager.set(userId) } /** - * Adds an attribute for the current user with the given [String] value and [String] key. + * Adds a string attribute for the current user. * The SDK must be initialized before calling this method. * * ``` - * Formbricks.setAttribute("my_attribute", "key") + * Formbricks.setAttribute("John", "name") * ``` * */ @@ -117,19 +130,72 @@ object Formbricks { Logger.e(error) return } - UserManager.addAttribute(attribute, key) + UserManager.addAttribute(AttributeValue.string(attribute), key) + } + + /** + * Adds a numeric attribute for the current user. + * The SDK must be initialized before calling this method. + * + * ``` + * Formbricks.setAttribute(42.0, "age") + * ``` + * + */ + fun setAttribute(attribute: Double, key: String) { + if (!isInitialized) { + val error = SDKError.sdkIsNotInitialized + Logger.e(error) + return + } + UserManager.addAttribute(AttributeValue.number(attribute), key) } /** - * Sets the user attributes for the current user with the given [Map] of [String] values and [String] keys. + * Adds a date attribute for the current user. + * The date is converted to an ISO 8601 string. The backend will detect the format and treat it as a date type. + * The SDK must be initialized before calling this method. + * + * ``` + * Formbricks.setAttribute(Date(), "signupDate") + * ``` + * + */ + fun setAttribute(attribute: Date, key: String) { + if (!isInitialized) { + val error = SDKError.sdkIsNotInitialized + Logger.e(error) + return + } + val iso8601Format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + UserManager.addAttribute(AttributeValue.string(iso8601Format.format(attribute)), key) + } + + /** + * Sets the user attributes for the current user. + * + * Attribute types are determined by the value: + * - String values -> string attribute + * - Number values -> number attribute + * - Use ISO 8601 date strings for date attributes + * + * On first write to a new attribute, the type is set based on the value type. + * On subsequent writes, the value must match the existing attribute type. + * * The SDK must be initialized before calling this method. * * ``` - * Formbricks.setAttributes(mapOf(Pair("key", "my_attribute"))) + * Formbricks.setAttributes(mapOf( + * "name" to AttributeValue.string("John"), + * "age" to AttributeValue.number(30.0), + * "score" to AttributeValue.number(9.5) + * )) * ``` * */ - fun setAttributes(attributes: Map) { + fun setAttributes(attributes: Map) { if (!isInitialized) { val error = SDKError.sdkIsNotInitialized Logger.e(error) @@ -235,4 +301,4 @@ object Formbricks { val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/formbricks/android/api/FormbricksApi.kt b/android/src/main/java/com/formbricks/android/api/FormbricksApi.kt index 712c118..d762bfc 100644 --- a/android/src/main/java/com/formbricks/android/api/FormbricksApi.kt +++ b/android/src/main/java/com/formbricks/android/api/FormbricksApi.kt @@ -2,6 +2,7 @@ package com.formbricks.android.api import com.formbricks.android.Formbricks import com.formbricks.android.model.environment.EnvironmentDataHolder +import com.formbricks.android.model.user.AttributeValue import com.formbricks.android.model.user.PostUserBody import com.formbricks.android.model.user.UserResponse import com.formbricks.android.network.FormbricksApiService @@ -45,7 +46,7 @@ object FormbricksApi { } } - suspend fun postUser(userId: String, attributes: Map?): Result = withContext(Dispatchers.IO) { + suspend fun postUser(userId: String, attributes: Map?): Result = withContext(Dispatchers.IO) { retryApiCall { try { val result = service.postUser(Formbricks.environmentId, PostUserBody.create(userId, attributes)).getOrThrow() 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 fff2a09..dac20da 100644 --- a/android/src/main/java/com/formbricks/android/helper/FormbricksConfig.kt +++ b/android/src/main/java/com/formbricks/android/helper/FormbricksConfig.kt @@ -2,6 +2,7 @@ package com.formbricks.android.helper import androidx.annotation.Keep import androidx.fragment.app.FragmentManager +import com.formbricks.android.model.user.AttributeValue /** * Configuration options for the SDK @@ -13,13 +14,13 @@ class FormbricksConfig private constructor( val appUrl: String, val environmentId: String, val userId: String?, - val attributes: Map?, + val attributes: Map?, val loggingEnabled: Boolean, val fragmentManager: FragmentManager? ) { class Builder(private val appUrl: String, private val environmentId: String) { private var userId: String? = null - private var attributes: MutableMap = mutableMapOf() + private var attributes: MutableMap = mutableMapOf() private var loggingEnabled = false private var fragmentManager: FragmentManager? = null @@ -28,13 +29,38 @@ class FormbricksConfig private constructor( return this } - fun setAttributes(attributes: MutableMap): Builder { + /** + * Sets the attributes for the Builder object. + * + * ```kotlin + * .setAttributes(mutableMapOf( + * "name" to AttributeValue.string("John"), + * "age" to AttributeValue.number(30.0) + * )) + * ``` + */ + fun setAttributes(attributes: MutableMap): Builder { this.attributes = attributes return this } + /** + * Sets the attributes for the Builder object using string values. + * + * ```kotlin + * .setStringAttributes(mutableMapOf("name" to "John", "plan" to "free")) + * ``` + */ + fun setStringAttributes(attributes: MutableMap): Builder { + this.attributes = attributes.mapValues { AttributeValue.string(it.value) }.toMutableMap() + return this + } + + /** + * Adds a string attribute to the Builder object. + */ fun addAttribute(attribute: String, key: String): Builder { - this.attributes[key] = attribute + this.attributes[key] = AttributeValue.string(attribute) return this } @@ -59,4 +85,4 @@ class FormbricksConfig private constructor( ) } } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/formbricks/android/manager/SurveyManager.kt b/android/src/main/java/com/formbricks/android/manager/SurveyManager.kt index d015508..9475db6 100644 --- a/android/src/main/java/com/formbricks/android/manager/SurveyManager.kt +++ b/android/src/main/java/com/formbricks/android/manager/SurveyManager.kt @@ -151,7 +151,7 @@ object SurveyManager { val codeActionClasses = actionClasses.filter { it.type == "code" } val actionClass = codeActionClasses.firstOrNull { it.key == action } if (actionClass == null) { - val error = RuntimeException("\"$action\" action unknown. Please add this action in Formbricks first in order to use it in your code.") + val error = RuntimeException("Action with identifier '$action' is unknown. Please add this action in Formbricks in order to use it via the SDK action tracking.") Logger.e(error) return } diff --git a/android/src/main/java/com/formbricks/android/manager/UserManager.kt b/android/src/main/java/com/formbricks/android/manager/UserManager.kt index b3a4f95..83ae473 100644 --- a/android/src/main/java/com/formbricks/android/manager/UserManager.kt +++ b/android/src/main/java/com/formbricks/android/manager/UserManager.kt @@ -9,6 +9,7 @@ import com.formbricks.android.extensions.guard import com.formbricks.android.extensions.lastDisplayAt import com.formbricks.android.logger.Logger import com.formbricks.android.model.error.SDKError +import com.formbricks.android.model.user.AttributeValue import com.formbricks.android.model.user.Display import com.formbricks.android.network.queue.UpdateQueue import com.google.gson.Gson @@ -57,7 +58,7 @@ object UserManager { * @param attribute * @param key */ - fun addAttribute(attribute: String, key: String) { + fun addAttribute(attribute: AttributeValue, key: String) { UpdateQueue.addAttribute(key, attribute) } @@ -66,7 +67,7 @@ object UserManager { * * @param attributes */ - fun setAttributes(attributes: Map) { + fun setAttributes(attributes: Map) { UpdateQueue.setAttributes(attributes) } @@ -126,7 +127,7 @@ object UserManager { * @param id * @param attributes */ - fun syncUser(id: String, attributes: Map? = null) { + fun syncUser(id: String, attributes: Map? = null) { CoroutineScope(Dispatchers.IO).launch { try { val userResponse = FormbricksApi.postUser(id, attributes).getOrThrow() @@ -139,10 +140,20 @@ object UserManager { expiresAt = userResponse.data.state.expiresAt() val languageFromUserResponse = userResponse.data.state.data.language - if(languageFromUserResponse != null) { + if (languageFromUserResponse != null) { Formbricks.language = languageFromUserResponse } + // Log errors (always visible) - e.g., invalid attribute keys, type mismatches + userResponse.data.errors?.forEach { error -> + Logger.e(RuntimeException(error)) + } + + // Log informational messages (debug only) + userResponse.data.messages?.forEach { message -> + Logger.d("User update message: $message") + } + UpdateQueue.reset() SurveyManager.filterSurveys() startSyncTimer() @@ -157,12 +168,7 @@ object UserManager { * Logs out the user and clears the user state. */ fun logout() { - val isUserIdDefined = userId != null - - if (!isUserIdDefined) { - val error = SDKError.noUserIdSetError - Logger.e(error) - } + Logger.d("Logging out and cleaning user state") prefManager.edit().apply { remove(CONTACT_ID_KEY) @@ -186,10 +192,6 @@ object UserManager { UpdateQueue.reset() SurveyManager.filterSurveys() - - if(isUserIdDefined) { - Logger.d("User logged out successfully!") - } } private fun startSyncTimer() { diff --git a/android/src/main/java/com/formbricks/android/model/user/AttributeValue.kt b/android/src/main/java/com/formbricks/android/model/user/AttributeValue.kt new file mode 100644 index 0000000..fed4198 --- /dev/null +++ b/android/src/main/java/com/formbricks/android/model/user/AttributeValue.kt @@ -0,0 +1,75 @@ +package com.formbricks.android.model.user + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import com.google.gson.JsonPrimitive +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import com.google.gson.annotations.JsonAdapter +import java.lang.reflect.Type + +/** + * Represents a user attribute value that can be a string or number. + * + * Attribute types are determined by the value type: + * - String values -> string attribute + * - Number values -> number attribute + * - Use ISO 8601 date strings for date attributes + * + * On first write to a new attribute, the type is set based on the value type. + * On subsequent writes, the value must match the existing attribute type. + * + * ```kotlin + * val attributes = mapOf( + * "name" to AttributeValue.StringValue("John"), + * "age" to AttributeValue.NumberValue(30.0), + * "score" to AttributeValue.NumberValue(9.5) + * ) + * ``` + */ +@JsonAdapter(AttributeValueAdapter::class) +sealed class AttributeValue { + data class StringValue(val value: String) : AttributeValue() + data class NumberValue(val value: Double) : AttributeValue() + + /** The string representation of this attribute value, if it is a string. */ + val stringValue: String? + get() = (this as? StringValue)?.value + + /** The numeric representation of this attribute value, if it is a number. */ + val numberValue: Double? + get() = (this as? NumberValue)?.value + + companion object { + fun string(value: String): AttributeValue = StringValue(value) + fun number(value: Double): AttributeValue = NumberValue(value) + } +} + +/** + * Gson adapter for [AttributeValue] that serializes/deserializes as the raw primitive value. + * - `StringValue("hello")` serializes as `"hello"` + * - `NumberValue(42.0)` serializes as `42.0` + */ +class AttributeValueAdapter : JsonSerializer, JsonDeserializer { + override fun serialize(src: AttributeValue, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { + return when (src) { + is AttributeValue.StringValue -> JsonPrimitive(src.value) + is AttributeValue.NumberValue -> JsonPrimitive(src.value) + } + } + + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): AttributeValue { + if (json.isJsonPrimitive) { + val primitive = json.asJsonPrimitive + return when { + primitive.isNumber -> AttributeValue.NumberValue(primitive.asDouble) + primitive.isString -> AttributeValue.StringValue(primitive.asString) + else -> throw JsonParseException("Expected String or Number for AttributeValue") + } + } + throw JsonParseException("Expected String or Number for AttributeValue") + } +} diff --git a/android/src/main/java/com/formbricks/android/model/user/PostUserBody.kt b/android/src/main/java/com/formbricks/android/model/user/PostUserBody.kt index 26dd962..9998b82 100644 --- a/android/src/main/java/com/formbricks/android/model/user/PostUserBody.kt +++ b/android/src/main/java/com/formbricks/android/model/user/PostUserBody.kt @@ -4,10 +4,10 @@ import com.google.gson.annotations.SerializedName data class PostUserBody( @SerializedName("userId") val userId: String, - @SerializedName("attributes") val attributes: Map? + @SerializedName("attributes") val attributes: Map? ) { companion object { - fun create(userId: String, attributes: Map?): PostUserBody { + fun create(userId: String, attributes: Map?): PostUserBody { return PostUserBody(userId, attributes) } } diff --git a/android/src/main/java/com/formbricks/android/model/user/UserResponseData.kt b/android/src/main/java/com/formbricks/android/model/user/UserResponseData.kt index afff7db..1125707 100644 --- a/android/src/main/java/com/formbricks/android/model/user/UserResponseData.kt +++ b/android/src/main/java/com/formbricks/android/model/user/UserResponseData.kt @@ -3,5 +3,7 @@ package com.formbricks.android.model.user import com.google.gson.annotations.SerializedName data class UserResponseData( - @SerializedName("state") val state: UserState + @SerializedName("state") val state: UserState, + @SerializedName("messages") val messages: List? = null, + @SerializedName("errors") val errors: List? = null ) diff --git a/android/src/main/java/com/formbricks/android/network/FormbricksRetrofitBuilder.kt b/android/src/main/java/com/formbricks/android/network/FormbricksRetrofitBuilder.kt index 1e96314..b38a7a1 100644 --- a/android/src/main/java/com/formbricks/android/network/FormbricksRetrofitBuilder.kt +++ b/android/src/main/java/com/formbricks/android/network/FormbricksRetrofitBuilder.kt @@ -13,17 +13,17 @@ import java.util.concurrent.TimeUnit class FormbricksRetrofitBuilder(private val baseUrl: String, private val loggingEnabled: Boolean) { fun getBuilder(): Retrofit.Builder? { // Validate base URL is HTTPS - if (!baseUrl.startsWith("https://", ignoreCase = true)) { - val error = RuntimeException("Only HTTPS URLs are allowed. HTTP URLs are not permitted for security reasons. Provided URL: $baseUrl") - Logger.e(error) - return null - } +// if (!baseUrl.startsWith("https://", ignoreCase = true)) { +// val error = RuntimeException("Only HTTPS URLs are allowed. HTTP URLs are not permitted for security reasons. Provided URL: $baseUrl") +// Logger.e(error) +// return null +// } val clientBuilder = OkHttpClient.Builder() .connectTimeout(CONNECT_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS) .readTimeout(READ_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS) .followSslRedirects(true) - .addInterceptor(HttpsOnlyInterceptor()) +// .addInterceptor(HttpsOnlyInterceptor()) if (loggingEnabled) { val logging = HttpLoggingInterceptor() diff --git a/android/src/main/java/com/formbricks/android/network/queue/UpdateQueue.kt b/android/src/main/java/com/formbricks/android/network/queue/UpdateQueue.kt index 0e115cd..2005cdb 100644 --- a/android/src/main/java/com/formbricks/android/network/queue/UpdateQueue.kt +++ b/android/src/main/java/com/formbricks/android/network/queue/UpdateQueue.kt @@ -3,6 +3,7 @@ package com.formbricks.android.network.queue import com.formbricks.android.logger.Logger import com.formbricks.android.manager.UserManager import com.formbricks.android.model.error.SDKError +import com.formbricks.android.model.user.AttributeValue import java.util.* import kotlin.concurrent.timer @@ -15,7 +16,7 @@ object UpdateQueue { private const val DEBOUNCE_INTERVAL: Long = 500 // 500 ms private var userId: String? = null - private var attributes: MutableMap? = null + private var attributes: MutableMap? = null private var language: String? = null private var timer: Timer? = null @@ -24,12 +25,12 @@ object UpdateQueue { startDebounceTimer() } - fun setAttributes(attributes: Map) { + fun setAttributes(attributes: Map) { this.attributes = attributes.toMutableMap() startDebounceTimer() } - fun addAttribute(key: String, attribute: String) { + fun addAttribute(key: String, attribute: AttributeValue) { if (attributes == null) { attributes = mutableMapOf() } @@ -38,13 +39,13 @@ object UpdateQueue { } fun setLanguage(language: String) { - val effectiveUserId = userId ?: UserManager.userId + val effectiveUserId = userId ?: UserManager.userId - if(effectiveUserId != null) { - addAttribute("language", language) + if (effectiveUserId != null) { + addAttribute("language", AttributeValue.string(language)) startDebounceTimer() } else { - Logger.d("UpdateQueue - updating language locally: ${language}") + Logger.d("UpdateQueue - updating language locally: $language") return } } @@ -75,4 +76,4 @@ object UpdateQueue { Logger.d("UpdateQueue - commit() called on UpdateQueue with $effectiveUserId and $attributes") UserManager.syncUser(effectiveUserId, attributes) } -} \ No newline at end of file +} From 5c21adb4b2f73d8407bb192cf0962c8b17da4bf0 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 16 Feb 2026 14:24:19 +0530 Subject: [PATCH 4/9] revert https change --- .../main/java/com/formbricks/android/Formbricks.kt | 9 +++++++++ .../android/network/FormbricksRetrofitBuilder.kt | 12 ++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/android/src/main/java/com/formbricks/android/Formbricks.kt b/android/src/main/java/com/formbricks/android/Formbricks.kt index 6eee903..143f059 100644 --- a/android/src/main/java/com/formbricks/android/Formbricks.kt +++ b/android/src/main/java/com/formbricks/android/Formbricks.kt @@ -57,6 +57,15 @@ object Formbricks { return } + + // Validate HTTPS URL + if (!config.appUrl.startsWith("https://", ignoreCase = true)) { + val error = RuntimeException("Only HTTPS URLs are allowed for security reasons. HTTP URLs are not permitted. Provided URL: ${config.appUrl}") + Logger.e(error) + return + } + + applicationContext = context appUrl = config.appUrl diff --git a/android/src/main/java/com/formbricks/android/network/FormbricksRetrofitBuilder.kt b/android/src/main/java/com/formbricks/android/network/FormbricksRetrofitBuilder.kt index b38a7a1..1e96314 100644 --- a/android/src/main/java/com/formbricks/android/network/FormbricksRetrofitBuilder.kt +++ b/android/src/main/java/com/formbricks/android/network/FormbricksRetrofitBuilder.kt @@ -13,17 +13,17 @@ import java.util.concurrent.TimeUnit class FormbricksRetrofitBuilder(private val baseUrl: String, private val loggingEnabled: Boolean) { fun getBuilder(): Retrofit.Builder? { // Validate base URL is HTTPS -// if (!baseUrl.startsWith("https://", ignoreCase = true)) { -// val error = RuntimeException("Only HTTPS URLs are allowed. HTTP URLs are not permitted for security reasons. Provided URL: $baseUrl") -// Logger.e(error) -// return null -// } + if (!baseUrl.startsWith("https://", ignoreCase = true)) { + val error = RuntimeException("Only HTTPS URLs are allowed. HTTP URLs are not permitted for security reasons. Provided URL: $baseUrl") + Logger.e(error) + return null + } val clientBuilder = OkHttpClient.Builder() .connectTimeout(CONNECT_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS) .readTimeout(READ_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS) .followSslRedirects(true) -// .addInterceptor(HttpsOnlyInterceptor()) + .addInterceptor(HttpsOnlyInterceptor()) if (loggingEnabled) { val logging = HttpLoggingInterceptor() From 0aaa0e0322468cd87368227b25866706fb3f0d74 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 16 Feb 2026 15:06:49 +0530 Subject: [PATCH 5/9] fixes errors --- android/consumer-rules.pro | 2 ++ .../main/java/com/formbricks/android/Formbricks.kt | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro index 3b6eeaa..8ca9ce8 100644 --- a/android/consumer-rules.pro +++ b/android/consumer-rules.pro @@ -2,4 +2,6 @@ -keep class com.formbricks.android.Formbricks { *; } -keep class com.formbricks.android.helper.FormbricksConfig { *; } -keep class com.formbricks.android.model.error.SDKError { *; } +-keep class com.formbricks.android.model.user.AttributeValue { *; } +-keep class com.formbricks.android.model.user.AttributeValue$* { *; } -keep interface com.formbricks.android.FormbricksCallback { *; } \ No newline at end of file diff --git a/android/src/main/java/com/formbricks/android/Formbricks.kt b/android/src/main/java/com/formbricks/android/Formbricks.kt index 143f059..64e7674 100644 --- a/android/src/main/java/com/formbricks/android/Formbricks.kt +++ b/android/src/main/java/com/formbricks/android/Formbricks.kt @@ -160,6 +160,20 @@ object Formbricks { UserManager.addAttribute(AttributeValue.number(attribute), key) } + /** + * Adds an integer attribute for the current user. + * The value is converted to a [Double] internally. + * The SDK must be initialized before calling this method. + * + * ``` + * Formbricks.setAttribute(42, "age") + * ``` + * + */ + fun setAttribute(attribute: Int, key: String) { + setAttribute(attribute.toDouble(), key) + } + /** * Adds a date attribute for the current user. * The date is converted to an ISO 8601 string. The backend will detect the format and treat it as a date type. From be81f159da046f476d78c9daf62b215d06d8172b Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 16 Feb 2026 18:28:43 +0530 Subject: [PATCH 6/9] tests --- .../android/FormbricksInstrumentedTest.kt | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt index fb7ae52..31fcb0c 100644 --- a/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt @@ -175,6 +175,116 @@ class FormbricksInstrumentedTest { assertTrue(SurveyManager.isShowingSurvey) } + @Test + fun testSetAttributesWithUserId() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + Formbricks.setup(appContext, FormbricksConfig.Builder(appUrl, environmentId).setLoggingEnabled(true).build()) + waitForSeconds(1) + + // Set userId first, then set attributes - exercises UpdateQueue.setAttributes with a valid userId + Formbricks.setUserId(userId) + waitForSeconds(2) + assertEquals(userId, UserManager.userId) + + Formbricks.setAttributes(mapOf( + "plan" to AttributeValue.string("premium"), + "score" to AttributeValue.number(99.5) + )) + waitForSeconds(1) + + // User should still be synced + assertEquals(userId, UserManager.userId) + } + + @Test + fun testAddAttributeWithUserId() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + Formbricks.setup(appContext, FormbricksConfig.Builder(appUrl, environmentId).setLoggingEnabled(true).build()) + waitForSeconds(1) + + // Set userId first, then add attributes - exercises UpdateQueue.addAttribute with a valid userId + Formbricks.setUserId(userId) + waitForSeconds(2) + assertEquals(userId, UserManager.userId) + + Formbricks.setAttribute("John", "name") + Formbricks.setAttribute(42.0, "age") + Formbricks.setAttribute(99, "level") + waitForSeconds(1) + + assertEquals(userId, UserManager.userId) + } + + @Test + fun testSetLanguageWithUserId() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + Formbricks.setup(appContext, FormbricksConfig.Builder(appUrl, environmentId).setLoggingEnabled(true).build()) + waitForSeconds(1) + + // Set userId first, then set language - exercises the if-branch in UpdateQueue.setLanguage + Formbricks.setUserId(userId) + waitForSeconds(2) + assertEquals(userId, UserManager.userId) + + Formbricks.setLanguage("de") + waitForSeconds(1) + + assertEquals("de", Formbricks.language) + assertEquals(userId, UserManager.userId) + } + + @Test + fun testSetUserIdSameValueIsNoOp() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + Formbricks.setup(appContext, FormbricksConfig.Builder(appUrl, environmentId).setLoggingEnabled(true).build()) + waitForSeconds(1) + + Formbricks.setUserId(userId) + waitForSeconds(2) + assertEquals(userId, UserManager.userId) + + // Same userId again — should be a no-op + Formbricks.setUserId(userId) + assertEquals(userId, UserManager.userId) + } + + @Test + fun testSetUserIdDifferentValueOverridesPrevious() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + Formbricks.setup(appContext, FormbricksConfig.Builder(appUrl, environmentId).setLoggingEnabled(true).build()) + waitForSeconds(1) + + Formbricks.setUserId(userId) + waitForSeconds(2) + assertEquals(userId, UserManager.userId) + assertNotNull(UserManager.expiresAt) + + // Different userId — should clean up previous state and re-sync + // (Previously this would error and return without doing anything) + val newUserId = "NEW-USER-ID-12345" + Formbricks.setUserId(newUserId) + + // Verify that logout was called: expiresAt should be cleared immediately + assertNull("expiresAt should be cleared by logout", UserManager.expiresAt) + + // After sync completes, the mock returns the hardcoded userId from User.json, + // so we just verify the SDK is still functional (sync completed without errors) + waitForSeconds(2) + assertNotNull("userId should be set after re-sync", UserManager.userId) + } + + @Test + fun testLogoutWithoutUserIdDoesNotError() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + Formbricks.setup(appContext, FormbricksConfig.Builder(appUrl, environmentId).setLoggingEnabled(true).build()) + waitForSeconds(1) + + // Logout without ever setting a userId — should not crash + assertNull(UserManager.userId) + Formbricks.logout() + assertNull(UserManager.userId) + } + private fun waitForSeconds(seconds: Long) { val latch = CountDownLatch(1) latch.await(seconds, TimeUnit.SECONDS) From ec4fd9260bd7bed9b1d51cf73b4fd9790e431082 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 16 Feb 2026 18:41:20 +0530 Subject: [PATCH 7/9] test fixes --- .../java/com/formbricks/android/FormbricksInstrumentedTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt index 31fcb0c..3f2a42a 100644 --- a/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt @@ -38,6 +38,8 @@ class FormbricksInstrumentedTest { fun setUp() { val appContext = InstrumentationRegistry.getInstrumentation().targetContext Formbricks.applicationContext = appContext + Formbricks.isInitialized = false + Formbricks.language = "default" UserManager.logout() SurveyManager.environmentDataHolder = null FormbricksApi.service = MockFormbricksApiService() From e75a7a548c56aa763e82453b316d33c39ec1accf Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 16 Feb 2026 18:54:42 +0530 Subject: [PATCH 8/9] fixes test --- .../java/com/formbricks/android/FormbricksInstrumentedTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt index 3f2a42a..fdfd57f 100644 --- a/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt @@ -42,6 +42,7 @@ class FormbricksInstrumentedTest { Formbricks.language = "default" UserManager.logout() SurveyManager.environmentDataHolder = null + SurveyManager.filteredSurveys.clear() FormbricksApi.service = MockFormbricksApiService() } From 95d9f921155b6f4e96f87e807e39765e10712fbb Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 16 Feb 2026 19:12:05 +0530 Subject: [PATCH 9/9] tests --- android/src/androidTest/assets/User.json | 4 +- .../android/FormbricksInstrumentedTest.kt | 79 ++++++++++++++++++- .../android/MockFormbricksApiService.kt | 2 +- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/android/src/androidTest/assets/User.json b/android/src/androidTest/assets/User.json index 31cc23b..a41b6f9 100644 --- a/android/src/androidTest/assets/User.json +++ b/android/src/androidTest/assets/User.json @@ -10,6 +10,8 @@ "userId": "6CCCE716-6783-4D0F-8344-9C7DFA43D8F7" }, "expiresAt": "2035-03-06T10:59:32.359Z" - } + }, + "messages": ["User synced successfully"], + "errors": ["Unknown attribute key: invalidKey"] } } diff --git a/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt index fdfd57f..88cedb4 100644 --- a/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/FormbricksInstrumentedTest.kt @@ -9,6 +9,7 @@ import com.formbricks.android.logger.Logger import com.formbricks.android.manager.SurveyManager import com.formbricks.android.manager.UserManager import com.formbricks.android.model.user.AttributeValue +import com.formbricks.android.network.queue.UpdateQueue import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals @@ -41,6 +42,7 @@ class FormbricksInstrumentedTest { Formbricks.isInitialized = false Formbricks.language = "default" UserManager.logout() + UpdateQueue.reset() SurveyManager.environmentDataHolder = null SurveyManager.filteredSurveys.clear() FormbricksApi.service = MockFormbricksApiService() @@ -278,9 +280,8 @@ class FormbricksInstrumentedTest { @Test fun testLogoutWithoutUserIdDoesNotError() { - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - Formbricks.setup(appContext, FormbricksConfig.Builder(appUrl, environmentId).setLoggingEnabled(true).build()) - waitForSeconds(1) + // Mark SDK as initialized without triggering async operations + Formbricks.isInitialized = true // Logout without ever setting a userId — should not crash assertNull(UserManager.userId) @@ -288,6 +289,78 @@ class FormbricksInstrumentedTest { assertNull(UserManager.userId) } + @Test + fun testSyncUserSetsLanguageFromResponse() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + Formbricks.setup(appContext, FormbricksConfig.Builder(appUrl, environmentId).setLoggingEnabled(true).build()) + waitForSeconds(1) + + assertEquals("default", Formbricks.language) + + // Override the mock to return a user response that includes a language + val mockService = FormbricksApi.service as MockFormbricksApiService + val originalUser = mockService.user + mockService.user = originalUser.copy( + data = originalUser.data.copy( + state = originalUser.data.state.copy( + data = originalUser.data.state.data.copy( + language = "fr" + ) + ) + ) + ) + + // setUserId triggers syncUser, which should pick up the language from the response + Formbricks.setUserId(userId) + waitForSeconds(2) + + assertEquals(userId, UserManager.userId) + assertEquals("fr", Formbricks.language) + } + + @Test + fun testSyncUserCatchBlockOnApiError() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + Formbricks.setup(appContext, FormbricksConfig.Builder(appUrl, environmentId).setLoggingEnabled(true).build()) + waitForSeconds(1) + + // Enable error mode so postUser returns a failure, exercising the catch block + (FormbricksApi.service as MockFormbricksApiService).isErrorResponseNeeded = true + + Formbricks.setUserId(userId) + waitForSeconds(2) + + // The sync should have failed gracefully — userId should not be set from the response + assertNull(UserManager.expiresAt) + } + + @Test + fun testLogoutClearsAllUserState() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + Formbricks.setup(appContext, FormbricksConfig.Builder(appUrl, environmentId).setLoggingEnabled(true).build()) + waitForSeconds(1) + + // Set up a user so we have state to clear + Formbricks.setUserId(userId) + waitForSeconds(2) + assertEquals(userId, UserManager.userId) + assertNotNull(UserManager.expiresAt) + assertNotNull(UserManager.contactId) + + // Set language to something other than default + Formbricks.setLanguage("de") + assertEquals("de", Formbricks.language) + + // Logout should clear all user state + UserManager.logout() + + assertNull(UserManager.userId) + assertNull(UserManager.contactId) + assertNull(UserManager.expiresAt) + assertNull(UserManager.lastDisplayedAt) + assertEquals("default", Formbricks.language) + } + private fun waitForSeconds(seconds: Long) { val latch = CountDownLatch(1) latch.await(seconds, TimeUnit.SECONDS) diff --git a/android/src/androidTest/java/com/formbricks/android/MockFormbricksApiService.kt b/android/src/androidTest/java/com/formbricks/android/MockFormbricksApiService.kt index ea09895..f8cfe79 100644 --- a/android/src/androidTest/java/com/formbricks/android/MockFormbricksApiService.kt +++ b/android/src/androidTest/java/com/formbricks/android/MockFormbricksApiService.kt @@ -12,7 +12,7 @@ import com.formbricks.android.model.error.SDKError class MockFormbricksApiService: FormbricksApiService() { private val gson = Gson() private val environment: EnvironmentResponse - private val user: UserResponse + internal var user: UserResponse var isErrorResponseNeeded = false init {