Compare commits

..

3 Commits

Author SHA1 Message Date
Dhruwang Jariwala
0e0d3780d3 fix: removed completed surveys from survey list in integrations (#4838)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-06 09:10:51 +00:00
Peter Pesti-Varga
38ff01aedc feat: Android & iOS SDK (#4871) 2025-03-06 09:43:49 +01:00
Dhruwang Jariwala
cdf687ad80 fix: delete webhook button visibility (#4862) 2025-03-06 07:40:00 +00:00
163 changed files with 7135 additions and 87 deletions

View File

@@ -1,4 +1,5 @@
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
@@ -15,7 +16,6 @@ import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";

View File

@@ -1,4 +1,5 @@
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
@@ -19,7 +20,6 @@ import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";

View File

@@ -0,0 +1,48 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { selectSurvey } from "@formbricks/lib/survey/service";
import { transformPrismaSurvey } from "@formbricks/lib/survey/utils";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
export const getSurveys = reactCache(
async (environmentId: string): Promise<TSurvey[]> =>
cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
status: {
not: "completed",
},
},
select: selectSurvey,
orderBy: {
updatedAt: "desc",
},
});
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSurveys-${environmentId}`],
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
}
)()
);

View File

@@ -1,3 +1,4 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
@@ -21,7 +22,6 @@ import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getNotionDatabases } from "@formbricks/lib/notion/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";

View File

@@ -1,3 +1,4 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
@@ -14,7 +15,6 @@ import { getIntegrationByType } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";

View File

@@ -21,14 +21,14 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { deleteWebhookAction, testEndpointAction, updateWebhookAction } from "../actions";
import { TWebhookInput } from "../types/webhooks";
interface ActionSettingsTabProps {
interface WebhookSettingsTabProps {
webhook: Webhook;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
isReadOnly: boolean;
}
export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: ActionSettingsTabProps) => {
export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: WebhookSettingsTabProps) => {
const { t } = useTranslate();
const router = useRouter();
const { register, handleSubmit } = useForm({
@@ -219,7 +219,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: Ac
<div className="flex justify-between border-t border-slate-200 py-6">
<div>
{webhook.source === "user" && !isReadOnly && (
{!isReadOnly && (
<Button
type="button"
variant="destructive"

15
packages/android/.gitignore vendored Normal file
View File

@@ -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

1
packages/android/app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -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)
}

26
packages/android/app/proguard-rules.pro vendored Normal file
View File

@@ -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 { *; }

View File

@@ -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)
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Demo"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustPan"
android:theme="@style/Theme.Demo">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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()
}
}

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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
)
*/
)

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Demo</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Demo" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.0.12</domain>
<domain includeSubdomains="true">localhost</domain>
</domain-config>
</network-security-config>

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -0,0 +1 @@
/build

View File

@@ -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)
}

View File

@@ -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 <fields>;
}
-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.* <methods>;
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -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<String, String>) {
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)
}
}

View File

@@ -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<EnvironmentDataHolder> = 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<String, *>?): Result<UserResponse> = withContext(Dispatchers.IO) {
try {
val result = service.postUser(Formbricks.environmentId, PostUserBody.create(userId, attributes)).getOrThrow()
Result.success(result)
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -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<String, String>? = null
) : RuntimeException(messageText)

View File

@@ -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
}

View File

@@ -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 <reified T> 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 <T: Any> guardLet(vararg elements: T?, closure: () -> Nothing): List<T> {
return if (elements.all { it != null }) {
elements.filterNotNull()
} else {
closure()
}
}

View File

@@ -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<String,String>?,
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<String,String> = mutableMapOf()
private var loggingEnabled = false
private var fragmentManager: FragmentManager? = null
fun setUserId(userId: String): Builder {
this.userId = userId
return this
}
fun setAttributes(attributes: MutableMap<String,String>): 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
)
}
}
}

View File

@@ -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<String, Any?>): 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<String, Any?>))
}
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<String, Any?>)
}
is List<*> -> JsonArray(value.map { elem -> mapToJsonElementItem(elem) })
null -> JsonNull
else -> throw IllegalArgumentException("Unsupported type: ${value::class}")
}
}

View File

@@ -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<Survey> = 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<Survey>, displays: List<Display>, responses: List<String>): List<Survey> {
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<Survey>, defaultRecontactDays: Int?): List<Survey> {
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<Survey>, segments: List<String>): List<Survey> {
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
}
}

View File

@@ -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<String>? = null
private var backingDisplays: List<Display>? = null
private var backingResponses: List<String>? = 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<String, String>) {
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<String, String>? = 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<String>?
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<Display>?
get() {
if (backingDisplays == null) {
val json = prefManager.getString(DISPLAYS_KEY, null)
if (json != null) {
backingDisplays = Gson().fromJson(json, Array<Display>::class.java).toList()
}
}
return backingDisplays
}
private set(value) {
backingDisplays = value
prefManager.edit().putString(DISPLAYS_KEY, Gson().toJson(value)).apply()
}
var responses: List<String>?
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()
}
}

View File

@@ -0,0 +1,3 @@
package com.formbricks.formbrickssdk.model
interface BaseFormbricksResponse

View File

@@ -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?,
)

View File

@@ -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?
)

View File

@@ -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?
)

View File

@@ -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<Survey>?,
@SerializedName("actionClasses") val actionClasses: List<ActionClass>?,
@SerializedName("project") val project: Project
)

View File

@@ -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<String, Any>
)
@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<Map<String, Any?>>
val firstSurvey = surveyArray?.firstOrNull { it["id"] == surveyId }
firstSurvey?.let {
return Gson().toJsonTree(it)
}
return null
}
@Suppress("UNCHECKED_CAST")
fun EnvironmentDataHolder.getStyling(surveyId: String): JsonElement? {
val responseMap = originalResponseMap["data"] as? Map<*, *>
val dataMap = responseMap?.get("data") as? Map<*, *>
val surveyArray = dataMap?.get("surveys") as? ArrayList<Map<String, Any?>>
val firstSurvey = surveyArray?.firstOrNull { it["id"] == surveyId }
firstSurvey?.get("styling")?.let {
return Gson().toJsonTree(it)
}
return null
}
@Suppress("UNCHECKED_CAST")
fun EnvironmentDataHolder.getProjectStylingJson(): JsonElement? {
val responseMap = originalResponseMap["data"] as? Map<*, *>
val dataMap = responseMap?.get("data") as? Map<*, *>
val projectMap = dataMap?.get("project") as? Map<*, *>
val stylingMap = projectMap?.get("styling") as? Map<String, Any?>
stylingMap?.let {
return Gson().toJsonTree(it)
}
return null
}

View File

@@ -0,0 +1,10 @@
package com.formbricks.formbrickssdk.model.environment
import com.formbricks.formbrickssdk.model.BaseFormbricksResponse
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.Serializable
@Serializable
data class EnvironmentResponse(
@SerializedName("data") val data: EnvironmentResponseData,
): BaseFormbricksResponse

View File

@@ -0,0 +1,14 @@
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 EnvironmentResponseData(
@SerializedName("data") val data: EnvironmentData,
@SerializedName("expiresAt") val expiresAt: String?
)

View File

@@ -0,0 +1,19 @@
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 Project(
@SerializedName("id") val id: String?,
@SerializedName("recontactDays") val recontactDays: Double?,
@SerializedName("clickOutsideClose") val clickOutsideClose: Boolean?,
@SerializedName("darkOverlay") val darkOverlay: Boolean?,
@SerializedName("placement") val placement: String?,
@SerializedName("inAppSurveyBranding") val inAppSurveyBranding: Boolean?,
@SerializedName("styling") val styling: Styling?
)

View File

@@ -0,0 +1,21 @@
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 Segment(
@SerializedName("id") val id: String? = null,
@SerializedName("createdAt") val createdAt: String? = null,
@SerializedName("updatedAt") val updatedAt: String? = null,
@SerializedName("title") val title: String? = null,
@SerializedName("description") val description: String? = null,
@SerializedName("isPrivate") val isPrivate: Boolean? = null,
@SerializedName("filters") val filters: List<String>? = null,
@SerializedName("environmentId") val environmentId: String? = null,
@SerializedName("surveys") val surveys: List<String>? = null
)

View File

@@ -0,0 +1,14 @@
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 Styling(
@SerializedName("roundness") val roundness: Double? = null,
@SerializedName("allowStyleOverwrite") val allowStyleOverwrite: Boolean? = null,
)

View File

@@ -0,0 +1,31 @@
package com.formbricks.formbrickssdk.model.environment
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
@Serializable
enum class DisplayOptionType {
@SerialName("respondMultiple") RESPOND_MULTIPLE,
@SerialName("displayOnce") DISPLAY_ONCE,
@SerialName("displayMultiple") DISPLAY_MULTIPLE,
@SerialName("displaySome") DISPLAY_SOME,
}
@OptIn(ExperimentalSerializationApi::class)
@Serializable
@JsonIgnoreUnknownKeys
data class Survey(
@SerializedName("id") val id: String,
@SerializedName("name") val name: String,
@SerializedName("triggers") val triggers: List<Trigger>?,
@SerializedName("recontactDays") val recontactDays: Double?,
@SerializedName("displayLimit") val displayLimit: Double?,
@SerializedName("delay") val delay: Double?,
@SerializedName("displayPercentage") val displayPercentage: Double?,
@SerializedName("displayOption") val displayOption: DisplayOptionType?,
@SerializedName("segment") val segment: Segment?,
@SerializedName("styling") val styling: Styling?,
)

View File

@@ -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 Trigger(
@SerializedName("actionClass") val actionClass: ActionClassReference?
)

View File

@@ -0,0 +1,7 @@
package com.formbricks.formbrickssdk.model.error
object SDKError {
val sdkIsNotInitialized = RuntimeException("Formbricks SDK is not initialized")
val fragmentManagerIsNotSet = RuntimeException("The fragment manager is not set.")
val connectionIsNotAvailable = RuntimeException("There is no connection.")
}

View File

@@ -0,0 +1,11 @@
package com.formbricks.formbrickssdk.model.javascript
import com.google.gson.annotations.SerializedName
enum class EventType {
@SerializedName("onClose") ON_CLOSE,
@SerializedName("onFinished") ON_FINISHED,
@SerializedName("onDisplayCreated") ON_DISPLAY_CREATED,
@SerializedName("onResponseCreated") ON_RESPONSE_CREATED,
@SerializedName("onFilePick") ON_FILE_PICK,
}

View File

@@ -0,0 +1,25 @@
package com.formbricks.formbrickssdk.model.javascript
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
data class FileUploadData(
@SerializedName("event") val event: EventType,
@SerializedName("fileUploadParams") val fileUploadParams: FileUploadParams,
) {
companion object {
fun from(string: String): FileUploadData {
return Gson().fromJson(string, FileUploadData::class.java)
}
}
}
data class FileUploadParams(
@SerializedName("allowedFileExtensions") val allowedFileExtensions: String?,
@SerializedName("allowMultipleFiles") val allowMultipleFiles: Boolean
) {
fun allowedExtensionsArray(): Array<String> {
return allowedFileExtensions?.split(",")?.map { it }?.toTypedArray() ?: arrayOf()
}
}

View File

@@ -0,0 +1,18 @@
package com.formbricks.formbrickssdk.model.javascript
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
data class JsMessageData(
@SerializedName("event") val event: EventType,
) {
companion object {
fun from(string: String): JsMessageData {
return try {
Gson().fromJson(string, JsMessageData::class.java)
} catch (e: Exception) {
throw IllegalArgumentException("Invalid JSON format: ${e.message}", e)
}
}
}
}

View File

@@ -0,0 +1,17 @@
package com.formbricks.formbrickssdk.model.upload
import com.google.gson.annotations.SerializedName
data class FetchStorageUrlRequestBody (
@SerializedName("fileName") val fileName: String,
@SerializedName("fileType") val fileType: String,
@SerializedName("allowedFileExtensions") val allowedFileExtensions: List<String>?,
@SerializedName("surveyId") val surveyId: String,
@SerializedName("accessType") val accessType: String,
) {
companion object {
fun create(fileName: String, fileType: String, allowedFileExtensions: List<String>?, surveyId: String, accessType: String = "public"): FetchStorageUrlRequestBody {
return FetchStorageUrlRequestBody(fileName, fileType, allowedFileExtensions, surveyId, accessType)
}
}
}

View File

@@ -0,0 +1,7 @@
package com.formbricks.formbrickssdk.model.upload
import com.google.gson.annotations.SerializedName
data class FetchStorageUrlResponse(
@SerializedName("data") val data: StorageData
)

View File

@@ -0,0 +1,28 @@
//package com.formbricks.formbrickssdk.model.upload
//
//import com.formbricks.formbrickssdk.model.javascript.FileData
//import com.google.gson.annotations.SerializedName
//
//data class FileUploadBody(
// @SerializedName("fileName") val fileName: String,
// @SerializedName("fileType") val fileType: String,
// @SerializedName("surveyId") val surveyId: String?,
// @SerializedName("signature") val signature: String,
// @SerializedName("timestamp") val timestamp: String,
// @SerializedName("uuid") val uuid: String,
// @SerializedName("fileBase64String") val fileBase64String: String,
//) {
// companion object {
// fun create(file: FileData, storageData: StorageData, surveyId: String?): FileUploadBody {
// return FileUploadBody(
// fileName = storageData.updatedFileName,
// fileType = file.type,
// surveyId = surveyId,
// signature = storageData.signingData.signature,
// uuid = storageData.signingData.uuid,
// timestamp = storageData.signingData.timestamp.toString(),
// fileBase64String = file.base64
// )
// }
// }
//}

View File

@@ -0,0 +1,9 @@
package com.formbricks.formbrickssdk.model.upload
import com.google.gson.annotations.SerializedName
data class SigningData(
@SerializedName("signature") val signature: String,
@SerializedName("timestamp") val timestamp: Long,
@SerializedName("uuid") val uuid: String
)

View File

@@ -0,0 +1,10 @@
package com.formbricks.formbrickssdk.model.upload
import com.google.gson.annotations.SerializedName
data class StorageData(
@SerializedName("signedUrl") val signedUrl: String,
@SerializedName("signingData") val signingData: SigningData,
@SerializedName("updatedFileName") val updatedFileName: String,
@SerializedName("fileUrl") val fileUrl: String
)

View File

@@ -0,0 +1,8 @@
package com.formbricks.formbrickssdk.model.user
import com.google.gson.annotations.SerializedName
data class Display(
@SerializedName("surveyId") val surveyId: String,
@SerializedName("createdAt") val createdAt: String
)

View File

@@ -0,0 +1,14 @@
package com.formbricks.formbrickssdk.model.user
import com.google.gson.annotations.SerializedName
data class PostUserBody(
@SerializedName("userId") val userId: String,
@SerializedName("attributes") val attributes: Map<String, *>?
) {
companion object {
fun create(userId: String, attributes: Map<String, *>?): PostUserBody {
return PostUserBody(userId, attributes)
}
}
}

View File

@@ -0,0 +1,7 @@
package com.formbricks.formbrickssdk.model.user
import com.google.gson.annotations.SerializedName
data class UserResponse(
@SerializedName("data") val data: UserResponseData
)

View File

@@ -0,0 +1,7 @@
package com.formbricks.formbrickssdk.model.user
import com.google.gson.annotations.SerializedName
data class UserResponseData(
@SerializedName("state") val state: UserState
)

View File

@@ -0,0 +1,8 @@
package com.formbricks.formbrickssdk.model.user
import com.google.gson.annotations.SerializedName
data class UserState(
@SerializedName("data") val data: UserStateData,
@SerializedName("expiresAt") val expiresAt: String?
)

View File

@@ -0,0 +1,13 @@
package com.formbricks.formbrickssdk.model.user
import com.google.gson.annotations.SerializedName
data class UserStateData(
@SerializedName("userId") val userId: String?,
@SerializedName("segments") val segments: List<String>?,
@SerializedName("displays") val displays: List<Display>?,
@SerializedName("responses") val responses: List<String>?,
@SerializedName("lastDisplayAt") val lastDisplayAt: String?
)

View File

@@ -0,0 +1,65 @@
package com.formbricks.formbrickssdk.network
import com.formbricks.formbrickssdk.api.error.FormbricksAPIError
import com.formbricks.formbrickssdk.helper.mapToJsonElement
import com.formbricks.formbrickssdk.model.environment.EnvironmentDataHolder
import com.formbricks.formbrickssdk.model.environment.EnvironmentResponse
import com.formbricks.formbrickssdk.model.user.PostUserBody
import com.formbricks.formbrickssdk.model.user.UserResponse
import com.google.gson.Gson
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
import retrofit2.Call
import retrofit2.Retrofit
class FormbricksApiService {
private lateinit var retrofit: Retrofit
fun initialize(appUrl: String, isLoggingEnabled: Boolean) {
retrofit = FormbricksRetrofitBuilder(appUrl, isLoggingEnabled)
.getBuilder()
.build()
}
fun getEnvironmentStateObject(environmentId: String): Result<EnvironmentDataHolder> {
val result = execute {
retrofit.create(FormbricksService::class.java)
.getEnvironmentState(environmentId)
}
val resultMap = result.getOrThrow()
val resultJson = mapToJsonElement(resultMap).jsonObject
val environmentResponse = Json.decodeFromJsonElement<EnvironmentResponse>(resultJson)
val data = EnvironmentDataHolder(environmentResponse.data, resultMap)
return Result.success(data)
}
fun postUser(environmentId: String, body: PostUserBody): Result<UserResponse> {
return execute {
retrofit.create(FormbricksService::class.java)
.postUser(environmentId, body)
}
}
private inline fun <T> execute(apiCall: () -> Call<T>): Result<T> {
val call = apiCall().execute()
return if (call.isSuccessful) {
val body = call.body()
if (body == null) {
Result.failure(RuntimeException("Invalid response"))
} else {
Result.success(body)
}
} else {
return try {
val errorResponse =
Gson().fromJson(call.errorBody()?.string(), FormbricksAPIError::class.java)
Result.failure(errorResponse)
} catch (e: Exception) {
Result.failure(e)
}
}
}
}

View File

@@ -0,0 +1,41 @@
//package com.formbricks.formbrickssdk.network
//
//import com.formbricks.formbrickssdk.api.error.FormbricksAPIError
//import com.formbricks.formbrickssdk.model.upload.FileUploadBody
//import com.google.gson.Gson
//import retrofit2.Call
//import retrofit2.Retrofit
//
//class FormbricksFileUploadService(appUrl: String, isLoggingEnabled: Boolean) {
// private var retrofit: Retrofit = FormbricksRetrofitBuilder(appUrl, isLoggingEnabled)
// .getBuilder()
// .build()
//
//
// fun uploadFile(path: String, body: FileUploadBody): Result<Map<String, *>> {
// return execute {
// retrofit.create(FormbricksService::class.java)
// .uploadFile(path, body)
// }
// }
//
// private inline fun <T> execute(apiCall: () -> Call<T>): Result<T> {
// val call = apiCall().execute()
// return if (call.isSuccessful) {
// val body = call.body()
// if (body == null) {
// Result.failure(RuntimeException("Invalid response"))
// } else {
// Result.success(body)
// }
// } else {
// return try {
// val errorResponse =
// Gson().fromJson(call.errorBody()?.string(), FormbricksAPIError::class.java)
// Result.failure(errorResponse)
// } catch (e: Exception) {
// Result.failure(e)
// }
// }
// }
//}

View File

@@ -0,0 +1,32 @@
package com.formbricks.formbrickssdk.network
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
class FormbricksRetrofitBuilder(private val baseUrl: String, private val loggingEnabled: Boolean) {
fun getBuilder(): Retrofit.Builder {
val clientBuilder = OkHttpClient.Builder()
.connectTimeout(CONNECT_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS)
.readTimeout(READ_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS)
.followSslRedirects(true)
if (loggingEnabled) {
val logging = HttpLoggingInterceptor()
logging.setLevel(HttpLoggingInterceptor.Level.BODY)
clientBuilder.addInterceptor(logging)
}
return Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.client(clientBuilder.build())
}
companion object {
private const val CONNECT_TIMEOUT_MS = 30 * 1000 // 30 seconds
private const val READ_TIMEOUT_MS = 30 * 1000 // 30 seconds
}
}

View File

@@ -0,0 +1,31 @@
package com.formbricks.formbrickssdk.network
import com.formbricks.formbrickssdk.model.upload.FetchStorageUrlRequestBody
import com.formbricks.formbrickssdk.model.upload.FetchStorageUrlResponse
import com.formbricks.formbrickssdk.model.user.PostUserBody
import com.formbricks.formbrickssdk.model.user.UserResponse
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
interface FormbricksService {
@GET("$API_PREFIX/client/{environmentId}/environment")
fun getEnvironmentState(@Path("environmentId") environmentId: String): Call<Map<String, Any>>
@POST("$API_PREFIX/client/{environmentId}/user")
fun postUser(@Path("environmentId") environmentId: String, @Body body: PostUserBody): Call<UserResponse>
@POST("$API_PREFIX/client/{environmentId}/storage")
fun fetchStorageUrl(@Path("environmentId") environmentId: String, @Body body: FetchStorageUrlRequestBody): Call<FetchStorageUrlResponse>
// @POST("{path}")
// fun uploadFile(@Path("path") path: String, @Body fileUploadBody: FileUploadBody): Call<Map<String, Any>>
companion object {
const val API_PREFIX = "/api/v1"
}
}

View File

@@ -0,0 +1,72 @@
package com.formbricks.formbrickssdk.network.queue
import com.formbricks.formbrickssdk.manager.UserManager
import timber.log.Timber
import java.util.*
import kotlin.concurrent.timer
/**
* Update queue. This class is used to queue updates to the user.
* The given properties will be sent to the backend and updated in
* the user object when the debounce interval is reached.
*/
class UpdateQueue private constructor() {
private var userId: String? = null
private var attributes: MutableMap<String, String>? = null
private var language: String? = null
private var timer: Timer? = null
fun setUserId(userId: String) {
this.userId = userId
startDebounceTimer()
}
fun setAttributes(attributes: Map<String, String>) {
this.attributes = attributes.toMutableMap()
startDebounceTimer()
}
fun addAttribute(key: String, attribute: String) {
if (attributes == null) {
attributes = mutableMapOf()
}
attributes?.put(key, attribute)
startDebounceTimer()
}
fun setLanguage(language: String) {
addAttribute("language", language)
startDebounceTimer()
}
fun reset() {
userId = null
attributes = null
language = null
}
private fun startDebounceTimer() {
timer?.cancel()
timer = timer("debounceTimer", false, DEBOUNCE_INTERVAL, DEBOUNCE_INTERVAL) {
commit()
timer?.cancel()
}
}
private fun commit() {
val currentUserId = userId
if (currentUserId == null) {
Timber.d("Error: User ID is not set yet")
return
}
Timber.d("UpdateQueue - commit() called on UpdateQueue with $currentUserId and $attributes")
UserManager.syncUser(currentUserId, attributes)
}
companion object {
private const val DEBOUNCE_INTERVAL: Long = 500 // 500 ms
val current: UpdateQueue = UpdateQueue()
}
}

View File

@@ -0,0 +1,232 @@
package com.formbricks.formbrickssdk.webview
import android.annotation.SuppressLint
import android.app.Activity.RESULT_OK
import android.app.Dialog
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowManager
import android.webkit.ConsoleMessage
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import com.formbricks.formbrickssdk.Formbricks
import com.formbricks.formbrickssdk.R
import com.formbricks.formbrickssdk.databinding.FragmentFormbricksBinding
import com.formbricks.formbrickssdk.manager.SurveyManager
import com.formbricks.formbrickssdk.model.javascript.FileUploadData
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.gson.JsonObject
import kotlinx.serialization.json.JsonArray
import timber.log.Timber
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.time.Instant
import java.util.Date
import java.util.Timer
import java.util.TimerTask
class FormbricksFragment : BottomSheetDialogFragment() {
private lateinit var binding: FragmentFormbricksBinding
private lateinit var surveyId: String
private val closeTimer = Timer()
private val viewModel: FormbricksViewModel by viewModels()
private var webAppInterface = WebAppInterface(object : WebAppInterface.WebAppCallback {
override fun onClose() {
dismiss()
}
override fun onFinished() {
closeTimer.schedule(object: TimerTask() {
override fun run() {
dismiss()
}
}, Date.from(Instant.now().plusSeconds(CLOSING_TIMEOUT_IN_SECONDS)))
}
override fun onDisplayCreated() {
SurveyManager.onNewDisplay(surveyId)
}
override fun onResponseCreated() {
SurveyManager.postResponse(surveyId)
}
override fun onFilePick(data: FileUploadData) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.setType("*/*")
.putExtra(Intent.EXTRA_MIME_TYPES, data.fileUploadParams.allowedExtensionsArray())
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, data.fileUploadParams.allowMultipleFiles)
resultLauncher.launch(intent)
}
})
var resultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
val intent: Intent? = result.data
var uriArray: MutableList<Uri> = mutableListOf()
val dataString = intent?.dataString
if (null != dataString) {
uriArray = arrayOf(Uri.parse(dataString)).toMutableList()
} else {
val clipData = intent?.clipData
if (null != clipData) {
for (i in 0 until clipData.itemCount) {
val uri = clipData.getItemAt(i).uri
uriArray.add(uri)
}
}
}
val jsonArray = com.google.gson.JsonArray()
uriArray.forEach { uri ->
val type = activity?.contentResolver?.getType(uri)
val fileName = getFileName(uri)
val base64 = "data:${type};base64,${uriToBase64(uri)}"
val json = JsonObject()
json.addProperty("name", fileName)
json.addProperty("type", type)
json.addProperty("base64", base64)
jsonArray.add(json)
}
binding.formbricksWebview.evaluateJavascript("""window.formbricksSurveys.onFilePick($jsonArray)""") { result ->
print(result)
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentFormbricksBinding.inflate(inflater).apply {
lifecycleOwner = viewLifecycleOwner
}
binding.viewModel = viewModel
return binding.root
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
setStyle(STYLE_NO_FRAME, R.style.BottomSheetDialog)
return super.onCreateDialog(savedInstanceState)
}
@Suppress("DEPRECATION")
override fun onStart() {
super.onStart()
val view: FrameLayout = dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)!!
view.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
val behavior = BottomSheetBehavior.from(view)
behavior.peekHeight = resources.displayMetrics.heightPixels
behavior.state = BottomSheetBehavior.STATE_EXPANDED
behavior.isFitToContents = false
behavior.setState(BottomSheetBehavior.STATE_EXPANDED)
dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
}
@Suppress("DEPRECATION")
@SuppressLint("SetJavaScriptEnabled")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.formbricksWebview.setBackgroundColor(Color.TRANSPARENT)
binding.formbricksWebview.let {
if (Formbricks.loggingEnabled) {
WebView.setWebContentsDebuggingEnabled(true)
}
it.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
consoleMessage?.let { cm ->
val log = "[CONSOLE:${cm.messageLevel()}] \"${cm.message()}\", source: ${cm.sourceId()} (${cm.lineNumber()})"
Timber.tag("Javascript message").d(log)
}
return super.onConsoleMessage(consoleMessage)
}
}
it.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
loadWithOverviewMode = true
useWideViewPort = true
}
it.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
}
}
it.setInitialScale(1)
it.addJavascriptInterface(webAppInterface, WebAppInterface.INTERFACE_NAME)
}
viewModel.loadHtml(surveyId)
}
private fun getFileName(uri: Uri): String? {
var fileName: String? = null
activity?.contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1 && cursor.moveToFirst()) {
fileName = cursor.getString(nameIndex)
}
}
return fileName
}
private fun uriToBase64(uri: Uri): String? {
return try {
val inputStream: InputStream? = activity?.contentResolver?.openInputStream(uri)
val outputStream = ByteArrayOutputStream()
val buffer = ByteArray(1024)
var bytesRead: Int
while (inputStream?.read(buffer).also { bytesRead = it ?: -1 } != -1) {
outputStream.write(buffer, 0, bytesRead)
}
inputStream?.close()
outputStream.close()
Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
companion object {
private val TAG: String by lazy { FormbricksFragment::class.java.simpleName }
fun show(childFragmentManager: FragmentManager, surveyId: String) {
val fragment = FormbricksFragment()
fragment.surveyId = surveyId
fragment.show(childFragmentManager, TAG)
}
private const val CLOSING_TIMEOUT_IN_SECONDS = 5L
}
}

View File

@@ -0,0 +1,153 @@
package com.formbricks.formbrickssdk.webview
import android.webkit.WebView
import androidx.databinding.BindingAdapter
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.formbricks.formbrickssdk.Formbricks
import com.formbricks.formbrickssdk.extensions.guard
import com.formbricks.formbrickssdk.manager.SurveyManager
import com.formbricks.formbrickssdk.model.environment.EnvironmentDataHolder
import com.formbricks.formbrickssdk.model.environment.getProjectStylingJson
import com.formbricks.formbrickssdk.model.environment.getStyling
import com.formbricks.formbrickssdk.model.environment.getSurveyJson
import com.google.gson.JsonObject
/**
* A view model for the Formbricks WebView.
* It generates the HTML string with the necessary data to render the survey.
*/
class FormbricksViewModel : ViewModel() {
var html = MutableLiveData<String>()
/**
* The HTML template to render the Formbricks WebView.
*/
private val htmlTemplate = """
<!doctype html>
<html>
<meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0">
<head>
<title>Formbricks WebView Survey</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body style="overflow: hidden; height: 100vh; display: flex; flex-direction: column; justify-content: flex-end;">
<div id="formbricks-react-native" style="width: 100%;"></div>
</body>
<script type="text/javascript">
var json = `{{WEBVIEW_DATA}}`
function onClose() {
console.log("onClose")
FormbricksJavascript.message(JSON.stringify({ event: "onClose" }));
};
function onFinished() {
FormbricksJavascript.message(JSON.stringify({ event: "onFinished" }));
};
function onDisplayCreated() {
FormbricksJavascript.message(JSON.stringify({ event: "onDisplayCreated" }));
};
function onResponseCreated() {
FormbricksJavascript.message(JSON.stringify({ event: "onResponseCreated" }));
};
function loadSurvey() {
const options = JSON.parse(json);
const surveyProps = {
...options,
onFinished,
onDisplayCreated,
onResponseCreated,
onClose,
};
window.formbricksSurveys.renderSurvey(surveyProps);
}
// Function to attach click listener to file inputs
function attachFilePickerOverride() {
const inputs = document.querySelectorAll('input[type="file"]');
inputs.forEach(input => {
if (!input.getAttribute('data-file-picker-overridden')) {
input.setAttribute('data-file-picker-overridden', 'true');
const allowedFileExtensions = input.getAttribute('data-accept-extensions');
const allowMultipleFiles = input.getAttribute('data-accept-multiple');
input.addEventListener('click', function (e) {
e.preventDefault();
FormbricksJavascript.message(JSON.stringify({
event: "onFilePick",
fileUploadParams: {
allowedFileExtensions: allowedFileExtensions,
allowMultipleFiles: allowMultipleFiles === "true",
},
}));
});
}
});
}
// Initially attach the override
attachFilePickerOverride();
// Set up a MutationObserver to catch dynamically added file inputs
const observer = new MutationObserver(function (mutations) {
attachFilePickerOverride();
});
observer.observe(document.body, { childList: true, subtree: true });
const script = document.createElement("script");
script.src = "${Formbricks.appUrl}/js/surveys.umd.cjs";
script.async = true;
script.onload = () => loadSurvey();
script.onerror = (error) => {
console.error("Failed to load Formbricks Surveys library:", error);
};
document.head.appendChild(script);
</script>
</html>
"""
fun loadHtml(surveyId: String) {
val environment = SurveyManager.environmentDataHolder.guard { return }
val json = getJson(environment, surveyId)
val htmlString = htmlTemplate.replace("{{WEBVIEW_DATA}}", json)
html.postValue(htmlString)
}
private fun getJson(environmentDataHolder: EnvironmentDataHolder, surveyId: String): String {
val jsonObject = JsonObject()
environmentDataHolder.getSurveyJson(surveyId).let { jsonObject.add("survey", it) }
jsonObject.addProperty("isBrandingEnabled", true)
jsonObject.addProperty("apiHost", Formbricks.appUrl)
jsonObject.addProperty("languageCode", Formbricks.language)
jsonObject.addProperty("environmentId", Formbricks.environmentId)
val hasCustomStyling = environmentDataHolder.data?.data?.surveys?.first { it.id == surveyId }?.styling != null
val enabled = environmentDataHolder.data?.data?.project?.styling?.allowStyleOverwrite ?: false
if (hasCustomStyling && enabled) {
environmentDataHolder.getStyling(surveyId)?.let { jsonObject.add("styling", it) }
} else {
environmentDataHolder.getProjectStylingJson()?.let { jsonObject.add("styling", it) }
}
return jsonObject.toString()
.replace("#", "%23") // Hex color code's # breaks the JSON
.replace("\\\"","'") // " is replaced to ' in the html codes in the JSON
}
}
@BindingAdapter("htmlText")
fun WebView.setHtmlText(htmlString: String?) {
loadData(htmlString ?: "", "text/html", "UTF-8")
}

View File

@@ -0,0 +1,50 @@
package com.formbricks.formbrickssdk.webview
import android.webkit.JavascriptInterface
import com.formbricks.formbrickssdk.model.javascript.JsMessageData
import com.formbricks.formbrickssdk.model.javascript.EventType
import com.formbricks.formbrickssdk.model.javascript.FileUploadData
import com.google.gson.JsonParseException
import timber.log.Timber
class WebAppInterface(private val callback: WebAppCallback?) {
interface WebAppCallback {
fun onClose()
fun onFinished()
fun onDisplayCreated()
fun onResponseCreated()
fun onFilePick(data: FileUploadData)
}
/**
* Javascript interface to get messages from the WebView's embedded JS
*/
@JavascriptInterface
fun message(data: String) {
Timber.tag("WebAppInterface message").d(data)
try {
val jsMessage = JsMessageData.from(data)
when (jsMessage.event) {
EventType.ON_CLOSE -> callback?.onClose()
EventType.ON_FINISHED -> callback?.onFinished()
EventType.ON_DISPLAY_CREATED -> callback?.onDisplayCreated()
EventType.ON_RESPONSE_CREATED -> callback?.onResponseCreated()
EventType.ON_FILE_PICK -> { callback?.onFilePick(FileUploadData.from(data)) }
}
} catch (e: Exception) {
Timber.tag("WebAppInterface error").e(e)
} catch (e: JsonParseException) {
Timber.tag("WebAppInterface error").e(e, "Failed to parse JSON message: $data")
} catch (e: IllegalArgumentException) {
Timber.tag("WebAppInterface error").e(e, "Invalid message format: $data")
} catch (e: Exception) {
Timber.tag("WebAppInterface error").e(e, "Unexpected error processing message: $data")
}
}
companion object {
const val INTERFACE_NAME = "FormbricksJavascript"
}
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:bind="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="com.formbricks.formbrickssdk.webview.FormbricksViewModel" />
</data>
<WebView
android:id="@+id/formbricks_webview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
bind:htmlText="@{viewModel.html}"/>
</layout>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="BottomSheetDialog" parent="ThemeOverlay.MaterialComponents.BottomSheetDialog">
<item name="bottomSheetStyle">@style/BottomSheetModal</item>
</style>
<style name="BottomSheetModal" parent="Widget.Design.BottomSheet.Modal">
<item name="android:background">@android:color/transparent</item>
</style>
</resources>

View File

@@ -0,0 +1,17 @@
package com.formbricks.formbrickssdk
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)
}
}

View File

@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

View File

@@ -0,0 +1,70 @@
[versions]
agp = "8.8.0"
kotlin = "2.0.0"
coreKtx = "1.10.1"
lifecycleRuntimeKtx = "2.6.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
activityCompose = "1.8.0"
composeBom = "2024.04.01"
appcompat = "1.6.1"
material = "1.10.0"
androidx-annotation = "1.8.0"
kotlinx-serialization-json = "1.8.0"
retrofit = "2.9.0"
okhttp3 = "4.11.0"
gson = "2.10.1"
legacySupportV4 = "1.0.0"
lifecycleLivedataKtx = "2.8.7"
lifecycleViewmodelKtx = "2.8.7"
fragmentKtx = "1.8.5"
databindingCommon = "8.8.0"
timber = "5.0.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
retrofit-converter-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "retrofit" }
okhttp3-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp3" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-legacy-support-v4 = { group = "androidx.legacy", name = "legacy-support-v4", version.ref = "legacySupportV4" }
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleLivedataKtx" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" }
androidx-databinding-common = { group = "androidx.databinding", name = "databinding-common", version.ref = "databindingCommon" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-library = { id = "com.android.library", version.ref = "agp" }

Binary file not shown.

View File

@@ -0,0 +1,6 @@
#Mon Feb 10 09:17:42 CET 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
packages/android/gradlew vendored Executable file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
packages/android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Formbricks"
include(":app")
include(":formbricksSDK")

View File

@@ -0,0 +1,358 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
4DDAED9F2D50D70F00A19B1F /* FormbricksSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4DDAED9E2D50D70F00A19B1F /* FormbricksSDK.framework */; };
4DDAEDA02D50D70F00A19B1F /* FormbricksSDK.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4DDAED9E2D50D70F00A19B1F /* FormbricksSDK.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
4DDAEDA12D50D70F00A19B1F /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
4DDAEDA02D50D70F00A19B1F /* FormbricksSDK.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
4DDAED822D50D4EC00A19B1F /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; };
4DDAED9E2D50D70F00A19B1F /* FormbricksSDK.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = FormbricksSDK.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
4DDAED842D50D4EC00A19B1F /* Demo */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Demo;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
4DDAED7F2D50D4EC00A19B1F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4DDAED9F2D50D70F00A19B1F /* FormbricksSDK.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
4DDAED792D50D4EC00A19B1F = {
isa = PBXGroup;
children = (
4DDAED842D50D4EC00A19B1F /* Demo */,
4DDAED9D2D50D70F00A19B1F /* Frameworks */,
4DDAED832D50D4EC00A19B1F /* Products */,
);
sourceTree = "<group>";
};
4DDAED832D50D4EC00A19B1F /* Products */ = {
isa = PBXGroup;
children = (
4DDAED822D50D4EC00A19B1F /* Demo.app */,
);
name = Products;
sourceTree = "<group>";
};
4DDAED9D2D50D70F00A19B1F /* Frameworks */ = {
isa = PBXGroup;
children = (
4DDAED9E2D50D70F00A19B1F /* FormbricksSDK.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
4DDAED812D50D4EC00A19B1F /* Demo */ = {
isa = PBXNativeTarget;
buildConfigurationList = 4DDAED902D50D4ED00A19B1F /* Build configuration list for PBXNativeTarget "Demo" */;
buildPhases = (
4DDAED7E2D50D4EC00A19B1F /* Sources */,
4DDAED7F2D50D4EC00A19B1F /* Frameworks */,
4DDAED802D50D4EC00A19B1F /* Resources */,
4DDAEDA12D50D70F00A19B1F /* Embed Frameworks */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
4DDAED842D50D4EC00A19B1F /* Demo */,
);
name = Demo;
packageProductDependencies = (
);
productName = Demo;
productReference = 4DDAED822D50D4EC00A19B1F /* Demo.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
4DDAED7A2D50D4EC00A19B1F /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 1620;
TargetAttributes = {
4DDAED812D50D4EC00A19B1F = {
CreatedOnToolsVersion = 16.2;
};
};
};
buildConfigurationList = 4DDAED7D2D50D4EC00A19B1F /* Build configuration list for PBXProject "Demo" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 4DDAED792D50D4EC00A19B1F;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 4DDAED832D50D4EC00A19B1F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
4DDAED812D50D4EC00A19B1F /* Demo */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
4DDAED802D50D4EC00A19B1F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
4DDAED7E2D50D4EC00A19B1F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
4DDAED8E2D50D4ED00A19B1F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
4DDAED8F2D50D4ED00A19B1F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
4DDAED912D50D4ED00A19B1F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Demo/Preview Content\"";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.formbricks.Demo;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
4DDAED922D50D4ED00A19B1F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Demo/Preview Content\"";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.formbricks.Demo;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
4DDAED7D2D50D4EC00A19B1F /* Build configuration list for PBXProject "Demo" */ = {
isa = XCConfigurationList;
buildConfigurations = (
4DDAED8E2D50D4ED00A19B1F /* Debug */,
4DDAED8F2D50D4ED00A19B1F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
4DDAED902D50D4ED00A19B1F /* Build configuration list for PBXNativeTarget "Demo" */ = {
isa = XCConfigurationList;
buildConfigurations = (
4DDAED912D50D4ED00A19B1F /* Debug */,
4DDAED922D50D4ED00A19B1F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 4DDAED7A2D50D4EC00A19B1F /* Project object */;
}

Some files were not shown because too many files have changed in this diff Show More