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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package com.formbricks.android.mobilecore

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.formbricks.android.model.user.Display
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit

@RunWith(AndroidJUnit4::class)
class MobileCoreRuntimeInstrumentedTest {

private val context = InstrumentationRegistry.getInstrumentation().targetContext

/**
* A minimal stand-in for the server-delivered bundle, speaking bridge protocol v1.
* Echoes enough of the payload back to prove state crosses the bridge intact.
*/
private val stubBrain = """
globalThis.formbricksMobileCore = {
protocolVersion: 1,
selectSurvey: function (payload) {
var surveys = (payload.workspaceState.data && payload.workspaceState.data.data.surveys) || [];
var alreadyDisplayed = (payload.userState.displays || []).length > 0;
if (surveys.length === 0 || alreadyDisplayed) {
return { v: 1, shouldDisplay: false, surveyId: null, delaySeconds: null, languageCode: null, reason: "stub: nothing to show" };
}
return {
v: 1,
shouldDisplay: true,
surveyId: surveys[0].id,
delaySeconds: 2,
languageCode: payload.language,
reason: "stub: action=" + payload.action + " userId=" + payload.userState.userId
};
}
};
""".trimIndent()

private val workspaceStateJson = """{ "data": { "data": { "surveys": [ { "id": "survey_123" } ] } } }"""

private fun createRuntime(bundleSource: String): MobileCoreRuntime? {
val latch = CountDownLatch(1)
var runtime: MobileCoreRuntime? = null
MobileCoreRuntime.create(context, bundleSource) {
runtime = it
latch.countDown()
}
assertTrue("runtime creation timed out", latch.await(10, TimeUnit.SECONDS))
return runtime
}

private fun selectSurvey(
runtime: MobileCoreRuntime,
userState: MobileCoreUserState,
language: String = "default"
): MobileCoreDecision? {
val latch = CountDownLatch(1)
var decision: MobileCoreDecision? = null
runtime.selectSurvey("button_clicked", workspaceStateJson, userState, language) {
decision = it
latch.countDown()
}
assertTrue("selectSurvey timed out", latch.await(10, TimeUnit.SECONDS))
return decision
}

@Test
fun runtimeInitializesWithValidBundle() {
val runtime = createRuntime(stubBrain)
assertNotNull(runtime)
runtime?.destroy()
}

@Test
fun runtimeRejectsBundleWithoutGlobal() {
assertNull(createRuntime("var x = 1;"))
}

@Test
fun runtimeRejectsBundleWithWrongProtocolVersion() {
assertNull(createRuntime(stubBrain.replace("protocolVersion: 1", "protocolVersion: 2")))
}

@Test
fun runtimeRejectsBundleThatFailsToEvaluate() {
assertNull(createRuntime("this is not javascript {{{"))
}

@Test
fun selectSurveyReturnsDecisionAndPassesStateThrough() {
val runtime = createRuntime(stubBrain)!!
val userState = MobileCoreUserState(
userId = "user_1",
segments = emptyList(),
displays = emptyList(),
responses = emptyList(),
lastDisplayedAtMs = null
)

val decision = selectSurvey(runtime, userState, language = "de")

assertNotNull(decision)
assertEquals(true, decision?.shouldDisplay)
assertEquals("survey_123", decision?.surveyId)
assertEquals(2.0, decision?.delaySeconds)
assertEquals("de", decision?.languageCode)
assertEquals("stub: action=button_clicked userId=user_1", decision?.reason)
runtime.destroy()
}

@Test
fun selectSurveyRespectsUserStateAcrossBridge() {
val runtime = createRuntime(stubBrain)!!
val userState = MobileCoreUserState(
userId = "user_1",
segments = emptyList(),
displays = listOf(Display(surveyId = "survey_123", createdAt = "2026-07-02T00:00:00Z")),
responses = emptyList(),
lastDisplayedAtMs = null
)

val decision = selectSurvey(runtime, userState)

assertNotNull(decision)
assertEquals(false, decision?.shouldDisplay)
assertNull(decision?.surveyId)
runtime.destroy()
}

@Test
fun selectSurveyReturnsNullWhenBrainThrows() {
val throwingBrain = """
globalThis.formbricksMobileCore = {
protocolVersion: 1,
selectSurvey: function () { throw new Error("boom"); }
};
""".trimIndent()
val runtime = createRuntime(throwingBrain)!!
val userState = MobileCoreUserState(null, null, null, null, null)

val decision = selectSurvey(runtime, userState)

assertNull(decision)
runtime.destroy()
}

@Test
fun loaderBuildsProtocolVersionedUrl() {
assertEquals(
"https://app.formbricks.com/js/mobile/v1/core.umd.cjs",
MobileCoreLoader.bundleUrl("https://app.formbricks.com/")
)
assertFalse(MobileCoreLoader.bundleUrl("http://localhost:3000").contains("//js"))
}
}
18 changes: 18 additions & 0 deletions android/src/main/java/com/formbricks/android/Formbricks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import com.formbricks.android.logger.Logger
import com.formbricks.android.manager.EmbeddedDataManager
import com.formbricks.android.manager.SurveyManager
import com.formbricks.android.manager.UserManager
import com.formbricks.android.mobilecore.MobileCoreLoader
import com.formbricks.android.mobilecore.MobileCoreRuntime
import com.formbricks.android.model.embeddeddata.EmbeddedDataValue
import com.formbricks.android.model.error.SDKError
import com.formbricks.android.model.user.AttributeValue
Expand Down Expand Up @@ -104,6 +106,22 @@ object Formbricks {
SurveyManager.refreshWorkspaceIfNeeded(force = forceRefresh)
UserManager.syncUserStateIfNeeded()

// Fetch the server-delivered decision logic (the mobile core "brain").
// Until it arrives — or if it never does — the SDK runs on its built-in
// native logic, so this is a progressive enhancement, not a dependency.
MobileCoreLoader.load(appUrl) { source ->
if (source == null) {
Logger.d("No mobile core bundle available; using built-in survey logic.")
return@load
}
MobileCoreRuntime.create(applicationContext, source) { runtime ->
SurveyManager.mobileCoreRuntime = runtime
if (runtime != null) {
Logger.d("Mobile core bundle loaded; server-delivered survey logic active.")
}
}
}

isInitialized = true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import com.formbricks.android.model.workspace.InteractionSource
import com.formbricks.android.model.workspace.Segment
import com.formbricks.android.model.workspace.SegmentDeserializer
import com.formbricks.android.model.workspace.Survey
import com.formbricks.android.mobilecore.MobileCoreDecision
import com.formbricks.android.mobilecore.MobileCoreRuntime
import com.formbricks.android.mobilecore.MobileCoreUserState
import com.formbricks.android.model.error.SDKError
import com.formbricks.android.model.user.Display
import com.google.gson.Gson
Expand Down Expand Up @@ -42,6 +45,12 @@ object SurveyManager {
private val prefManager by lazy { Formbricks.applicationContext.getSharedPreferences(FORMBRICKS_PREFS, Context.MODE_PRIVATE) }
internal var filteredSurveys: MutableList<Survey> = mutableListOf()

/**
* The server-delivered JS brain. When present and healthy, it owns the
* survey-selection decision; the native logic below remains as fallback.
*/
internal var mobileCoreRuntime: MobileCoreRuntime? = null

val gson = GsonBuilder()
.registerTypeAdapter(Segment::class.java, SegmentDeserializer())
.create()
Expand Down Expand Up @@ -166,6 +175,61 @@ object SurveyManager {
* Handles the display percentage and the delay of the survey.
*/
fun track(action: String) {
// When the server-delivered brain is available, it owns the decision.
// Any failure inside the remote path falls back to the built-in logic.
val runtime = mobileCoreRuntime
val workspaceStateJson = workspaceDataHolder?.let { Gson().toJson(it.originalResponseMap) }
if (runtime != null && workspaceStateJson != null) {
val userState = MobileCoreUserState(
userId = UserManager.userId,
segments = UserManager.segments,
displays = UserManager.displays,
responses = UserManager.responses,
lastDisplayedAtMs = UserManager.lastDisplayedAt?.time
)
runtime.selectSurvey(action, workspaceStateJson, userState, Formbricks.language) { decision ->
if (decision == null) {
nativeTrack(action)
} else {
handleRemoteDecision(decision)
}
}
return
}

nativeTrack(action)
}

/**
* Executes a brain decision. The shell keeps only the native-only parts:
* delay scheduling, language propagation, and presenting the survey fragment.
*/
private fun handleRemoteDecision(decision: MobileCoreDecision) {
val surveyId = decision.surveyId
if (decision.shouldDisplay != true || surveyId == null) {
Logger.d("Mobile core decided not to display a survey: ${decision.reason ?: "no reason given"}")
return
}

Logger.d("Mobile core selected survey $surveyId: ${decision.reason ?: "no reason given"}")

decision.languageCode?.let { Formbricks.setLanguage(it) }

isShowingSurvey = true
val timeout = decision.delaySeconds ?: 0.0
if (timeout > 0.0) {
Logger.d("Delaying survey \"$surveyId\" by $timeout seconds")
}
stopDisplayTimer()
displayTimer.schedule(object : TimerTask() {
override fun run() {
Formbricks.showSurvey(surveyId)
}
}, Date(System.currentTimeMillis() + (timeout * 1000).toLong()))
}

/** The built-in decision logic, used until the brain is available and as its fallback. */
private fun nativeTrack(action: String) {
val actionClasses = workspaceDataHolder?.data?.data?.actionClasses ?: listOf()
val codeActionClasses = actionClasses.filter { it.type == "code" }
val actionClass = codeActionClasses.firstOrNull { it.key == action }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.formbricks.android.mobilecore

import androidx.annotation.Keep
import com.formbricks.android.model.user.Display
import com.google.gson.annotations.SerializedName

/**
* The decision returned by the remote mobile core (the server-delivered JS "brain")
* when asked whether a tracked action should display a survey.
*/
@Keep
data class MobileCoreDecision(
/** Protocol version of the decision payload. Lets old shells reject decisions
* produced by a newer, incompatible brain instead of misinterpreting them. */
@SerializedName("v") val v: Int?,
@SerializedName("shouldDisplay") val shouldDisplay: Boolean?,
@SerializedName("surveyId") val surveyId: String?,
@SerializedName("delaySeconds") val delaySeconds: Double?,
/** The resolved survey language code (e.g. "default" or "de"), already validated
* against the survey's enabled languages by the brain. */
@SerializedName("languageCode") val languageCode: String?,
/** Human-readable explanation of the decision, used for logging only. */
@SerializedName("reason") val reason: String?
)

/**
* The user-state snapshot the shell hands to the brain alongside the workspace state.
* Mirrors what [com.formbricks.android.manager.UserManager] persists; the brain owns
* all interpretation of it.
*/
@Keep
data class MobileCoreUserState(
@SerializedName("userId") val userId: String?,
@SerializedName("segments") val segments: List<String>?,
@SerializedName("displays") val displays: List<Display>?,
@SerializedName("responses") val responses: List<String>?,
/** Milliseconds since epoch; JS-friendly representation of `lastDisplayedAt`. */
@SerializedName("lastDisplayedAtMs") val lastDisplayedAtMs: Long?
)
Loading
Loading