diff --git a/packages/android/.gitignore b/packages/android/.gitignore
new file mode 100644
index 0000000000..aa724b7707
--- /dev/null
+++ b/packages/android/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/packages/android/app/.gitignore b/packages/android/app/.gitignore
new file mode 100644
index 0000000000..42afabfd2a
--- /dev/null
+++ b/packages/android/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/packages/android/app/build.gradle.kts b/packages/android/app/build.gradle.kts
new file mode 100644
index 0000000000..d2bf794cfa
--- /dev/null
+++ b/packages/android/app/build.gradle.kts
@@ -0,0 +1,62 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ kotlin("kapt")
+}
+
+android {
+ namespace = "com.formbricks.demo"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.formbricks.demo"
+ minSdk = 26
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ buildFeatures {
+ compose = true
+ dataBinding = true
+ }
+}
+
+dependencies {
+ implementation(project(":formbricksSDK"))
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.fragment.ktx)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+}
\ No newline at end of file
diff --git a/packages/android/app/proguard-rules.pro b/packages/android/app/proguard-rules.pro
new file mode 100644
index 0000000000..f1a2ed7494
--- /dev/null
+++ b/packages/android/app/proguard-rules.pro
@@ -0,0 +1,26 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
+
+-dontwarn androidx.databinding.**
+-keep class androidx.databinding.** { *; }
+-keep class * extends androidx.databinding.DataBinderMapper { *; }
+-keep class com.formbricks.formbrickssdk.DataBinderMapperImpl { *; }
\ No newline at end of file
diff --git a/packages/android/app/src/androidTest/java/com/formbricks/demo/ExampleInstrumentedTest.kt b/packages/android/app/src/androidTest/java/com/formbricks/demo/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000000..0391316e3e
--- /dev/null
+++ b/packages/android/app/src/androidTest/java/com/formbricks/demo/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.formbricks.demo
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.formbricks.demo", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/packages/android/app/src/main/AndroidManifest.xml b/packages/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..d0460a8f9c
--- /dev/null
+++ b/packages/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/android/app/src/main/java/com/formbricks/demo/MainActivity.kt b/packages/android/app/src/main/java/com/formbricks/demo/MainActivity.kt
new file mode 100644
index 0000000000..baacbbbfd1
--- /dev/null
+++ b/packages/android/app/src/main/java/com/formbricks/demo/MainActivity.kt
@@ -0,0 +1,71 @@
+package com.formbricks.demo
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.FragmentActivity
+import com.formbricks.demo.ui.theme.DemoTheme
+import com.formbricks.formbrickssdk.Formbricks
+import com.formbricks.formbrickssdk.helper.FormbricksConfig
+import java.util.UUID
+
+class MainActivity : FragmentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val config = FormbricksConfig.Builder("[API_HOST]","[ENVIRONMENT_ID]")
+ .setLoggingEnabled(true)
+ .setFragmentManager(supportFragmentManager)
+ Formbricks.setup(this, config.build())
+
+ Formbricks.logout()
+ Formbricks.setUserId(UUID.randomUUID().toString())
+
+ enableEdgeToEdge()
+ setContent {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ DemoTheme {
+ FormbricksDemo()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun FormbricksDemo() {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Button(onClick = {
+ Formbricks.track("click_demo_button")
+ }) {
+ Text(
+ text = "Click me!",
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun GreetingPreview() {
+ DemoTheme {
+ FormbricksDemo()
+ }
+}
\ No newline at end of file
diff --git a/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Color.kt b/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Color.kt
new file mode 100644
index 0000000000..f8d7441db4
--- /dev/null
+++ b/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.formbricks.demo.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
diff --git a/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Theme.kt b/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Theme.kt
new file mode 100644
index 0000000000..4b5618a286
--- /dev/null
+++ b/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Theme.kt
@@ -0,0 +1,51 @@
+package com.formbricks.demo.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun DemoTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true, content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme, typography = Typography, content = content
+ )
+}
\ No newline at end of file
diff --git a/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Type.kt b/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Type.kt
new file mode 100644
index 0000000000..a4720f9f76
--- /dev/null
+++ b/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Type.kt
@@ -0,0 +1,33 @@
+package com.formbricks.demo.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )/* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/packages/android/app/src/main/res/drawable/ic_launcher_background.xml b/packages/android/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000000..07d5da9cbf
--- /dev/null
+++ b/packages/android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/packages/android/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000000..2b068d1146
--- /dev/null
+++ b/packages/android/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/packages/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 0000000000..6f3b755bf5
--- /dev/null
+++ b/packages/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/packages/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 0000000000..6f3b755bf5
--- /dev/null
+++ b/packages/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/packages/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000000..c209e78ecd
Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/packages/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/packages/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..b2dfe3d1ba
Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/packages/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/packages/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000000..4f0f1d64e5
Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/packages/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/packages/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..62b611da08
Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/packages/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/packages/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..948a3070fe
Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/packages/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/packages/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..1b9a6956b3
Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/packages/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/packages/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..28d4b77f9f
Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/packages/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/packages/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..9287f50836
Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/packages/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/packages/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..aa7d6427e6
Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/packages/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/packages/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..9126ae37cb
Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/packages/android/app/src/main/res/values/colors.xml b/packages/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..f8c6127d32
--- /dev/null
+++ b/packages/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/packages/android/app/src/main/res/values/strings.xml b/packages/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..aee84b4bfe
--- /dev/null
+++ b/packages/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Demo
+
\ No newline at end of file
diff --git a/packages/android/app/src/main/res/values/themes.xml b/packages/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000000..f8dcc514d4
--- /dev/null
+++ b/packages/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/android/app/src/main/res/xml/backup_rules.xml b/packages/android/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000000..fa0f996d2c
--- /dev/null
+++ b/packages/android/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/packages/android/app/src/main/res/xml/data_extraction_rules.xml b/packages/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000000..9ee9997b0b
--- /dev/null
+++ b/packages/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/android/app/src/main/res/xml/network_security_config.xml b/packages/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000000..e1303219b7
--- /dev/null
+++ b/packages/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,7 @@
+
+
+
+ 192.168.0.12
+ localhost
+
+
\ No newline at end of file
diff --git a/packages/android/app/src/test/java/com/formbricks/demo/ExampleUnitTest.kt b/packages/android/app/src/test/java/com/formbricks/demo/ExampleUnitTest.kt
new file mode 100644
index 0000000000..2d05d2816a
--- /dev/null
+++ b/packages/android/app/src/test/java/com/formbricks/demo/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.formbricks.demo
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/packages/android/build.gradle.kts b/packages/android/build.gradle.kts
new file mode 100644
index 0000000000..5ea216fa07
--- /dev/null
+++ b/packages/android/build.gradle.kts
@@ -0,0 +1,7 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.android.library) apply false
+}
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/.gitignore b/packages/android/formbricksSDK/.gitignore
new file mode 100644
index 0000000000..42afabfd2a
--- /dev/null
+++ b/packages/android/formbricksSDK/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/build.gradle.kts b/packages/android/formbricksSDK/build.gradle.kts
new file mode 100644
index 0000000000..336a0b3c93
--- /dev/null
+++ b/packages/android/formbricksSDK/build.gradle.kts
@@ -0,0 +1,67 @@
+plugins {
+ id("com.android.library")
+ kotlin("android")
+ kotlin("kapt")
+ kotlin("plugin.serialization") version "2.1.0"
+ id("org.jetbrains.dokka") version "1.9.10"
+}
+
+android {
+ namespace = "com.formbricks.formbrickssdk"
+ compileSdk = 35
+
+ defaultConfig {
+ minSdk = 26
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ buildFeatures {
+ dataBinding = true
+ viewBinding = true
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.annotation)
+ implementation(libs.androidx.appcompat)
+
+ implementation(libs.gson)
+ implementation(libs.retrofit)
+ implementation(libs.retrofit.converter.gson)
+ implementation(libs.retrofit.converter.scalars)
+ implementation(libs.okhttp3.logging.interceptor)
+
+ implementation(libs.material)
+
+ implementation(libs.timber)
+
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.androidx.legacy.support.v4)
+ implementation(libs.androidx.lifecycle.livedata.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
+ implementation(libs.androidx.fragment.ktx)
+ implementation(libs.androidx.databinding.common)
+
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+}
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/consumer-rules.pro b/packages/android/formbricksSDK/consumer-rules.pro
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/android/formbricksSDK/proguard-rules.pro b/packages/android/formbricksSDK/proguard-rules.pro
new file mode 100644
index 0000000000..423583a252
--- /dev/null
+++ b/packages/android/formbricksSDK/proguard-rules.pro
@@ -0,0 +1,35 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+-keeppackagenames com.formbricks.**
+
+-keep class com.formbricks.** { *; }
+
+-keepclassmembers,allowobfuscation class * {
+ @com.google.gson.annotations.SerializedName ;
+}
+
+-keepattributes SourceFile,LineNumberTable,Exceptions,InnerClasses,Signature,Deprecated,*Annotation*,EnclosingMethod
+
+# add all known-to-be-safely-shrinkable classes to the beginning of line below
+-keep class !androidx.legacy.**,!com.google.android.**,!androidx.** { *; }
+-keep class android.support.v4.app.** { *; }
+
+
+
+
+# Retrofit
+-dontwarn okio.**
+-keep class com.squareup.okhttp.** { *; }
+-keep interface com.squareup.okhttp.** { *; }
+-keep class retrofit.** { *; }
+-dontwarn com.squareup.okhttp.**
+
+-keep class retrofit.** { *; }
+-keepclasseswithmembers class * {
+ @retrofit.http.* ;
+}
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/androidTest/java/com/formbricks/formbrickssdk/ExampleInstrumentedTest.kt b/packages/android/formbricksSDK/src/androidTest/java/com/formbricks/formbrickssdk/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000000..c100aaa669
--- /dev/null
+++ b/packages/android/formbricksSDK/src/androidTest/java/com/formbricks/formbrickssdk/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.formbricks.formbrickssdk
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.formbricks.formbrickssdk.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/main/AndroidManifest.xml b/packages/android/formbricksSDK/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..eb41a3970b
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/Formbricks.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/Formbricks.kt
new file mode 100644
index 0000000000..2b8b9b69e3
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/Formbricks.kt
@@ -0,0 +1,214 @@
+package com.formbricks.formbrickssdk
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import androidx.annotation.Keep
+import androidx.fragment.app.FragmentManager
+import com.formbricks.formbrickssdk.api.FormbricksApi
+import com.formbricks.formbrickssdk.helper.FormbricksConfig
+import com.formbricks.formbrickssdk.manager.SurveyManager
+import com.formbricks.formbrickssdk.manager.UserManager
+import com.formbricks.formbrickssdk.model.error.SDKError
+import com.formbricks.formbrickssdk.webview.FormbricksFragment
+import timber.log.Timber
+
+
+@Keep
+object Formbricks {
+ internal lateinit var applicationContext: Context
+
+ internal lateinit var environmentId: String
+ internal lateinit var appUrl: String
+ internal var language: String = "default"
+ internal var loggingEnabled: Boolean = true
+ private var fragmentManager: FragmentManager? = null
+ private var isInitialized = false
+
+ /**
+ * Initializes the Formbricks SDK with the given [Context] config [FormbricksConfig].
+ * This method is mandatory to be called, and should be only once per application lifecycle.
+ * To show a survey, the SDK needs a [FragmentManager] instance.
+ *
+ * ```
+ * class MainActivity : FragmentActivity() {
+ *
+ * override fun onCreate() {
+ * super.onCreate()
+ * val config = FormbricksConfig.Builder("http://localhost:3000","my_environment_id")
+ * .setLoggingEnabled(true)
+ * .setFragmentManager(supportFragmentManager)
+ * .build())
+ * Formbricks.setup(this, config.build())
+ * }
+ * }
+ * ```
+ *
+ */
+ fun setup(context: Context, config: FormbricksConfig) {
+ applicationContext = context
+
+ appUrl = config.appUrl
+ environmentId = config.environmentId
+ loggingEnabled = config.loggingEnabled
+ fragmentManager = config.fragmentManager
+
+ config.userId?.let { UserManager.set(it) }
+ config.attributes?.let { UserManager.setAttributes(it) }
+ config.attributes?.get("language")?.let { UserManager.setLanguage(it) }
+
+ FormbricksApi.initialize()
+ SurveyManager.refreshEnvironmentIfNeeded()
+ UserManager.syncUserStateIfNeeded()
+
+ if (loggingEnabled) {
+ Timber.plant(Timber.DebugTree())
+ }
+
+ isInitialized = true
+ }
+
+ /**
+ * Sets the user id for the current user with the given [String].
+ * The SDK must be initialized before calling this method.
+ *
+ * ```
+ * Formbricks.setUserId("my_user_id")
+ * ```
+ *
+ */
+ fun setUserId(userId: String) {
+ if (!isInitialized) {
+ Timber.e(SDKError.sdkIsNotInitialized)
+ return
+ }
+ UserManager.set(userId)
+ }
+
+ /**
+ * Adds an attribute for the current user with the given [String] value and [String] key.
+ * The SDK must be initialized before calling this method.
+ *
+ * ```
+ * Formbricks.setAttribute("my_attribute", "key")
+ * ```
+ *
+ */
+ fun setAttribute(attribute: String, key: String) {
+ if (!isInitialized) {
+ Timber.e(SDKError.sdkIsNotInitialized)
+ return
+ }
+ UserManager.addAttribute(attribute, key)
+ }
+
+ /**
+ * Sets the user attributes for the current user with the given [Map] of [String] values and [String] keys.
+ * The SDK must be initialized before calling this method.
+ *
+ * ```
+ * Formbricks.setAttributes(mapOf(Pair("key", "my_attribute")))
+ * ```
+ *
+ */
+ fun setAttributes(attributes: Map) {
+ if (!isInitialized) {
+ Timber.e(SDKError.sdkIsNotInitialized)
+ return
+ }
+ UserManager.setAttributes(attributes)
+ }
+
+ /**
+ * Sets the language for the current user with the given [String].
+ * The SDK must be initialized before calling this method.
+ *
+ * ```
+ * Formbricks.setLanguage("de")
+ * ```
+ *
+ */
+ fun setLanguage(language: String) {
+ if (!isInitialized) {
+ Timber.e(SDKError.sdkIsNotInitialized)
+ return
+ }
+ Formbricks.language = language
+ UserManager.addAttribute(language, "language")
+ }
+
+ /**
+ * Tracks an action with the given [String]. The SDK will process the action and it will present the survey if any of them can be triggered.
+ * The SDK must be initialized before calling this method.
+ *
+ * ```
+ * Formbricks.track("button_clicked")
+ * ```
+ *
+ */
+ fun track(action: String) {
+ if (!isInitialized) {
+ Timber.e(SDKError.sdkIsNotInitialized)
+ return
+ }
+
+ if (!isInternetAvailable()) {
+ Timber.w(SDKError.connectionIsNotAvailable)
+ return
+ }
+
+ SurveyManager.track(action)
+ }
+
+ /**
+ * Logs out the current user. This will clear the user attributes and the user id.
+ * The SDK must be initialized before calling this method.
+ *
+ * ```
+ * Formbricks.logout()
+ * ```
+ *
+ */
+ fun logout() {
+ if (!isInitialized) {
+ Timber.e(SDKError.sdkIsNotInitialized)
+ return
+ }
+
+ UserManager.logout()
+ }
+
+ /**
+ * Sets the [FragmentManager] instance. The SDK always needs the actual [FragmentManager] to
+ * display surveys, so make sure you update it whenever it changes.
+ * The SDK must be initialized before calling this method.
+ *
+ * ```
+ * Formbricks.setFragmentManager(supportFragmentMananger)
+ * ```
+ *
+ */
+ fun setFragmentManager(fragmentManager: FragmentManager) {
+ this.fragmentManager = fragmentManager
+ }
+
+ /// Assembles the survey fragment and presents it
+ internal fun showSurvey(id: String) {
+ if (fragmentManager == null) {
+ Timber.e(SDKError.fragmentManagerIsNotSet)
+ return
+ }
+
+ fragmentManager?.let {
+ FormbricksFragment.show(it, surveyId = id)
+ }
+ }
+
+ /// Checks if the phone has active network connection
+ private fun isInternetAvailable(): Boolean {
+ val connectivityManager = applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val network = connectivityManager.activeNetwork ?: return false
+ val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ }
+}
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/api/FormbricksApi.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/api/FormbricksApi.kt
new file mode 100644
index 0000000000..b83b130f32
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/api/FormbricksApi.kt
@@ -0,0 +1,39 @@
+package com.formbricks.formbrickssdk.api
+
+import com.formbricks.formbrickssdk.Formbricks
+import com.formbricks.formbrickssdk.model.environment.EnvironmentDataHolder
+import com.formbricks.formbrickssdk.model.user.PostUserBody
+import com.formbricks.formbrickssdk.model.user.UserResponse
+import com.formbricks.formbrickssdk.network.FormbricksApiService
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+object FormbricksApi {
+ private val service = FormbricksApiService()
+
+ fun initialize() {
+ service.initialize(
+ appUrl = Formbricks.appUrl,
+ isLoggingEnabled = Formbricks.loggingEnabled
+ )
+ }
+
+ suspend fun getEnvironmentState(): Result = withContext(Dispatchers.IO) {
+ try {
+ val response = service.getEnvironmentStateObject(Formbricks.environmentId)
+ val result = response.getOrThrow()
+ Result.success(result)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun postUser(userId: String, attributes: Map?): Result = withContext(Dispatchers.IO) {
+ try {
+ val result = service.postUser(Formbricks.environmentId, PostUserBody.create(userId, attributes)).getOrThrow()
+ Result.success(result)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/api/error/FormbricksAPIError.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/api/error/FormbricksAPIError.kt
new file mode 100644
index 0000000000..d7b02fe82d
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/api/error/FormbricksAPIError.kt
@@ -0,0 +1,9 @@
+package com.formbricks.formbrickssdk.api.error
+
+import com.google.gson.annotations.SerializedName
+
+data class FormbricksAPIError(
+ @SerializedName("code") val code: String,
+ @SerializedName("message") val messageText: String,
+ @SerializedName("details") val details: Map? = null
+) : RuntimeException(messageText)
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/extensions/DateExtensions.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/extensions/DateExtensions.kt
new file mode 100644
index 0000000000..296c826026
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/extensions/DateExtensions.kt
@@ -0,0 +1,62 @@
+package com.formbricks.formbrickssdk.extensions
+
+import com.formbricks.formbrickssdk.model.environment.EnvironmentDataHolder
+import com.formbricks.formbrickssdk.model.user.UserState
+import com.formbricks.formbrickssdk.model.user.UserStateData
+import java.text.SimpleDateFormat
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+internal const val dateFormatPattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
+
+fun Date.dateString(): String {
+ val dateFormat = SimpleDateFormat(dateFormatPattern, Locale.getDefault())
+ dateFormat.timeZone = TimeZone.getTimeZone("UTC")
+ return dateFormat.format(this)
+}
+
+fun UserStateData.lastDisplayAt(): Date? {
+ lastDisplayAt?.let {
+ try {
+ val formatter = DateTimeFormatter.ofPattern(dateFormatPattern)
+ val dateTime = LocalDateTime.parse(it, formatter)
+ return Date.from(dateTime.atZone(ZoneId.of("GMT")).toInstant())
+ } catch (e: Exception) {
+ return null
+ }
+ }
+
+ return null
+}
+
+fun UserState.expiresAt(): Date? {
+ expiresAt?.let {
+ try {
+ val formatter = DateTimeFormatter.ofPattern(dateFormatPattern)
+ val dateTime = LocalDateTime.parse(it, formatter)
+ return Date.from(dateTime.atZone(ZoneId.of("GMT")).toInstant())
+ } catch (e: Exception) {
+ return null
+ }
+ }
+
+ return null
+}
+
+fun EnvironmentDataHolder.expiresAt(): Date? {
+ data?.expiresAt?.let {
+ try {
+ val formatter = DateTimeFormatter.ofPattern(dateFormatPattern)
+ val dateTime = LocalDateTime.parse(it, formatter)
+ return Date.from(dateTime.atZone(ZoneId.of("GMT")).toInstant())
+ } catch (e: Exception) {
+ return null
+ }
+ }
+
+ return null
+}
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/extensions/Guard.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/extensions/Guard.kt
new file mode 100644
index 0000000000..783816e024
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/extensions/Guard.kt
@@ -0,0 +1,33 @@
+package com.formbricks.formbrickssdk.extensions
+
+/**
+ * Swift like guard statement.
+ * To achieve that, on null the statement must return an empty T object
+ */
+inline fun T?.guard(block: T?.() -> Unit): T {
+ this?.let {
+ return it
+ } ?: run {
+ block()
+ }
+
+ return T::class.java.newInstance()
+}
+
+inline fun String?.guardEmpty(block: String?.() -> Unit): String {
+ if (isNullOrBlank()) {
+ block()
+ } else {
+ return this
+ }
+
+ return ""
+}
+
+inline fun guardLet(vararg elements: T?, closure: () -> Nothing): List {
+ return if (elements.all { it != null }) {
+ elements.filterNotNull()
+ } else {
+ closure()
+ }
+}
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/helper/FormbricksConfig.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/helper/FormbricksConfig.kt
new file mode 100644
index 0000000000..8e7cd9a364
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/helper/FormbricksConfig.kt
@@ -0,0 +1,62 @@
+package com.formbricks.formbrickssdk.helper
+
+import androidx.annotation.Keep
+import androidx.fragment.app.FragmentManager
+
+/**
+ * Configuration options for the SDK
+ *
+ * Use the [Builder] to configure the options, then pass the result of [build] to the setup method.
+ */
+@Keep
+class FormbricksConfig private constructor(
+ val appUrl: String,
+ val environmentId: String,
+ val userId: String?,
+ 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 loggingEnabled = false
+ private var fragmentManager: FragmentManager? = null
+
+ fun setUserId(userId: String): Builder {
+ this.userId = userId
+ return this
+ }
+
+ fun setAttributes(attributes: MutableMap): Builder {
+ this.attributes = attributes
+ return this
+ }
+
+ fun addAttribute(attribute: String, key: String): Builder {
+ this.attributes[key] = attribute
+ return this
+ }
+
+ fun setLoggingEnabled(loggingEnabled: Boolean): Builder {
+ this.loggingEnabled = loggingEnabled
+ return this
+ }
+
+ fun setFragmentManager(fragmentManager: FragmentManager): Builder {
+ this.fragmentManager = fragmentManager
+ return this
+ }
+
+ fun build(): FormbricksConfig {
+ return FormbricksConfig(
+ appUrl = appUrl,
+ environmentId = environmentId,
+ userId = userId,
+ attributes = attributes,
+ loggingEnabled = loggingEnabled,
+ fragmentManager = fragmentManager
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/helper/JsonHelper.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/helper/JsonHelper.kt
new file mode 100644
index 0000000000..8e425cc636
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/helper/JsonHelper.kt
@@ -0,0 +1,44 @@
+package com.formbricks.formbrickssdk.helper
+
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+
+fun mapToJsonElement(map: Map): JsonElement {
+ return buildJsonObject {
+ map.forEach { (key, value) ->
+ when (value) {
+ is String -> put(key, value)
+ is Number -> put(key, value)
+ is Boolean -> put(key, value)
+ is Map<*, *> -> {
+ @Suppress("UNCHECKED_CAST")
+ put(key, mapToJsonElement(value as Map))
+ }
+ is List<*> -> {
+ put(key, JsonArray(value.map { elem -> mapToJsonElementItem(elem) }))
+ }
+ null -> put(key, JsonNull)
+ else -> throw IllegalArgumentException("Unsupported type: ${value::class}")
+ }
+ }
+ }
+}
+
+fun mapToJsonElementItem(value: Any?): JsonElement {
+ return when (value) {
+ is String -> JsonPrimitive(value)
+ is Number -> JsonPrimitive(value)
+ is Boolean -> JsonPrimitive(value)
+ is Map<*, *> -> {
+ @Suppress("UNCHECKED_CAST")
+ mapToJsonElement(value as Map)
+ }
+ is List<*> -> JsonArray(value.map { elem -> mapToJsonElementItem(elem) })
+ null -> JsonNull
+ else -> throw IllegalArgumentException("Unsupported type: ${value::class}")
+ }
+}
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/manager/SurveyManager.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/manager/SurveyManager.kt
new file mode 100644
index 0000000000..b7473d1968
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/manager/SurveyManager.kt
@@ -0,0 +1,284 @@
+package com.formbricks.formbrickssdk.manager
+
+import android.content.Context
+import com.formbricks.formbrickssdk.Formbricks
+import com.formbricks.formbrickssdk.api.FormbricksApi
+import com.formbricks.formbrickssdk.extensions.expiresAt
+import com.formbricks.formbrickssdk.extensions.guard
+import com.formbricks.formbrickssdk.model.environment.DisplayOptionType
+import com.formbricks.formbrickssdk.model.environment.EnvironmentDataHolder
+import com.formbricks.formbrickssdk.model.environment.Survey
+import com.formbricks.formbrickssdk.model.user.Display
+import com.google.gson.Gson
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+import java.util.Date
+import java.util.Timer
+import java.util.TimerTask
+
+interface FileUploadListener {
+ fun fileUploaded(url: String, uploadId: String)
+}
+
+/**
+ * The SurveyManager is responsible for managing the surveys that are displayed to the user.
+ * Filtering surveys based on the user's segments, responses, and displays.
+ */
+object SurveyManager {
+ private const val REFRESH_STATE_ON_ERROR_TIMEOUT_IN_MINUTES = 10
+ private const val FORMBRICKS_PREFS = "formbricks_prefs"
+ private const val PREF_FORMBRICKS_DATA_HOLDER = "formbricksDataHolder"
+
+ private val refreshTimer = Timer()
+ private var displayTimer = Timer()
+ private val prefManager by lazy { Formbricks.applicationContext.getSharedPreferences(FORMBRICKS_PREFS, Context.MODE_PRIVATE) }
+ private var filteredSurveys: MutableList = mutableListOf()
+
+ private var environmentDataHolderJson: String?
+ get() {
+ return prefManager.getString(PREF_FORMBRICKS_DATA_HOLDER, "")
+ }
+ set(value) {
+ if (null != value) {
+ prefManager.edit().putString(PREF_FORMBRICKS_DATA_HOLDER, value).apply()
+ } else {
+ prefManager.edit().remove(PREF_FORMBRICKS_DATA_HOLDER).apply()
+ }
+ }
+
+ private var backingEnvironmentDataHolder: EnvironmentDataHolder? = null
+ var environmentDataHolder: EnvironmentDataHolder?
+ get() {
+ if (null != backingEnvironmentDataHolder) {
+ return backingEnvironmentDataHolder
+ }
+ synchronized(this) {
+ backingEnvironmentDataHolder = environmentDataHolderJson?.let { json ->
+ try {
+ Gson().fromJson(json, EnvironmentDataHolder::class.java)
+ } catch (e: Exception) {
+ Timber.tag("SurveyManager").e("Unable to retrieve environment data from the local storage.")
+ null
+ }
+ }
+ return backingEnvironmentDataHolder
+ }
+ }
+ set(value) {
+ synchronized(this) {
+ backingEnvironmentDataHolder = value
+ environmentDataHolderJson = Gson().toJson(value)
+ }
+ }
+
+ /**
+ * Fills up the [filteredSurveys] array
+ */
+ fun filterSurveys() {
+ val surveys = environmentDataHolder?.data?.data?.surveys.guard { return }
+ val displays = UserManager.displays ?: listOf()
+ val responses = UserManager.responses ?: listOf()
+ val segments = UserManager.segments ?: listOf()
+
+ filteredSurveys = filterSurveysBasedOnDisplayType(surveys, displays, responses).toMutableList()
+ filteredSurveys = filterSurveysBasedOnRecontactDays(filteredSurveys, environmentDataHolder?.data?.data?.project?.recontactDays?.toInt()).toMutableList()
+
+ if (UserManager.userId != null) {
+ if (segments.isEmpty()) {
+ filteredSurveys = mutableListOf()
+ return
+ }
+
+ filteredSurveys = filterSurveysBasedOnSegments(filteredSurveys, segments).toMutableList()
+ }
+ }
+
+ /**
+ * Checks if the environment state needs to be refreshed based on its [expiresAt] property,
+ * and if so, refreshes it, starts the refresh timer, and filters the surveys.
+ */
+ fun refreshEnvironmentIfNeeded() {
+ environmentDataHolder?.expiresAt()?.let {
+ if (it.after(Date())) {
+ Timber.tag("SurveyManager").d("Environment state is still valid until $it")
+ filterSurveys()
+ return
+ }
+ }
+
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ environmentDataHolder = FormbricksApi.getEnvironmentState().getOrThrow()
+ startRefreshTimer(environmentDataHolder?.expiresAt())
+ filterSurveys()
+ } catch (e: Exception) {
+ Timber.tag("SurveyManager").e(e, "Unable to refresh environment state.")
+ startErrorTimer()
+ }
+ }
+ }
+
+ /**
+ * Checks if there are any surveys to display, based in the track action, and if so, displays the first one.
+ * Handles the display percentage and the delay of the survey.
+ */
+ fun track(action: String) {
+ val actionClasses = environmentDataHolder?.data?.data?.actionClasses ?: listOf()
+ val codeActionClasses = actionClasses.filter { it.type == "code" }
+ val actionClass = codeActionClasses.firstOrNull { it.key == action }
+ val firstSurveyWithActionClass = filteredSurveys.firstOrNull { survey ->
+ val triggers = survey.triggers ?: listOf()
+ triggers.firstOrNull { it.actionClass?.name.equals(actionClass?.name) } != null
+ }
+
+ val shouldDisplay = shouldDisplayBasedOnPercentage(firstSurveyWithActionClass?.displayPercentage)
+
+ if (shouldDisplay) {
+ firstSurveyWithActionClass?.id?.let {
+ val timeout = firstSurveyWithActionClass.delay ?: 0.0
+ stopDisplayTimer()
+ displayTimer.schedule(object : TimerTask() {
+ override fun run() {
+ Formbricks.showSurvey(it)
+ }
+
+ }, Date.from(Instant.now().plusSeconds(timeout.toLong())))
+ }
+ }
+ }
+
+ private fun stopDisplayTimer() {
+ try {
+ displayTimer.cancel()
+ displayTimer = Timer()
+ } catch (_: Exception) {
+
+ }
+ }
+
+ /**
+ * Posts a survey response to the Formbricks API.
+ */
+ fun postResponse(surveyId: String?) {
+ val id = surveyId.guard {
+ Timber.tag("SurveyManager").e("Survey id is mandatory to set.")
+ return
+ }
+
+ UserManager.onResponse(id)
+ }
+
+ /**
+ * Creates a new display for the survey. It is called when the survey is displayed to the user.
+ */
+ fun onNewDisplay(surveyId: String?) {
+ val id = surveyId.guard {
+ Timber.tag("SurveyManager").e("Survey id is mandatory to set.")
+ return
+ }
+
+ UserManager.onDisplay(id)
+ }
+
+ /**
+ * Starts a timer to refresh the environment state after the given timeout [expiresAt].
+ */
+ private fun startRefreshTimer(expiresAt: Date?) {
+ val date = expiresAt.guard { return }
+ refreshTimer.schedule(object: TimerTask() {
+ override fun run() {
+ Timber.tag("SurveyManager").d("Refreshing environment state.")
+ refreshEnvironmentIfNeeded()
+ }
+
+ }, date)
+ }
+
+ /**
+ * When an error occurs, it starts a timer to refresh the environment state after the given timeout.
+ */
+ private fun startErrorTimer() {
+ val targetDate = Date(System.currentTimeMillis() + 1000 * 60 * REFRESH_STATE_ON_ERROR_TIMEOUT_IN_MINUTES)
+ refreshTimer.schedule(object: TimerTask() {
+ override fun run() {
+ Timber.tag("SurveyManager").d("Refreshing environment state after an error")
+ refreshEnvironmentIfNeeded()
+ }
+
+ }, targetDate)
+ }
+
+ /**
+ * Filters the surveys based on the display type and limit.
+ */
+ private fun filterSurveysBasedOnDisplayType(surveys: List, displays: List, responses: List): List {
+ return surveys.filter { survey ->
+ when (survey.displayOption) {
+ DisplayOptionType.RESPOND_MULTIPLE -> true
+
+ DisplayOptionType.DISPLAY_ONCE -> {
+ displays.none { it.surveyId == survey.id }
+ }
+
+ DisplayOptionType.DISPLAY_MULTIPLE -> {
+ responses.none { it == survey.id }
+ }
+
+ DisplayOptionType.DISPLAY_SOME -> {
+ survey.displayLimit?.let { limit ->
+ if (responses.any { it == survey.id }) {
+ return@filter false
+ }
+ displays.count { it.surveyId == survey.id } < limit
+ } ?: true
+ }
+
+ else -> {
+ Timber.tag("SurveyManager").e("Invalid Display Option")
+ false
+ }
+ }
+ }
+ }
+
+ /**
+ * Filters the surveys based on the recontact days and the [UserManager.lastDisplayedAt] date.
+ */
+ private fun filterSurveysBasedOnRecontactDays(surveys: List, defaultRecontactDays: Int?): List {
+ return surveys.filter { survey ->
+ val lastDisplayedAt = UserManager.lastDisplayedAt.guard { return@filter true }
+
+ val recontactDays = survey.recontactDays ?: defaultRecontactDays
+
+ if (recontactDays != null) {
+ val daysBetween = ChronoUnit.DAYS.between(lastDisplayedAt.toInstant(), Instant.now())
+ return@filter daysBetween >= recontactDays.toInt()
+ }
+
+ true
+ }
+ }
+
+ /**
+ * Filters the surveys based on the user's segments.
+ */
+ private fun filterSurveysBasedOnSegments(surveys: List, segments: List): List {
+ return surveys.filter { survey ->
+ val segmentId = survey.segment?.id?.guard { return@filter false }
+ segments.contains(segmentId)
+ }
+ }
+
+ /**
+ * Decides if the survey should be displayed based on the display percentage.
+ */
+ private fun shouldDisplayBasedOnPercentage(displayPercentage: Double?): Boolean {
+ val percentage = displayPercentage.guard { return true }
+ val randomNum = (0 until 10000).random() / 100.0
+ return randomNum <= percentage
+ }
+}
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/manager/UserManager.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/manager/UserManager.kt
new file mode 100644
index 0000000000..fa8b1e9b9d
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/manager/UserManager.kt
@@ -0,0 +1,226 @@
+package com.formbricks.formbrickssdk.manager
+
+import android.content.Context
+import com.formbricks.formbrickssdk.Formbricks
+import com.formbricks.formbrickssdk.api.FormbricksApi
+import com.formbricks.formbrickssdk.extensions.dateString
+import com.formbricks.formbrickssdk.extensions.expiresAt
+import com.formbricks.formbrickssdk.extensions.guard
+import com.formbricks.formbrickssdk.extensions.lastDisplayAt
+import com.formbricks.formbrickssdk.model.user.Display
+import com.formbricks.formbrickssdk.network.queue.UpdateQueue
+import com.google.gson.Gson
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.util.Date
+import java.util.Timer
+import java.util.TimerTask
+
+/**
+ * Store and manage user state and sync with the server when needed.
+ */
+object UserManager {
+ private const val FORMBROCKS_PERFS = "formbricks_prefs"
+ private const val USER_ID_KEY = "userIdKey"
+ private const val SEGMENTS_KEY = "segmentsKey"
+ private const val DISPLAYS_KEY = "displaysKey"
+ private const val RESPONSES_KEY = "responsesKey"
+ private const val LAST_DISPLAYED_AT_KEY = "lastDisplayedAtKey"
+ private const val EXPIRES_AT_KEY = "expiresAtKey"
+ private val prefManager by lazy { Formbricks.applicationContext.getSharedPreferences(FORMBROCKS_PERFS, Context.MODE_PRIVATE) }
+
+ private var backingUserId: String? = null
+ private var backingSegments: List? = null
+ private var backingDisplays: List? = null
+ private var backingResponses: List? = null
+ private var backingLastDisplayedAt: Date? = null
+ private var backingExpiresAt: Date? = null
+ private val syncTimer = Timer()
+
+ /**
+ * Starts an update queue with the given user id.
+ *
+ * @param userId
+ */
+ fun set(userId: String) {
+ UpdateQueue.current.setUserId(userId)
+ }
+
+ /**
+ * Starts an update queue with the given attribute.
+ *
+ * @param attribute
+ * @param key
+ */
+ fun addAttribute(attribute: String, key: String) {
+ UpdateQueue.current.addAttribute(key, attribute)
+ }
+
+ /**
+ * Starts an update queue with the given attributes.
+ *
+ * @param attributes
+ */
+ fun setAttributes(attributes: Map) {
+ UpdateQueue.current.setAttributes(attributes)
+ }
+
+ /**
+ * Starts an update queue with the given language..
+ *
+ * @param language
+ */
+ fun setLanguage(language: String) {
+ UpdateQueue.current.setLanguage(language)
+ }
+
+ /**
+ * Saves [surveyId] to the [displays] property and the the current date to the [lastDisplayedAt] property.
+ *
+ * @param surveyId
+ */
+ fun onDisplay(surveyId: String) {
+ val lastDisplayedAt = Date()
+ val newDisplays = displays?.toMutableList() ?: mutableListOf()
+ newDisplays.add(Display(surveyId, lastDisplayedAt.dateString()))
+ displays = newDisplays
+ this.lastDisplayedAt = lastDisplayedAt
+ }
+
+ /**
+ * Saves [surveyId] to the [responses] property.
+ *
+ * @param surveyId
+ */
+ fun onResponse(surveyId: String) {
+ val newResponses = responses?.toMutableList() ?: mutableListOf()
+ newResponses.add(surveyId)
+ responses = newResponses
+ }
+
+ /**
+ * Syncs the user state with the server if the user id is set and the expiration date has passed.
+ */
+ fun syncUserStateIfNeeded() {
+ val id = userId
+ val expiresAt = expiresAt
+ if (id != null && expiresAt != null && Date().before(expiresAt)) {
+ syncUser(id)
+ } else {
+ backingSegments = emptyList()
+ backingDisplays = emptyList()
+ backingResponses = emptyList()
+ }
+ }
+
+ /**
+ * Syncs the user state with the server, calls the [SurveyManager.filterSurveys] method and starts the sync timer.
+ *
+ * @param id
+ * @param attributes
+ */
+ fun syncUser(id: String, attributes: Map? = null) {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val userResponse = FormbricksApi.postUser(id, attributes).getOrThrow()
+ userId = userResponse.data.state.data.userId
+ segments = userResponse.data.state.data.segments
+ displays = userResponse.data.state.data.displays
+ responses = userResponse.data.state.data.responses
+ lastDisplayedAt = userResponse.data.state.data.lastDisplayAt()
+ expiresAt = userResponse.data.state.expiresAt()
+ UpdateQueue.current.reset()
+ SurveyManager.filterSurveys()
+ startSyncTimer()
+ } catch (e: Exception) {
+ Timber.tag("SurveyManager").e(e, "Unable to post survey response.")
+ }
+ }
+ }
+
+ /**
+ * Logs out the user and clears the user state.
+ */
+ fun logout() {
+ prefManager.edit().apply {
+ remove(USER_ID_KEY)
+ remove(SEGMENTS_KEY)
+ remove(DISPLAYS_KEY)
+ remove(RESPONSES_KEY)
+ remove(LAST_DISPLAYED_AT_KEY)
+ remove(EXPIRES_AT_KEY)
+ apply()
+ }
+ backingUserId = null
+ backingSegments = null
+ backingDisplays = null
+ backingResponses = null
+ backingLastDisplayedAt = null
+ backingExpiresAt = null
+ UpdateQueue.current.reset()
+ }
+
+ private fun startSyncTimer() {
+ val expiresAt = expiresAt.guard { return }
+ val userId = userId.guard { return }
+ syncTimer.schedule(object: TimerTask() {
+ override fun run() {
+ syncUser(userId)
+ }
+
+ }, expiresAt)
+ }
+
+
+ var userId: String?
+ get() = backingUserId ?: prefManager.getString(USER_ID_KEY, null).also { backingUserId = it }
+ private set(value) {
+ backingUserId = value
+ prefManager.edit().putString(USER_ID_KEY, value).apply()
+ }
+
+ var segments: List?
+ get() = backingSegments ?: prefManager.getStringSet(SEGMENTS_KEY, emptySet())?.toList().also { backingSegments = it }
+ private set(value) {
+ backingSegments = value
+ prefManager.edit().putStringSet(SEGMENTS_KEY, value?.toSet()).apply()
+ }
+
+ var displays: List?
+ get() {
+ if (backingDisplays == null) {
+ val json = prefManager.getString(DISPLAYS_KEY, null)
+ if (json != null) {
+ backingDisplays = Gson().fromJson(json, Array::class.java).toList()
+ }
+ }
+ return backingDisplays
+ }
+ private set(value) {
+ backingDisplays = value
+ prefManager.edit().putString(DISPLAYS_KEY, Gson().toJson(value)).apply()
+ }
+
+ var responses: List?
+ get() = backingResponses ?: prefManager.getStringSet(RESPONSES_KEY, emptySet())?.toList().also { backingResponses = it }
+ private set(value) {
+ backingResponses = value
+ prefManager.edit().putStringSet(RESPONSES_KEY, value?.toSet()).apply()
+ }
+
+ var lastDisplayedAt: Date?
+ get() = backingLastDisplayedAt ?: prefManager.getLong(LAST_DISPLAYED_AT_KEY, 0L).takeIf { it > 0 }?.let { Date(it) }.also { backingLastDisplayedAt = it }
+ private set(value) {
+ backingLastDisplayedAt = value
+ prefManager.edit().putLong(LAST_DISPLAYED_AT_KEY, value?.time ?: 0L).apply()
+ }
+
+ var expiresAt: Date?
+ get() = backingExpiresAt ?: prefManager.getLong(EXPIRES_AT_KEY, 0L).takeIf { it > 0 }?.let { Date(it) }.also { backingExpiresAt = it }
+ private set(value) {
+ backingExpiresAt = value
+ prefManager.edit().putLong(EXPIRES_AT_KEY, value?.time ?: 0L).apply()
+ }
+}
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/BaseFormbricksResponse.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/BaseFormbricksResponse.kt
new file mode 100644
index 0000000000..527586c89e
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/BaseFormbricksResponse.kt
@@ -0,0 +1,3 @@
+package com.formbricks.formbrickssdk.model
+
+interface BaseFormbricksResponse
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/ActionClass.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/ActionClass.kt
new file mode 100644
index 0000000000..c7bd8c213d
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/ActionClass.kt
@@ -0,0 +1,16 @@
+package com.formbricks.formbrickssdk.model.environment
+
+import com.google.gson.annotations.SerializedName
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonIgnoreUnknownKeys
+
+@OptIn(ExperimentalSerializationApi::class)
+@Serializable
+@JsonIgnoreUnknownKeys
+data class ActionClass(
+ @SerializedName("id") val id: String?,
+ @SerializedName("type") val type: String?,
+ @SerializedName("name") val name: String?,
+ @SerializedName("key") val key: String?,
+)
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/ActionClassReference.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/ActionClassReference.kt
new file mode 100644
index 0000000000..1c4ef5fa9d
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/ActionClassReference.kt
@@ -0,0 +1,13 @@
+package com.formbricks.formbrickssdk.model.environment
+
+import com.google.gson.annotations.SerializedName
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonIgnoreUnknownKeys
+
+@OptIn(ExperimentalSerializationApi::class)
+@Serializable
+@JsonIgnoreUnknownKeys
+data class ActionClassReference(
+ @SerializedName("name") val name: String?
+)
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/BrandColor.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/BrandColor.kt
new file mode 100644
index 0000000000..0cf5aef4a0
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/BrandColor.kt
@@ -0,0 +1,9 @@
+package com.formbricks.formbrickssdk.model.environment
+
+import com.google.gson.annotations.SerializedName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BrandColor(
+ @SerializedName("light") val light: String?
+)
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/EnvironmentData.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/EnvironmentData.kt
new file mode 100644
index 0000000000..35501007dc
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/EnvironmentData.kt
@@ -0,0 +1,15 @@
+package com.formbricks.formbrickssdk.model.environment
+
+import com.google.gson.annotations.SerializedName
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonIgnoreUnknownKeys
+
+@OptIn(ExperimentalSerializationApi::class)
+@Serializable
+@JsonIgnoreUnknownKeys
+data class EnvironmentData(
+ @SerializedName("surveys") val surveys: List?,
+ @SerializedName("actionClasses") val actionClasses: List?,
+ @SerializedName("project") val project: Project
+)
\ No newline at end of file
diff --git a/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/EnvironmentDataHolder.kt b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/EnvironmentDataHolder.kt
new file mode 100644
index 0000000000..7263e33afb
--- /dev/null
+++ b/packages/android/formbricksSDK/src/main/java/com/formbricks/formbrickssdk/model/environment/EnvironmentDataHolder.kt
@@ -0,0 +1,48 @@
+package com.formbricks.formbrickssdk.model.environment
+
+import com.google.gson.Gson
+import com.google.gson.JsonElement
+
+data class EnvironmentDataHolder(
+ val data: EnvironmentResponseData?,
+ val originalResponseMap: Map
+)
+
+@Suppress("UNCHECKED_CAST")
+fun EnvironmentDataHolder.getSurveyJson(surveyId: String): JsonElement? {
+ val responseMap = originalResponseMap["data"] as? Map<*, *>
+ val dataMap = responseMap?.get("data") as? Map<*, *>
+ val surveyArray = dataMap?.get("surveys") as? ArrayList