diff --git a/android/src/androidTest/java/com/formbricks/android/mobilecore/MobileCoreRuntimeInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/mobilecore/MobileCoreRuntimeInstrumentedTest.kt new file mode 100644 index 0000000..8fe0ec4 --- /dev/null +++ b/android/src/androidTest/java/com/formbricks/android/mobilecore/MobileCoreRuntimeInstrumentedTest.kt @@ -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")) + } +} diff --git a/android/src/main/java/com/formbricks/android/Formbricks.kt b/android/src/main/java/com/formbricks/android/Formbricks.kt index 6282c09..fee40ac 100644 --- a/android/src/main/java/com/formbricks/android/Formbricks.kt +++ b/android/src/main/java/com/formbricks/android/Formbricks.kt @@ -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 @@ -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 } 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 da3269f..53cb82c 100644 --- a/android/src/main/java/com/formbricks/android/manager/SurveyManager.kt +++ b/android/src/main/java/com/formbricks/android/manager/SurveyManager.kt @@ -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 @@ -42,6 +45,12 @@ object SurveyManager { private val prefManager by lazy { Formbricks.applicationContext.getSharedPreferences(FORMBRICKS_PREFS, Context.MODE_PRIVATE) } internal var filteredSurveys: MutableList = 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() @@ -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 } diff --git a/android/src/main/java/com/formbricks/android/mobilecore/MobileCoreDecision.kt b/android/src/main/java/com/formbricks/android/mobilecore/MobileCoreDecision.kt new file mode 100644 index 0000000..814333f --- /dev/null +++ b/android/src/main/java/com/formbricks/android/mobilecore/MobileCoreDecision.kt @@ -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?, + @SerializedName("displays") val displays: List?, + @SerializedName("responses") val responses: List?, + /** Milliseconds since epoch; JS-friendly representation of `lastDisplayedAt`. */ + @SerializedName("lastDisplayedAtMs") val lastDisplayedAtMs: Long? +) diff --git a/android/src/main/java/com/formbricks/android/mobilecore/MobileCoreLoader.kt b/android/src/main/java/com/formbricks/android/mobilecore/MobileCoreLoader.kt new file mode 100644 index 0000000..0f08b84 --- /dev/null +++ b/android/src/main/java/com/formbricks/android/mobilecore/MobileCoreLoader.kt @@ -0,0 +1,91 @@ +package com.formbricks.android.mobilecore + +import android.content.Context +import com.formbricks.android.Formbricks +import com.formbricks.android.logger.Logger +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Downloads the server-delivered mobile core bundle (the JS "brain") and caches the + * last successfully fetched copy, so the SDK keeps working offline and survives a + * temporarily unreachable server. The bundle is versioned by bridge protocol in its + * URL path: a v1 shell only ever asks for a v1-compatible bundle. + */ +internal object MobileCoreLoader { + + /** Bridge protocol version this shell speaks. Bump only on breaking bridge changes. */ + const val BRIDGE_PROTOCOL_VERSION = 1 + + private const val FORMBRICKS_PREFS = "formbricks_prefs" + internal const val PREF_BUNDLE = "mobileCoreBundleKey" + internal const val PREF_BUNDLE_URL = "mobileCoreBundleURLKey" + + private val client by lazy { + OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() + } + + private val prefManager + get() = Formbricks.applicationContext.getSharedPreferences(FORMBRICKS_PREFS, Context.MODE_PRIVATE) + + fun bundleUrl(appUrl: String): String = + "${appUrl.trimEnd('/')}/js/mobile/v$BRIDGE_PROTOCOL_VERSION/core.umd.cjs" + + /** + * Fetches the bundle from the server, falling back to the cached copy on any failure. + * The completion is called from a background thread with the JS source, or `null` + * when neither network nor cache can provide one (the shell then falls back to its + * built-in native logic). + */ + fun load(appUrl: String, completion: (String?) -> Unit) { + val url = bundleUrl(appUrl) + val request = try { + Request.Builder().url(url).build() + } catch (e: IllegalArgumentException) { + completion(cachedBundle(appUrl)) + return + } + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Logger.w("Unable to fetch mobile core bundle from $url, falling back to cached copy.") + completion(cachedBundle(appUrl)) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + val source = if (it.isSuccessful) it.body?.string() else null + if (source.isNullOrEmpty()) { + Logger.w("Unable to fetch mobile core bundle from $url, falling back to cached copy.") + completion(cachedBundle(appUrl)) + return + } + + cache(source, appUrl) + completion(source) + } + } + }) + } + + private fun cache(bundle: String, appUrl: String) { + prefManager.edit() + .putString(PREF_BUNDLE, bundle) + .putString(PREF_BUNDLE_URL, appUrl) + .apply() + } + + private fun cachedBundle(appUrl: String): String? { + // A cached brain from a different host must not run against this one. + if (prefManager.getString(PREF_BUNDLE_URL, null) != appUrl) return null + return prefManager.getString(PREF_BUNDLE, null) + } +} diff --git a/android/src/main/java/com/formbricks/android/mobilecore/MobileCoreRuntime.kt b/android/src/main/java/com/formbricks/android/mobilecore/MobileCoreRuntime.kt new file mode 100644 index 0000000..7ab2072 --- /dev/null +++ b/android/src/main/java/com/formbricks/android/mobilecore/MobileCoreRuntime.kt @@ -0,0 +1,119 @@ +package com.formbricks.android.mobilecore + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.webkit.WebView +import android.webkit.WebViewClient +import com.formbricks.android.logger.Logger +import com.google.gson.Gson + +/** + * Hosts the server-delivered mobile core bundle in a hidden [WebView] and exposes its + * decision API to the shell. Google Play's device-and-network-abuse policy exempts + * code running in a WebView/JS interpreter from the "no downloaded executable code" + * rule — this is that path, mirroring the JavaScriptCore host on iOS. + * + * The runtime is intentionally dumb about survey logic: it serializes state in, + * gets a decision out, and never interprets the rules itself. The WebView is never + * attached to a window; it exists purely as a JS engine. + * + * All WebView interaction happens on the main thread ([WebView.evaluateJavascript] + * requirement); decision callbacks are therefore invoked on the main thread too. + */ +internal class MobileCoreRuntime private constructor(private val webView: WebView) { + + fun selectSurvey( + action: String, + workspaceStateJson: String, + userState: MobileCoreUserState, + language: String, + callback: (MobileCoreDecision?) -> Unit + ) { + val call = """ + JSON.stringify(globalThis.$GLOBAL_NAME.selectSurvey({ + action: ${gson.toJson(action)}, + workspaceState: $workspaceStateJson, + userState: ${gson.toJson(userState)}, + language: ${gson.toJson(language)}, + nowMs: Date.now(), + })) + """.trimIndent() + + mainHandler.post { + webView.evaluateJavascript(call) { raw -> + // A JS exception surfaces as the literal string "null". + val decision = try { + // evaluateJavascript returns the JS value JSON-encoded, so the + // stringified decision arrives as a quoted JSON string literal. + val json = gson.fromJson(raw, String::class.java) + json?.let { gson.fromJson(it, MobileCoreDecision::class.java) } + } catch (e: Exception) { + null + } + + if (decision == null) { + Logger.w("Mobile core returned an unreadable decision for action '$action'.") + } + callback(decision) + } + } + } + + fun destroy() { + mainHandler.post { webView.destroy() } + } + + companion object { + /** Global the bundle must define: `globalThis.formbricksMobileCore = { protocolVersion, selectSurvey }`. */ + private const val GLOBAL_NAME = "formbricksMobileCore" + + private val gson = Gson() + private val mainHandler = Handler(Looper.getMainLooper()) + + /** + * Creates the runtime by evaluating the bundle in a fresh off-screen WebView. + * Calls back with `null` (from the main thread) when the bundle doesn't + * evaluate, doesn't define the expected global, or speaks a different bridge + * protocol than this shell — the caller then keeps using native logic. + */ + @SuppressLint("SetJavaScriptEnabled") + fun create(context: Context, bundleSource: String, onReady: (MobileCoreRuntime?) -> Unit) { + mainHandler.post { + val webView = WebView(context) + webView.settings.javaScriptEnabled = true + webView.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + webView.evaluateJavascript(bundleSource) { _ -> + validate(webView, onReady) + } + } + } + // A blank page is enough; the bundle is evaluated into it once loaded. + webView.loadDataWithBaseURL(null, "", "text/html", "utf-8", null) + } + } + + private fun validate(webView: WebView, onReady: (MobileCoreRuntime?) -> Unit) { + val probe = "globalThis.$GLOBAL_NAME && typeof globalThis.$GLOBAL_NAME.selectSurvey === 'function'" + + " ? globalThis.$GLOBAL_NAME.protocolVersion : null" + webView.evaluateJavascript(probe) { result -> + val version = result?.trim()?.toIntOrNull() + when (version) { + MobileCoreLoader.BRIDGE_PROTOCOL_VERSION -> onReady(MobileCoreRuntime(webView)) + null -> { + Logger.e(RuntimeException("Mobile core bundle did not define $GLOBAL_NAME.selectSurvey.")) + webView.destroy() + onReady(null) + } + else -> { + Logger.e(RuntimeException("Mobile core bundle speaks bridge protocol v$version, shell speaks v${MobileCoreLoader.BRIDGE_PROTOCOL_VERSION}. Ignoring bundle.")) + webView.destroy() + onReady(null) + } + } + } + } + } +}