Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -432,103 +432,6 @@ 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?) {
Expand All @@ -537,50 +440,6 @@ class SurveyManagerInstrumentedTest {
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,
Expand Down
102 changes: 88 additions & 14 deletions android/src/main/java/com/formbricks/android/Formbricks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ 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.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 {
Expand Down Expand Up @@ -53,13 +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
Expand All @@ -69,7 +75,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)
Expand All @@ -80,6 +89,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.
*
* ```
Expand All @@ -94,21 +108,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")
* ```
*
*/
Expand All @@ -118,19 +139,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)
}

/**
* 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 with the given [Map] of [String] values and [String] keys.
* 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<String, String>) {
fun setAttributes(attributes: Map<String, AttributeValue>) {
if (!isInitialized) {
val error = SDKError.sdkIsNotInitialized
Logger.e(error)
Expand Down Expand Up @@ -217,15 +291,15 @@ 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)
return
}

fragmentManager?.let {
FormbricksFragment.show(it, surveyId = id, overlay = overlay)
FormbricksFragment.show(it, surveyId = id)
}
}

Expand All @@ -236,4 +310,4 @@ object Formbricks {
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,7 +46,7 @@ object FormbricksApi {
}
}

suspend fun postUser(userId: String, attributes: Map<String, *>?): Result<UserResponse> = withContext(Dispatchers.IO) {
suspend fun postUser(userId: String, attributes: Map<String, AttributeValue>?): Result<UserResponse> = withContext(Dispatchers.IO) {
retryApiCall {
try {
val result = service.postUser(Formbricks.environmentId, PostUserBody.create(userId, attributes)).getOrThrow()
Expand Down
Loading
Loading