diff --git a/packages/android/.gitignore b/packages/android/.gitignore new file mode 100644 index 0000000000..aa724b7707 --- /dev/null +++ b/packages/android/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/packages/android/app/.gitignore b/packages/android/app/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/packages/android/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/packages/android/app/build.gradle.kts b/packages/android/app/build.gradle.kts new file mode 100644 index 0000000000..d2bf794cfa --- /dev/null +++ b/packages/android/app/build.gradle.kts @@ -0,0 +1,62 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + kotlin("kapt") +} + +android { + namespace = "com.formbricks.demo" + compileSdk = 35 + + defaultConfig { + applicationId = "com.formbricks.demo" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + dataBinding = true + } +} + +dependencies { + implementation(project(":formbricksSDK")) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.fragment.ktx) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/packages/android/app/proguard-rules.pro b/packages/android/app/proguard-rules.pro new file mode 100644 index 0000000000..f1a2ed7494 --- /dev/null +++ b/packages/android/app/proguard-rules.pro @@ -0,0 +1,26 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-dontwarn androidx.databinding.** +-keep class androidx.databinding.** { *; } +-keep class * extends androidx.databinding.DataBinderMapper { *; } +-keep class com.formbricks.formbrickssdk.DataBinderMapperImpl { *; } \ No newline at end of file diff --git a/packages/android/app/src/androidTest/java/com/formbricks/demo/ExampleInstrumentedTest.kt b/packages/android/app/src/androidTest/java/com/formbricks/demo/ExampleInstrumentedTest.kt new file mode 100644 index 0000000000..0391316e3e --- /dev/null +++ b/packages/android/app/src/androidTest/java/com/formbricks/demo/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.formbricks.demo + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.formbricks.demo", appContext.packageName) + } +} \ No newline at end of file diff --git a/packages/android/app/src/main/AndroidManifest.xml b/packages/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..d0460a8f9c --- /dev/null +++ b/packages/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/android/app/src/main/java/com/formbricks/demo/MainActivity.kt b/packages/android/app/src/main/java/com/formbricks/demo/MainActivity.kt new file mode 100644 index 0000000000..baacbbbfd1 --- /dev/null +++ b/packages/android/app/src/main/java/com/formbricks/demo/MainActivity.kt @@ -0,0 +1,71 @@ +package com.formbricks.demo + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import com.formbricks.demo.ui.theme.DemoTheme +import com.formbricks.formbrickssdk.Formbricks +import com.formbricks.formbrickssdk.helper.FormbricksConfig +import java.util.UUID + +class MainActivity : FragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val config = FormbricksConfig.Builder("[API_HOST]","[ENVIRONMENT_ID]") + .setLoggingEnabled(true) + .setFragmentManager(supportFragmentManager) + Formbricks.setup(this, config.build()) + + Formbricks.logout() + Formbricks.setUserId(UUID.randomUUID().toString()) + + enableEdgeToEdge() + setContent { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + DemoTheme { + FormbricksDemo() + } + } + } + } +} + +@Composable +fun FormbricksDemo() { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button(onClick = { + Formbricks.track("click_demo_button") + }) { + Text( + text = "Click me!", + modifier = Modifier.padding(16.dp) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun GreetingPreview() { + DemoTheme { + FormbricksDemo() + } +} \ No newline at end of file diff --git a/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Color.kt b/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Color.kt new file mode 100644 index 0000000000..f8d7441db4 --- /dev/null +++ b/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.formbricks.demo.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Theme.kt b/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Theme.kt new file mode 100644 index 0000000000..4b5618a286 --- /dev/null +++ b/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Theme.kt @@ -0,0 +1,51 @@ +package com.formbricks.demo.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun DemoTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, typography = Typography, content = content + ) +} \ No newline at end of file diff --git a/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Type.kt b/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Type.kt new file mode 100644 index 0000000000..a4720f9f76 --- /dev/null +++ b/packages/android/app/src/main/java/com/formbricks/demo/ui/theme/Type.kt @@ -0,0 +1,33 @@ +package com.formbricks.demo.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + )/* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/packages/android/app/src/main/res/drawable/ic_launcher_background.xml b/packages/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..07d5da9cbf --- /dev/null +++ b/packages/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/packages/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..2b068d1146 --- /dev/null +++ b/packages/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/packages/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000000..6f3b755bf5 --- /dev/null +++ b/packages/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/packages/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000000..6f3b755bf5 --- /dev/null +++ b/packages/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/packages/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000..c209e78ecd Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/packages/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/packages/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..b2dfe3d1ba Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/packages/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/packages/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000..4f0f1d64e5 Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/packages/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/packages/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..62b611da08 Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/packages/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/packages/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000..948a3070fe Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/packages/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/packages/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..1b9a6956b3 Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/packages/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/packages/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..28d4b77f9f Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/packages/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/packages/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9287f50836 Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/packages/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/packages/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..aa7d6427e6 Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/packages/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/packages/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9126ae37cb Binary files /dev/null and b/packages/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/packages/android/app/src/main/res/values/colors.xml b/packages/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..f8c6127d32 --- /dev/null +++ b/packages/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/packages/android/app/src/main/res/values/strings.xml b/packages/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..aee84b4bfe --- /dev/null +++ b/packages/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Demo + \ No newline at end of file diff --git a/packages/android/app/src/main/res/values/themes.xml b/packages/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..f8dcc514d4 --- /dev/null +++ b/packages/android/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + + + + + \ No newline at end of file diff --git a/packages/android/formbricksSDK/src/test/java/com/formbricks/formbrickssdk/ExampleUnitTest.kt b/packages/android/formbricksSDK/src/test/java/com/formbricks/formbrickssdk/ExampleUnitTest.kt new file mode 100644 index 0000000000..3e424e02c0 --- /dev/null +++ b/packages/android/formbricksSDK/src/test/java/com/formbricks/formbrickssdk/ExampleUnitTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/packages/android/gradle.properties b/packages/android/gradle.properties new file mode 100644 index 0000000000..20e2a01520 --- /dev/null +++ b/packages/android/gradle.properties @@ -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 \ No newline at end of file diff --git a/packages/android/gradle/libs.versions.toml b/packages/android/gradle/libs.versions.toml new file mode 100644 index 0000000000..ef49a3c8d3 --- /dev/null +++ b/packages/android/gradle/libs.versions.toml @@ -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" } + diff --git a/packages/android/gradle/wrapper/gradle-wrapper.jar b/packages/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..e708b1c023 Binary files /dev/null and b/packages/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/android/gradle/wrapper/gradle-wrapper.properties b/packages/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..0ed921fb89 --- /dev/null +++ b/packages/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/packages/android/gradlew b/packages/android/gradlew new file mode 100755 index 0000000000..4f906e0c81 --- /dev/null +++ b/packages/android/gradlew @@ -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" "$@" diff --git a/packages/android/gradlew.bat b/packages/android/gradlew.bat new file mode 100644 index 0000000000..ac1b06f938 --- /dev/null +++ b/packages/android/gradlew.bat @@ -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 diff --git a/packages/android/settings.gradle.kts b/packages/android/settings.gradle.kts new file mode 100644 index 0000000000..b151ccb162 --- /dev/null +++ b/packages/android/settings.gradle.kts @@ -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") diff --git a/packages/ios/Demo/Demo.xcodeproj/project.pbxproj b/packages/ios/Demo/Demo.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..15107a0f16 --- /dev/null +++ b/packages/ios/Demo/Demo.xcodeproj/project.pbxproj @@ -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 = ""; + }; +/* 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 = ""; + }; + 4DDAED832D50D4EC00A19B1F /* Products */ = { + isa = PBXGroup; + children = ( + 4DDAED822D50D4EC00A19B1F /* Demo.app */, + ); + name = Products; + sourceTree = ""; + }; + 4DDAED9D2D50D70F00A19B1F /* Frameworks */ = { + isa = PBXGroup; + children = ( + 4DDAED9E2D50D70F00A19B1F /* FormbricksSDK.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/packages/ios/Demo/Demo.xcodeproj/xcuserdata/vaxi.xcuserdatad/xcschemes/xcschememanagement.plist b/packages/ios/Demo/Demo.xcodeproj/xcuserdata/vaxi.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000000..62a1e83c56 --- /dev/null +++ b/packages/ios/Demo/Demo.xcodeproj/xcuserdata/vaxi.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Demo.xcscheme_^#shared#^_ + + orderHint + 1 + + + + diff --git a/packages/ios/Demo/Demo/AppDelegate.swift b/packages/ios/Demo/Demo/AppDelegate.swift new file mode 100644 index 0000000000..629b6c1dea --- /dev/null +++ b/packages/ios/Demo/Demo/AppDelegate.swift @@ -0,0 +1,19 @@ +import UIKit +import FormbricksSDK + +class AppDelegate: NSObject, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + let config = FormbricksConfig.Builder(appUrl: "http://localhost:3000", environmentId: "cm6ovvfoc000asf0k39wbzc8s") + .setLogLevel(.debug) + .build() + + Formbricks.setup(with: config) + + Formbricks.logout() + Formbricks.setUserId(UUID().uuidString) + + return true + } + +} diff --git a/packages/ios/Demo/Demo/Assets.xcassets/AccentColor.colorset/Contents.json b/packages/ios/Demo/Demo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..0afb3cf0ee --- /dev/null +++ b/packages/ios/Demo/Demo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors": [ + { + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/packages/ios/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/ios/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..c70a5bff18 --- /dev/null +++ b/packages/ios/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images": [ + { + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "tinted" + } + ], + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/packages/ios/Demo/Demo/Assets.xcassets/Contents.json b/packages/ios/Demo/Demo/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..74d6a722cf --- /dev/null +++ b/packages/ios/Demo/Demo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/packages/ios/Demo/Demo/ContentView.swift b/packages/ios/Demo/Demo/ContentView.swift new file mode 100644 index 0000000000..c99bc69ea5 --- /dev/null +++ b/packages/ios/Demo/Demo/ContentView.swift @@ -0,0 +1,24 @@ +import SwiftUI +import FormbricksSDK + +struct ContentView: View { + var body: some View { + VStack { + Spacer() + HStack { + Spacer() + Button("Click me!") { + Formbricks.track("click_demo_button") + } + Spacer() + } + .padding() + Spacer() + } + } + +} + +#Preview { + ContentView() +} diff --git a/packages/ios/Demo/Demo/DemoApp.swift b/packages/ios/Demo/Demo/DemoApp.swift new file mode 100644 index 0000000000..17c21302e9 --- /dev/null +++ b/packages/ios/Demo/Demo/DemoApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct DemoApp: App { + + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate: AppDelegate + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/packages/ios/Demo/Demo/Preview Content/Preview Assets.xcassets/Contents.json b/packages/ios/Demo/Demo/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000000..74d6a722cf --- /dev/null +++ b/packages/ios/Demo/Demo/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/packages/ios/Formbricks.xcworkspace/contents.xcworkspacedata b/packages/ios/Formbricks.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..7e0de4f2ea --- /dev/null +++ b/packages/ios/Formbricks.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/ios/Formbricks.xcworkspace/xcuserdata/vaxi.xcuserdatad/UserInterfaceState.xcuserstate b/packages/ios/Formbricks.xcworkspace/xcuserdata/vaxi.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000..7c23ccd0bf Binary files /dev/null and b/packages/ios/Formbricks.xcworkspace/xcuserdata/vaxi.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/packages/ios/Formbricks.xcworkspace/xcuserdata/vaxi.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/packages/ios/Formbricks.xcworkspace/xcuserdata/vaxi.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000000..0f50dd8bec --- /dev/null +++ b/packages/ios/Formbricks.xcworkspace/xcuserdata/vaxi.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.pbxproj b/packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..2db26ac721 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.pbxproj @@ -0,0 +1,515 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 4DDAED692D50D49B00A19B1F /* FormbricksSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4DDAED602D50D49A00A19B1F /* FormbricksSDK.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 4DDAED6A2D50D49B00A19B1F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4DDAED572D50D49A00A19B1F /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4DDAED5F2D50D49A00A19B1F; + remoteInfo = FormbricksSDK; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 4DDAED602D50D49A00A19B1F /* FormbricksSDK.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = FormbricksSDK.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 4DDAED682D50D49B00A19B1F /* FormbricksSDKTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FormbricksSDKTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 4DDAED702D50D49B00A19B1F /* Exceptions for "FormbricksSDK" folder in "FormbricksSDK" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + publicHeaders = ( + FormbricksSDK.h, + ); + target = 4DDAED5F2D50D49A00A19B1F /* FormbricksSDK */; + }; + 4DDAED9C2D50D54A00A19B1F /* Exceptions for "FormbricksSDK" folder in "FormbricksSDKTests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Config.swift, + "Extension/UIApplication+Window.swift", + Formbricks.swift, + Model/Environment/ActionClass/ActionClass.swift, + Model/Environment/Common/LocalizedText.swift, + Model/Environment/EnvironmentData.swift, + Model/Environment/EnvironmentResponse.swift, + Model/Environment/EnvironmentResponseData.swift, + Model/Environment/Project/BrandColor.swift, + Model/Environment/Project/Project.swift, + Model/Environment/Project/Styling.swift, + Model/Environment/Survey.swift, + Model/Environment/Surveys/ActionClassReference.swift, + Model/Environment/Surveys/Segment.swift, + Model/Environment/Surveys/Trigger.swift, + Model/User/Display.swift, + Model/User/UserResponseData.swift, + Model/User/UserState.swift, + Model/User/UserStateDetails.swift, + ); + target = 4DDAED672D50D49B00A19B1F /* FormbricksSDKTests */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 4DDAED622D50D49A00A19B1F /* FormbricksSDK */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 4DDAED702D50D49B00A19B1F /* Exceptions for "FormbricksSDK" folder in "FormbricksSDK" target */, + 4DDAED9C2D50D54A00A19B1F /* Exceptions for "FormbricksSDK" folder in "FormbricksSDKTests" target */, + ); + path = FormbricksSDK; + sourceTree = ""; + }; + 4DDAED6C2D50D49B00A19B1F /* FormbricksSDKTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FormbricksSDKTests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 4DDAED5D2D50D49A00A19B1F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4DDAED652D50D49B00A19B1F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4DDAED692D50D49B00A19B1F /* FormbricksSDK.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4DDAED562D50D49A00A19B1F = { + isa = PBXGroup; + children = ( + 4DDAED622D50D49A00A19B1F /* FormbricksSDK */, + 4DDAED6C2D50D49B00A19B1F /* FormbricksSDKTests */, + 4DDAED612D50D49A00A19B1F /* Products */, + ); + sourceTree = ""; + }; + 4DDAED612D50D49A00A19B1F /* Products */ = { + isa = PBXGroup; + children = ( + 4DDAED602D50D49A00A19B1F /* FormbricksSDK.framework */, + 4DDAED682D50D49B00A19B1F /* FormbricksSDKTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 4DDAED5B2D50D49A00A19B1F /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 4DDAED5F2D50D49A00A19B1F /* FormbricksSDK */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4DDAED712D50D49B00A19B1F /* Build configuration list for PBXNativeTarget "FormbricksSDK" */; + buildPhases = ( + 4DDAED5B2D50D49A00A19B1F /* Headers */, + 4DDAED5C2D50D49A00A19B1F /* Sources */, + 4DDAED5D2D50D49A00A19B1F /* Frameworks */, + 4DDAED5E2D50D49A00A19B1F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 4DDAED622D50D49A00A19B1F /* FormbricksSDK */, + ); + name = FormbricksSDK; + packageProductDependencies = ( + ); + productName = FormbricksSDK; + productReference = 4DDAED602D50D49A00A19B1F /* FormbricksSDK.framework */; + productType = "com.apple.product-type.framework"; + }; + 4DDAED672D50D49B00A19B1F /* FormbricksSDKTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4DDAED762D50D49B00A19B1F /* Build configuration list for PBXNativeTarget "FormbricksSDKTests" */; + buildPhases = ( + 4DDAED642D50D49B00A19B1F /* Sources */, + 4DDAED652D50D49B00A19B1F /* Frameworks */, + 4DDAED662D50D49B00A19B1F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 4DDAED6B2D50D49B00A19B1F /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 4DDAED6C2D50D49B00A19B1F /* FormbricksSDKTests */, + ); + name = FormbricksSDKTests; + packageProductDependencies = ( + ); + productName = FormbricksSDKTests; + productReference = 4DDAED682D50D49B00A19B1F /* FormbricksSDKTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 4DDAED572D50D49A00A19B1F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1620; + LastUpgradeCheck = 1620; + TargetAttributes = { + 4DDAED5F2D50D49A00A19B1F = { + CreatedOnToolsVersion = 16.2; + LastSwiftMigration = 1620; + }; + 4DDAED672D50D49B00A19B1F = { + CreatedOnToolsVersion = 16.2; + }; + }; + }; + buildConfigurationList = 4DDAED5A2D50D49A00A19B1F /* Build configuration list for PBXProject "FormbricksSDK" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 4DDAED562D50D49A00A19B1F; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 4DDAED612D50D49A00A19B1F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 4DDAED5F2D50D49A00A19B1F /* FormbricksSDK */, + 4DDAED672D50D49B00A19B1F /* FormbricksSDKTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 4DDAED5E2D50D49A00A19B1F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4DDAED662D50D49B00A19B1F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 4DDAED5C2D50D49A00A19B1F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4DDAED642D50D49B00A19B1F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 4DDAED6B2D50D49B00A19B1F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4DDAED5F2D50D49A00A19B1F /* FormbricksSDK */; + targetProxy = 4DDAED6A2D50D49B00A19B1F /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 4DDAED722D50D49B00A19B1F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = com.formbricks.FormbricksSDK; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4DDAED732D50D49B00A19B1F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = com.formbricks.FormbricksSDK; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 4DDAED742D50D49B00A19B1F /* 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; + CURRENT_PROJECT_VERSION = 1; + 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"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 4DDAED752D50D49B00A19B1F /* 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; + CURRENT_PROJECT_VERSION = 1; + 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; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 4DDAED772D50D49B00A19B1F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.formbricks.FormbricksSDKTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4DDAED782D50D49B00A19B1F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.formbricks.FormbricksSDKTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 4DDAED5A2D50D49A00A19B1F /* Build configuration list for PBXProject "FormbricksSDK" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4DDAED742D50D49B00A19B1F /* Debug */, + 4DDAED752D50D49B00A19B1F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 4DDAED712D50D49B00A19B1F /* Build configuration list for PBXNativeTarget "FormbricksSDK" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4DDAED722D50D49B00A19B1F /* Debug */, + 4DDAED732D50D49B00A19B1F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 4DDAED762D50D49B00A19B1F /* Build configuration list for PBXNativeTarget "FormbricksSDKTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4DDAED772D50D49B00A19B1F /* Debug */, + 4DDAED782D50D49B00A19B1F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 4DDAED572D50D49A00A19B1F /* Project object */; +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/xcuserdata/vaxi.xcuserdatad/xcschemes/xcschememanagement.plist b/packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/xcuserdata/vaxi.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000000..b82808bd07 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/xcuserdata/vaxi.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + FormbricksSDK.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Config.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Config.swift new file mode 100644 index 0000000000..b9262e490f --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Config.swift @@ -0,0 +1,8 @@ +struct Config { + struct Environment { + /// On error, the environment will be refreshed after this amount of time (in minutes) + static let refreshStateOnErrorTimeoutInMinutes = 10 + /// The survey window will be closed after this amount of time (in seconds) when the `onFinished` javascript callback is called. + static let closingTimeoutInSeconds = 5 + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Extension/Calendar+DaysBetween.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Extension/Calendar+DaysBetween.swift new file mode 100644 index 0000000000..6cf2168bde --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Extension/Calendar+DaysBetween.swift @@ -0,0 +1,10 @@ +extension Calendar { + /// Returns the number of days between two dates. + func numberOfDaysBetween(_ from: Date, and to: Date) -> Int { + let fromDate = startOfDay(for: from) + let toDate = startOfDay(for: to) + let numberOfDays = dateComponents([.day], from: fromDate, to: toDate) + + return numberOfDays.day! + 1 + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Extension/Error+Message.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Extension/Error+Message.swift new file mode 100644 index 0000000000..26e2f3ac62 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Extension/Error+Message.swift @@ -0,0 +1,21 @@ +extension Error { + var message: String { + if let error = self as? RuntimeError { + return error.message + } + + if let error = self as? FormbricksAPIClientError { + return error.errorDescription + } + + if let error = self as? FormbricksAPIError { + return error.getDetailedErrorMessage() + } + + if let error = self as? FormbricksSDKError { + return error.errorDescription + } + + return localizedDescription + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Extension/JSON+Formatter.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Extension/JSON+Formatter.swift new file mode 100644 index 0000000000..f7bc2450d0 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Extension/JSON+Formatter.swift @@ -0,0 +1,28 @@ +import Foundation + +extension DateFormatter { + static var isoFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" + formatter.calendar = Calendar(identifier: .iso8601) + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + } +} + +extension JSONDecoder { + static let iso8601Full: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.isoFormatter) + return decoder + + }() +} + +extension JSONEncoder { + static let iso8601Full: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(DateFormatter.isoFormatter) + return encoder + }() +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Extension/UIApplication+Window.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Extension/UIApplication+Window.swift new file mode 100644 index 0000000000..24dac104ea --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Extension/UIApplication+Window.swift @@ -0,0 +1,14 @@ +import UIKit + +extension UIApplication { + static var safeShared: UIApplication? { + return UIApplication.value(forKeyPath: "sharedApplication") as? UIApplication + } + + static var safeKeyWindow: UIWindow? { + return safeShared?.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap({ $0.windows }) + .first + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Formbricks.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Formbricks.swift new file mode 100644 index 0000000000..9a159dab6b --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Formbricks.swift @@ -0,0 +1,191 @@ +import Foundation +import Network + +/// The main class of the Formbricks SDK. It contains the main methods to interact with the SDK. +@objc(Formbricks) public class Formbricks: NSObject { + + static internal var appUrl: String? + static internal var environmentId: String? + static internal var language: String = "default" + static internal var isInitialized: Bool = false + + static internal var apiQueue = OperationQueue() + static internal var logger = Logger() + static internal var service = FormbricksService() + + // make this class not instantiatable outside of the SDK + internal override init() {} + + /** + Initializes the Formbricks SDK with the given config ``FormbricksConfig``. + This method is mandatory to be called, and should be only once per application lifecycle. + + Example: + ```swift + let config = FormbricksConfig.Builder(appUrl: "APP_URL_HERE", environmentId: "TOKEN_HERE") + .setUserId("USER_ID_HERE") + .setLogLevel(.debug) + .build() + + Formbricks.setup(with: config) + ``` + */ + @objc public static func setup(with config: FormbricksConfig) { + guard !isInitialized else { + Formbricks.logger.error(FormbricksSDKError(type: .sdkIsAlreadyInitialized).message) + return + } + + self.appUrl = config.appUrl + self.environmentId = config.environmentId + self.logger.logLevel = config.logLevel + + if let userId = config.userId { + UserManager.shared.set(userId: userId) + } + if let attributes = config.attributes { + UserManager.shared.set(attributes: attributes) + } + if let language = config.attributes?["language"] { + UserManager.shared.set(language: language) + self.language = language + } + + SurveyManager.shared.refreshEnvironmentIfNeeded() + UserManager.shared.syncUserStateIfNeeded() + + + self.isInitialized = true + } + + /** + Sets the user id for the current user with the given `String`. + The SDK must be initialized before calling this method. + + Example: + ```swift + Formbricks.setUserId(with: "USER_ID_HERE") + ``` + */ + @objc public static func setUserId(_ userId: String) { + guard Formbricks.isInitialized else { + Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) + return + } + + UserManager.shared.set(userId: 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. + + Example: + ```swift + Formbricks.setAttribute("ATTRIBUTE", forKey: "KEY") + ``` + */ + @objc public static func setAttribute(_ attribute: String, forKey key: String) { + guard Formbricks.isInitialized else { + Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) + return + } + + UserManager.shared.add(attribute: attribute, forKey: key) + } + + /** + Sets the user attributes for the current user with the given `Dictionary` of `String` values and `String` keys. + The SDK must be initialized before calling this method. + + Example: + ```swift + Formbricks.setAttributes(["KEY", "ATTRIBUTE"]) + ``` + */ + @objc public static func setAttributes(_ attributes: [String : String]) { + guard Formbricks.isInitialized else { + Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) + return + } + + UserManager.shared.set(attributes: attributes) + } + + /** + Sets the language for the current user with the given `String`. + The SDK must be initialized before calling this method. + + Example: + ```swift + Formbricks.setLanguage("de") + ``` + */ + @objc public static func setLanguage(_ language: String) { + guard Formbricks.isInitialized else { + Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) + return + } + + Formbricks.language = language + UserManager.shared.add(attribute: language, forKey: "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. + + Example: + ```swift + Formbricks.track("button_clicked") + ``` + */ + @objc public static func track(_ action: String) { + guard Formbricks.isInitialized else { + Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) + return + } + + Formbricks.isInternetAvailabile { available in + if available { + SurveyManager.shared.track(action) + } else { + Formbricks.logger.warning(FormbricksSDKError.init(type: .networkError).message) + } + } + + } + + /** + Logs out the current user. This will clear the user attributes and the user id. + The SDK must be initialized before calling this method. + + Example: + ```swift + Formbricks.logout() + ``` + */ + @objc public static func logout() { + guard Formbricks.isInitialized else { + Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) + return + } + + UserManager.shared.logout() + } +} + +// MARK: - Check the network connection - +private extension Formbricks { + static func isInternetAvailabile(completion: @escaping (Bool) -> Void) { + let monitor = NWPathMonitor() + let queue = DispatchQueue.global(qos: .background) + + monitor.pathUpdateHandler = { path in + completion(path.status == .satisfied) + monitor.cancel() + } + + monitor.start(queue: queue) + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/FormbricksSDK.h b/packages/ios/FormbricksSDK/FormbricksSDK/FormbricksSDK.h new file mode 100644 index 0000000000..4dd5b64240 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/FormbricksSDK.h @@ -0,0 +1,11 @@ +#import + +//! Project version number for FormbricksSDK. +FOUNDATION_EXPORT double FormbricksSDKVersionNumber; + +//! Project version string for FormbricksSDK. +FOUNDATION_EXPORT const unsigned char FormbricksSDKVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Helpers/AnyCodable/AnyCodable.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Helpers/AnyCodable/AnyCodable.swift new file mode 100644 index 0000000000..d8e0b7e4d5 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Helpers/AnyCodable/AnyCodable.swift @@ -0,0 +1,149 @@ +// https://github.com/Flight-School/AnyCodable/blob/master/Sources/AnyCodable/AnyCodable.swift + +import Foundation +/** + A type-erased `Codable` value. + + The `AnyCodable` type forwards encoding and decoding responsibilities + to an underlying value, hiding its specific underlying type. + + You can encode or decode mixed-type values in dictionaries + and other collections that require `Encodable` or `Decodable` conformance + by declaring their contained type to be `AnyCodable`. + + - SeeAlso: `AnyEncodable` + - SeeAlso: `AnyDecodable` + */ +struct AnyCodable: Codable { + public let value: Any + + public init(_ value: T?) { + self.value = value ?? () + } +} + +extension AnyCodable: _AnyEncodable, _AnyDecodable {} + +extension AnyCodable: Equatable { + public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { + switch (lhs.value, rhs.value) { + case is (Void, Void): + return true + case let (lhs as Bool, rhs as Bool): + return lhs == rhs + case let (lhs as Int, rhs as Int): + return lhs == rhs + case let (lhs as Int8, rhs as Int8): + return lhs == rhs + case let (lhs as Int16, rhs as Int16): + return lhs == rhs + case let (lhs as Int32, rhs as Int32): + return lhs == rhs + case let (lhs as Int64, rhs as Int64): + return lhs == rhs + case let (lhs as UInt, rhs as UInt): + return lhs == rhs + case let (lhs as UInt8, rhs as UInt8): + return lhs == rhs + case let (lhs as UInt16, rhs as UInt16): + return lhs == rhs + case let (lhs as UInt32, rhs as UInt32): + return lhs == rhs + case let (lhs as UInt64, rhs as UInt64): + return lhs == rhs + case let (lhs as Float, rhs as Float): + return lhs == rhs + case let (lhs as Double, rhs as Double): + return lhs == rhs + case let (lhs as String, rhs as String): + return lhs == rhs + case let (lhs as [String: AnyCodable], rhs as [String: AnyCodable]): + return lhs == rhs + case let (lhs as [AnyCodable], rhs as [AnyCodable]): + return lhs == rhs + case let (lhs as [String: Any], rhs as [String: Any]): + return NSDictionary(dictionary: lhs) == NSDictionary(dictionary: rhs) + case let (lhs as [Any], rhs as [Any]): + return NSArray(array: lhs) == NSArray(array: rhs) + case is (NSNull, NSNull): + return true + default: + return false + } + } +} + +extension AnyCodable: CustomStringConvertible { + public var description: String { + switch value { + case is Void: + return String(describing: nil as Any?) + case let value as CustomStringConvertible: + return value.description + default: + return String(describing: value) + } + } +} + +extension AnyCodable: CustomDebugStringConvertible { + public var debugDescription: String { + switch value { + case let value as CustomDebugStringConvertible: + return "AnyCodable(\(value.debugDescription))" + default: + return "AnyCodable(\(description))" + } + } +} + +extension AnyCodable: ExpressibleByNilLiteral {} +extension AnyCodable: ExpressibleByBooleanLiteral {} +extension AnyCodable: ExpressibleByIntegerLiteral {} +extension AnyCodable: ExpressibleByFloatLiteral {} +extension AnyCodable: ExpressibleByStringLiteral {} +extension AnyCodable: ExpressibleByStringInterpolation {} +extension AnyCodable: ExpressibleByArrayLiteral {} +extension AnyCodable: ExpressibleByDictionaryLiteral {} + + +extension AnyCodable: Hashable { + public func hash(into hasher: inout Hasher) { + switch value { + case let value as Bool: + hasher.combine(value) + case let value as Int: + hasher.combine(value) + case let value as Int8: + hasher.combine(value) + case let value as Int16: + hasher.combine(value) + case let value as Int32: + hasher.combine(value) + case let value as Int64: + hasher.combine(value) + case let value as UInt: + hasher.combine(value) + case let value as UInt8: + hasher.combine(value) + case let value as UInt16: + hasher.combine(value) + case let value as UInt32: + hasher.combine(value) + case let value as UInt64: + hasher.combine(value) + case let value as Float: + hasher.combine(value) + case let value as Double: + hasher.combine(value) + case let value as String: + hasher.combine(value) + case let value as [String: AnyCodable]: + hasher.combine(value) + case let value as [AnyCodable]: + hasher.combine(value) + default: + break + } + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Helpers/AnyCodable/AnyDecodable.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Helpers/AnyCodable/AnyDecodable.swift new file mode 100644 index 0000000000..198798dedb --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Helpers/AnyCodable/AnyDecodable.swift @@ -0,0 +1,190 @@ +// https://github.com/Flight-School/AnyCodable/blob/master/Sources/AnyCodable/AnyCodable.swift + +#if canImport(Foundation) +import Foundation +#endif + +/** + A type-erased `Decodable` value. + + The `AnyDecodable` type forwards decoding responsibilities + to an underlying value, hiding its specific underlying type. + + You can decode mixed-type values in dictionaries + and other collections that require `Decodable` conformance + by declaring their contained type to be `AnyDecodable`: + + let json = """ + { + "boolean": true, + "integer": 42, + "double": 3.141592653589793, + "string": "string", + "array": [1, 2, 3], + "nested": { + "a": "alpha", + "b": "bravo", + "c": "charlie" + }, + "null": null + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + let dictionary = try! decoder.decode([String: AnyDecodable].self, from: json) + */ +struct AnyDecodable: Decodable { + public let value: Any + + public init(_ value: T?) { + self.value = value ?? () + } +} + +@usableFromInline +protocol _AnyDecodable { + var value: Any { get } + init(_ value: T?) +} + +extension AnyDecodable: _AnyDecodable {} + +extension _AnyDecodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + #if canImport(Foundation) + self.init(NSNull()) + #else + self.init(Optional.none) + #endif + } else if let bool = try? container.decode(Bool.self) { + self.init(bool) + } else if let int = try? container.decode(Int.self) { + self.init(int) + } else if let uint = try? container.decode(UInt.self) { + self.init(uint) + } else if let double = try? container.decode(Double.self) { + self.init(double) + } else if let string = try? container.decode(String.self) { + self.init(string) + } else if let array = try? container.decode([AnyDecodable].self) { + self.init(array.map { $0.value }) + } else if let dictionary = try? container.decode([String: AnyDecodable].self) { + self.init(dictionary.mapValues { $0.value }) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyDecodable value cannot be decoded") + } + } +} + +extension AnyDecodable: Equatable { + public static func == (lhs: AnyDecodable, rhs: AnyDecodable) -> Bool { + switch (lhs.value, rhs.value) { +#if canImport(Foundation) + case is (NSNull, NSNull), is (Void, Void): + return true +#endif + case let (lhs as Bool, rhs as Bool): + return lhs == rhs + case let (lhs as Int, rhs as Int): + return lhs == rhs + case let (lhs as Int8, rhs as Int8): + return lhs == rhs + case let (lhs as Int16, rhs as Int16): + return lhs == rhs + case let (lhs as Int32, rhs as Int32): + return lhs == rhs + case let (lhs as Int64, rhs as Int64): + return lhs == rhs + case let (lhs as UInt, rhs as UInt): + return lhs == rhs + case let (lhs as UInt8, rhs as UInt8): + return lhs == rhs + case let (lhs as UInt16, rhs as UInt16): + return lhs == rhs + case let (lhs as UInt32, rhs as UInt32): + return lhs == rhs + case let (lhs as UInt64, rhs as UInt64): + return lhs == rhs + case let (lhs as Float, rhs as Float): + return lhs == rhs + case let (lhs as Double, rhs as Double): + return lhs == rhs + case let (lhs as String, rhs as String): + return lhs == rhs + case let (lhs as [String: AnyDecodable], rhs as [String: AnyDecodable]): + return lhs == rhs + case let (lhs as [AnyDecodable], rhs as [AnyDecodable]): + return lhs == rhs + default: + return false + } + } +} + +extension AnyDecodable: CustomStringConvertible { + public var description: String { + switch value { + case is Void: + return String(describing: nil as Any?) + case let value as CustomStringConvertible: + return value.description + default: + return String(describing: value) + } + } +} + +extension AnyDecodable: CustomDebugStringConvertible { + public var debugDescription: String { + switch value { + case let value as CustomDebugStringConvertible: + return "AnyDecodable(\(value.debugDescription))" + default: + return "AnyDecodable(\(description))" + } + } +} + +extension AnyDecodable: Hashable { + public func hash(into hasher: inout Hasher) { + switch value { + case let value as Bool: + hasher.combine(value) + case let value as Int: + hasher.combine(value) + case let value as Int8: + hasher.combine(value) + case let value as Int16: + hasher.combine(value) + case let value as Int32: + hasher.combine(value) + case let value as Int64: + hasher.combine(value) + case let value as UInt: + hasher.combine(value) + case let value as UInt8: + hasher.combine(value) + case let value as UInt16: + hasher.combine(value) + case let value as UInt32: + hasher.combine(value) + case let value as UInt64: + hasher.combine(value) + case let value as Float: + hasher.combine(value) + case let value as Double: + hasher.combine(value) + case let value as String: + hasher.combine(value) + case let value as [String: AnyDecodable]: + hasher.combine(value) + case let value as [AnyDecodable]: + hasher.combine(value) + default: + break + } + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Helpers/AnyCodable/AnyEncodable.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Helpers/AnyCodable/AnyEncodable.swift new file mode 100644 index 0000000000..6a2d4e5ae2 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Helpers/AnyCodable/AnyEncodable.swift @@ -0,0 +1,293 @@ +// https://github.com/Flight-School/AnyCodable/blob/master/Sources/AnyCodable/AnyCodable.swift + +#if canImport(Foundation) +import Foundation +#endif + +/** + A type-erased `Encodable` value. + + The `AnyEncodable` type forwards encoding responsibilities + to an underlying value, hiding its specific underlying type. + + You can encode mixed-type values in dictionaries + and other collections that require `Encodable` conformance + by declaring their contained type to be `AnyEncodable`: + + let dictionary: [String: AnyEncodable] = [ + "boolean": true, + "integer": 42, + "double": 3.141592653589793, + "string": "string", + "array": [1, 2, 3], + "nested": [ + "a": "alpha", + "b": "bravo", + "c": "charlie" + ], + "null": nil + ] + + let encoder = JSONEncoder() + let json = try! encoder.encode(dictionary) + */ +struct AnyEncodable: Encodable { + public let value: Any + + public init(_ value: T?) { + self.value = value ?? () + } +} + +@usableFromInline +protocol _AnyEncodable { + var value: Any { get } + init(_ value: T?) +} + +extension AnyEncodable: _AnyEncodable {} + +// MARK: - Encodable + +extension _AnyEncodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch value { + #if canImport(Foundation) + case is NSNull: + try container.encodeNil() + #endif + case is Void: + try container.encodeNil() + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let int8 as Int8: + try container.encode(int8) + case let int16 as Int16: + try container.encode(int16) + case let int32 as Int32: + try container.encode(int32) + case let int64 as Int64: + try container.encode(int64) + case let uint as UInt: + try container.encode(uint) + case let uint8 as UInt8: + try container.encode(uint8) + case let uint16 as UInt16: + try container.encode(uint16) + case let uint32 as UInt32: + try container.encode(uint32) + case let uint64 as UInt64: + try container.encode(uint64) + case let float as Float: + try container.encode(float) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + #if canImport(Foundation) + case let number as NSNumber: + try encode(nsnumber: number, into: &container) + case let date as Date: + try container.encode(date) + case let url as URL: + try container.encode(url) + #endif + case let array as [Any?]: + try container.encode(array.map { AnyEncodable($0) }) + case let dictionary as [String: Any?]: + try container.encode(dictionary.mapValues { AnyEncodable($0) }) + case let encodable as Encodable: + try encodable.encode(to: encoder) + default: + let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyEncodable value cannot be encoded") + throw EncodingError.invalidValue(value, context) + } + } + + #if canImport(Foundation) + private func encode(nsnumber: NSNumber, into container: inout SingleValueEncodingContainer) throws { + switch Character(Unicode.Scalar(UInt8(nsnumber.objCType.pointee))) { + case "B": + try container.encode(nsnumber.boolValue) + case "c": + try container.encode(nsnumber.int8Value) + case "s": + try container.encode(nsnumber.int16Value) + case "i", "l": + try container.encode(nsnumber.int32Value) + case "q": + try container.encode(nsnumber.int64Value) + case "C": + try container.encode(nsnumber.uint8Value) + case "S": + try container.encode(nsnumber.uint16Value) + case "I", "L": + try container.encode(nsnumber.uint32Value) + case "Q": + try container.encode(nsnumber.uint64Value) + case "f": + try container.encode(nsnumber.floatValue) + case "d": + try container.encode(nsnumber.doubleValue) + default: + let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "NSNumber cannot be encoded because its type is not handled") + throw EncodingError.invalidValue(nsnumber, context) + } + } + #endif +} + +extension AnyEncodable: Equatable { + public static func == (lhs: AnyEncodable, rhs: AnyEncodable) -> Bool { + switch (lhs.value, rhs.value) { + case is (Void, Void): + return true + case let (lhs as Bool, rhs as Bool): + return lhs == rhs + case let (lhs as Int, rhs as Int): + return lhs == rhs + case let (lhs as Int8, rhs as Int8): + return lhs == rhs + case let (lhs as Int16, rhs as Int16): + return lhs == rhs + case let (lhs as Int32, rhs as Int32): + return lhs == rhs + case let (lhs as Int64, rhs as Int64): + return lhs == rhs + case let (lhs as UInt, rhs as UInt): + return lhs == rhs + case let (lhs as UInt8, rhs as UInt8): + return lhs == rhs + case let (lhs as UInt16, rhs as UInt16): + return lhs == rhs + case let (lhs as UInt32, rhs as UInt32): + return lhs == rhs + case let (lhs as UInt64, rhs as UInt64): + return lhs == rhs + case let (lhs as Float, rhs as Float): + return lhs == rhs + case let (lhs as Double, rhs as Double): + return lhs == rhs + case let (lhs as String, rhs as String): + return lhs == rhs + case let (lhs as [String: AnyEncodable], rhs as [String: AnyEncodable]): + return lhs == rhs + case let (lhs as [AnyEncodable], rhs as [AnyEncodable]): + return lhs == rhs + default: + return false + } + } +} + +extension AnyEncodable: CustomStringConvertible { + public var description: String { + switch value { + case is Void: + return String(describing: nil as Any?) + case let value as CustomStringConvertible: + return value.description + default: + return String(describing: value) + } + } +} + +extension AnyEncodable: CustomDebugStringConvertible { + public var debugDescription: String { + switch value { + case let value as CustomDebugStringConvertible: + return "AnyEncodable(\(value.debugDescription))" + default: + return "AnyEncodable(\(description))" + } + } +} + +extension AnyEncodable: ExpressibleByNilLiteral {} +extension AnyEncodable: ExpressibleByBooleanLiteral {} +extension AnyEncodable: ExpressibleByIntegerLiteral {} +extension AnyEncodable: ExpressibleByFloatLiteral {} +extension AnyEncodable: ExpressibleByStringLiteral {} +extension AnyEncodable: ExpressibleByStringInterpolation {} +extension AnyEncodable: ExpressibleByArrayLiteral {} +extension AnyEncodable: ExpressibleByDictionaryLiteral {} + +extension _AnyEncodable { + public init(nilLiteral _: ()) { + self.init(nil as Any?) + } + + public init(booleanLiteral value: Bool) { + self.init(value) + } + + public init(integerLiteral value: Int) { + self.init(value) + } + + public init(floatLiteral value: Double) { + self.init(value) + } + + public init(extendedGraphemeClusterLiteral value: String) { + self.init(value) + } + + public init(stringLiteral value: String) { + self.init(value) + } + + public init(arrayLiteral elements: Any...) { + self.init(elements) + } + + public init(dictionaryLiteral elements: (AnyHashable, Any)...) { + self.init([AnyHashable: Any](elements, uniquingKeysWith: { first, _ in first })) + } +} + +extension AnyEncodable: Hashable { + public func hash(into hasher: inout Hasher) { + switch value { + case let value as Bool: + hasher.combine(value) + case let value as Int: + hasher.combine(value) + case let value as Int8: + hasher.combine(value) + case let value as Int16: + hasher.combine(value) + case let value as Int32: + hasher.combine(value) + case let value as Int64: + hasher.combine(value) + case let value as UInt: + hasher.combine(value) + case let value as UInt8: + hasher.combine(value) + case let value as UInt16: + hasher.combine(value) + case let value as UInt32: + hasher.combine(value) + case let value as UInt64: + hasher.combine(value) + case let value as Float: + hasher.combine(value) + case let value as Double: + hasher.combine(value) + case let value as String: + hasher.combine(value) + case let value as [String: AnyEncodable]: + hasher.combine(value) + case let value as [AnyEncodable]: + hasher.combine(value) + default: + break + } + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Helpers/ConfigBuilder.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Helpers/ConfigBuilder.swift new file mode 100644 index 0000000000..ffeabf94a7 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Helpers/ConfigBuilder.swift @@ -0,0 +1,61 @@ +import Foundation + +/// The configuration object for the Formbricks SDK. +@objc(FormbricksConfig) public class FormbricksConfig: NSObject { + let appUrl: String + let environmentId: String + let userId: String? + let attributes: [String:String]? + let logLevel: LogLevel + + init(appUrl: String, environmentId: String, userId: String?, attributes: [String : String]?, logLevel: LogLevel) { + self.appUrl = appUrl + self.environmentId = environmentId + self.userId = userId + self.attributes = attributes + self.logLevel = logLevel + } + + /// The builder class for the FormbricksConfig object. + @objc(FormbricksConfigBuilder) public class Builder: NSObject { + var appUrl: String + var environmentId: String + var userId: String? + var attributes: [String:String] = [:] + var logLevel: LogLevel = .error + + @objc public init(appUrl: String, environmentId: String) { + self.appUrl = appUrl + self.environmentId = environmentId + } + + /// Sets the user id for the Builder object. + @objc public func set(userId: String) -> Builder { + self.userId = userId + return self + } + + /// Sets the attributes for the Builder object. + @objc public func set(attributes: [String:String]) -> Builder { + self.attributes = attributes + return self + } + + /// Adds an attribute to the Builder object. + @objc public func add(attribute: String, forKey key: String) -> Builder { + self.attributes[key] = attribute + return self + } + + /// Sets the log level for the Builder object. + @objc public func setLogLevel(_ logLevel: LogLevel) -> Builder { + self.logLevel = logLevel + return self + } + + /// Builds the FormbricksConfig object from the Builder object. + @objc public func build() -> FormbricksConfig { + return FormbricksConfig(appUrl: appUrl, environmentId: environmentId, userId: userId, attributes: attributes, logLevel: logLevel) + } + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Logger/Logger.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Logger/Logger.swift new file mode 100644 index 0000000000..63eae7a156 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Logger/Logger.swift @@ -0,0 +1,129 @@ +import Foundation + +@objc public enum LogLevel: Int { + case verbose + case debug + case info + case warning + case error + case none +} + +class Logger { + var logLevel: LogLevel = .none + let name: String + let emoji: String = "📝" + + init(name: String = "FormbricksSDK") { + self.name = name + } + + func verbose(_ message: Any = "", filename: String = #file, function: String = #function, line: Int = #line) { + log(message, logLevel: .verbose, filename: filename, function: function, line: line) + } + + func debug(_ message: Any = "", filename: String = #file, function: String = #function, line: Int = #line) { + log(message, logLevel: .debug, filename: filename, function: function, line: line) + } + + func info(_ message: Any = "", filename: String = #file, function: String = #function, line: Int = #line) { + log(message, logLevel: .info, filename: filename, function: function, line: line) + } + + func warning(_ message: Any = "", filename: String = #file, function: String = #function, line: Int = #line) { + log(message, logLevel: .warning, filename: filename, function: function, line: line) + } + + func error(_ message: Any = "", filename: String = #file, function: String = #function, line: Int = #line) { + log(message, logLevel: .error, filename: filename, function: function, line: line) + } +} + +private extension Logger { + + func log(_ message: Any = "", logLevel: LogLevel = .debug, filename: String, function: String, line: Int) { + guard ( logLevel == .error || logLevel.rawValue >= self.logLevel.rawValue ) else { return } + let body = regularBody(filename: filename, function: function, line: line) + var logString = regularLog(messageHeader: regularHeader(), messageBody: body, logLevel: logLevel) + + let messageString = String(describing: message) + if !messageString.isEmpty { + let messageListString = messageString.split(separator: "\n").map { "\(emoji)└ 📣 \($0)\n" }.joined() + logString.append(messageListString) + } + if logLevel == .error || logLevel.rawValue >= self.logLevel.rawValue { + DispatchQueue.main.async { + let str = logString + "\(self.emoji)\n" + print(str) + } + } + } + + func regularHeader() -> String { + return " \(formattedDate()) " + } + + func regularBody(filename: String, function: String, line: Int) -> String { + return " \(filenameWithoutPath(filename: filename)), in \(function) at #\(line) " + } + + func regularLog(messageHeader: String, messageBody: String, logLevel: LogLevel) -> String { + let nameString = " \(name) " + let logLevelString = getString(forLogLevel: logLevel) + let logLevelHorizontalLine = horizontalLine(for: logLevelString) + let headerHorizontalLine = horizontalLine(for: messageHeader) + let bodyHorizontalLine = horizontalLine(for: messageBody) + let nameHorizontalLine = horizontalLine(for: nameString) + + var logString = "" + logString.append("\(emoji)┌\(nameHorizontalLine)┬\(logLevelHorizontalLine)┬\(headerHorizontalLine)┬\(bodyHorizontalLine)┐\n") + logString.append("\(emoji)│\(nameString)│\(logLevelString)│\(messageHeader)│\(messageBody)│\n") + logString.append("\(emoji)└\(nameHorizontalLine)┴\(logLevelHorizontalLine)┴\(headerHorizontalLine)┴\(bodyHorizontalLine)┘\n") + return logString + } + + /// Returns a `String` composed by horizontal box-drawing characters (─) based on the given message length. + /// + /// For example: + /// + /// " ViewController.swift, in viewDidLoad() at 26 " // Message + /// "──────────────────────────────────────────────" // Returned String + /// + /// Reference: [U+250x Unicode](https://en.wikipedia.org/wiki/Box-drawing_character) + func horizontalLine(for message: String) -> String { + return Array(repeating: "─", count: message.count).joined() + } + + func getString(forLogLevel logLevel: LogLevel) -> String { + switch logLevel { + case .verbose: + return " VERBOSE " + case .debug: + return " DEBUG " + case .info: + return " INFO " + case .warning: + return " WARNING " + case .error: + return " ERROR " + default: + return "" + } + } +} + +// MARK: Util + +private extension Logger { + /// "/Users/blablabla/Class.swift" becomes "Class.swift" + func filenameWithoutPath(filename: String) -> String { + return URL(fileURLWithPath: filename).lastPathComponent + } + + /// E.g. `15:25:04.749` + func formattedDate() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MM/dd/yy, HH:mm:ss.SSS" + return "\(dateFormatter.string(from: Date()))" + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Manager/SurveyManager.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/SurveyManager.swift new file mode 100644 index 0000000000..9b24cf2b69 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/SurveyManager.swift @@ -0,0 +1,247 @@ +import SwiftUI + +/// 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. +final class SurveyManager { + static let shared = SurveyManager() + private init() { } + + private static let environmentResponseObjectKey = "environmentResponseObjectKey" + private let service = FormbricksService() + private var backingEnvironmentResponse: EnvironmentResponse? + /// The view controller that will present the survey window. + private weak var viewController: UIViewController? + /// Stores the surveys that are filtered based on the defined criteria, such as recontact days, display options etc. + private var filteredSurveys: [Survey] = [] + /// Stores is a survey is being shown or the show in delayed + private var isShowingSurvey: Bool = false + + /// Fills up the `filteredSurveys` array + func filterSurveys() { + guard let environment = environmentResponse else { return } + guard let surveys = environment.data.data.surveys else { return } + + let displays = UserManager.shared.displays ?? [] + let responses = UserManager.shared.responses ?? [] + let segments = UserManager.shared.segments ?? [] + + filteredSurveys = filterSurveysBasedOnDisplayType(surveys, displays: displays, responses: responses) + filteredSurveys = filterSurveysBasedOnRecontactDays(filteredSurveys, defaultRecontactDays: environment.data.data.project.recontactDays) + + // If we have a user, we do more filtering + if UserManager.shared.userId != nil { + if segments.isEmpty { + filteredSurveys = [] + return + } + + filteredSurveys = filterSurveysBasedOnSegments(filteredSurveys, segments: segments) + } + } + + /// 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. + func track(_ action: String) { + guard !isShowingSurvey else { return } + let actionClasses = environmentResponse?.data.data.actionClasses ?? [] + let codeActionClasses = actionClasses.filter { $0.type == "code" } + let actionClass = codeActionClasses.first { $0.key == action } + let firstSurveyWithActionClass = filteredSurveys.first { survey in + return survey.triggers?.contains(where: { $0.actionClass?.name == actionClass?.name }) ?? false + } + + // Display percentage + let shouldDisplay = shouldDisplayBasedOnPercentage(firstSurveyWithActionClass?.displayPercentage) + + // Display and delay it if needed + if let surveyId = firstSurveyWithActionClass?.id, shouldDisplay { + isShowingSurvey = true + let timeout = firstSurveyWithActionClass?.delay ?? 0 + DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { + self.showSurvey(withId: surveyId) + } + } + } +} + +// MARK: - API calls - +extension SurveyManager { + /// 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. + func refreshEnvironmentIfNeeded(force: Bool = false) { + if let environmentResponse = environmentResponse, environmentResponse.data.expiresAt.timeIntervalSinceNow > 0, !force { + Formbricks.logger.debug("Environment state is still valid until \(environmentResponse.data.expiresAt)") + filterSurveys() + return + } + + service.getEnvironmentState { [weak self] result in + switch result { + case .success(let response): + self?.environmentResponse = response + self?.startRefreshTimer(expiresAt: response.data.expiresAt) + self?.filterSurveys() + case .failure: + Formbricks.logger.error(FormbricksSDKError(type: .unableToRefreshEnvironment).message) + self?.startErrorTimer() + } + } + } + + /// Posts a survey response to the Formbricks API. + func postResponse(surveyId: String) { + UserManager.shared.onResponse(surveyId: surveyId) + } + + /// Creates a new display for the survey. It is called when the survey is displayed to the user. + func onNewDisplay(surveyId: String) { + UserManager.shared.onDisplay(surveyId: surveyId) + } +} + +// MARK: - Present and dismiss survey window - +extension SurveyManager { + /// Dismisses the presented survey window. + func dismissSurveyWebView() { + isShowingSurvey = false + viewController?.dismiss(animated: true) + + } + + /// Dismisses the presented survey window after a delay. + func delayedDismiss() { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(Config.Environment.closingTimeoutInSeconds)) { + self.dismissSurveyWebView() + } + } +} + +private extension SurveyManager { + /// Presents the survey window with the given id. It is called when a survey is triggered. + /// The survey is displayed based on the `FormbricksView`. + /// The view controller is presented over the current context. + func showSurvey(withId id: String) { + if let environmentResponse = environmentResponse { + DispatchQueue.main.async { + if let window = UIApplication.safeKeyWindow { + let view = FormbricksView(viewModel: FormbricksViewModel(environmentResponse: environmentResponse, surveyId: id)) + let vc = UIHostingController(rootView: view) + vc.modalPresentationStyle = .overCurrentContext + vc.view.backgroundColor = UIColor.gray.withAlphaComponent(0.6) + if let presentationController = vc.presentationController as? UISheetPresentationController { + presentationController.detents = [.large()] + } + self.viewController = vc + window.rootViewController?.present(vc, animated: true, completion: nil) + } + } + } + + } + + /// Starts a timer to refresh the environment state after the given timeout (`expiresAt`). + func startRefreshTimer(expiresAt: Date) { + let timeout = expiresAt.timeIntervalSinceNow + refreshEnvironmentAfter(timeout: timeout) + } + + /// When an error occurs, it starts a timer to refresh the environment state after the given timeout. + func startErrorTimer() { + refreshEnvironmentAfter(timeout: Double(Config.Environment.refreshStateOnErrorTimeoutInMinutes) * 60.0) + } + + /// Refreshes the environment state after the given timeout. + func refreshEnvironmentAfter(timeout: Double) { + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { + Formbricks.logger.debug("Refreshing environment state.") + self.refreshEnvironmentIfNeeded(force: true) + } + } + + /// Decides if the survey should be displayed based on the display percentage. + func shouldDisplayBasedOnPercentage(_ displayPercentage: Double?) -> Bool { + guard let displayPercentage = displayPercentage else { return true } + let randomNum = Double(Int.random(in: 0..<10000)) / 100.0 + return randomNum <= displayPercentage + } +} + +// MARK: - Store data in the UserDefaults - +extension SurveyManager { + var environmentResponse: EnvironmentResponse? { + get { + if let environmentResponse = backingEnvironmentResponse { + return environmentResponse + } else { + if let data = UserDefaults.standard.data(forKey: SurveyManager.environmentResponseObjectKey) { + return try? JSONDecoder().decode(EnvironmentResponse.self, from: data) + } else { + Formbricks.logger.error(FormbricksSDKError(type: .unableToRetrieveEnvironment).message) + return nil + } + } + } set { + if let data = try? JSONEncoder().encode(newValue) { + UserDefaults.standard.set(data, forKey: SurveyManager.environmentResponseObjectKey) + backingEnvironmentResponse = newValue + } else { + Formbricks.logger.error(FormbricksSDKError(type: .unableToPersistEnvironment).message) + } + } + } +} + +// MARK: - Helper methods - +private extension SurveyManager { + /// Filters the surveys based on the display type and limit. + func filterSurveysBasedOnDisplayType(_ surveys: [Survey], displays: [Display], responses: [String]) -> [Survey] { + return surveys.filter { survey in + switch survey.displayOption { + case .respondMultiple: + return true + + case .displayOnce: + return !displays.contains { $0.surveyId == survey.id } + + case .displayMultiple: + return !responses.contains { $0 == survey.id } + + case .displaySome: + if let limit = survey.displayLimit { + if responses.contains(where: { $0 == survey.id }) { return false } + return displays.filter { $0.surveyId == survey.id }.count < limit + } else { + return true + } + + default: + Formbricks.logger.error(FormbricksSDKError(type: .invalidDisplayOption).message) + return false + } + + + } + } + + /// Filters the surveys based on the recontact days and the `lastDisplayedAt` date. + func filterSurveysBasedOnRecontactDays(_ surveys: [Survey], defaultRecontactDays: Int?) -> [Survey] { + surveys.filter { survey in + guard let lastDisplayedAt = UserManager.shared.lastDisplayedAt else { return true } + let recontactDays = survey.recontactDays ?? defaultRecontactDays + + if let recontactDays = recontactDays { + return Calendar.current.numberOfDaysBetween(Date(), and: lastDisplayedAt) >= recontactDays + } + + return true + } + } + + /// Filters the surveys based on the user's segments. + func filterSurveysBasedOnSegments(_ surveys: [Survey], segments: [String]) -> [Survey] { + return surveys.filter { survey in + guard let segmentId = survey.segment?.id else { return false } + return segments.contains(segmentId) + } + } + +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Manager/UserManager.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/UserManager.swift new file mode 100644 index 0000000000..b239b5c36f --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/UserManager.swift @@ -0,0 +1,191 @@ +/// Store and manage user state and sync with the server when needed. +final class UserManager { + static let shared = UserManager() + private init() { } + + private static let userIdKey = "userIdKey" + private static let segmentsKey = "segmentsKey" + private static let displaysKey = "displaysKey" + private static let responsesKey = "responsesKey" + private static let lastDisplayedAtKey = "lastDisplayedAtKey" + private static let expiresAtKey = "expiresAtKey" + + private let service = FormbricksService() + + private var backingUserId: String? + private var backingSegments: [String]? + private var backingDisplays: [Display]? + private var backingResponses: [String]? + private var backingLastDisplayedAt: Date? + private var backingExpiresAt: Date? + + private var syncTimer: Timer? + + /// Starts an update queue with the given user id. + func set(userId: String) { + UpdateQueue.current.set(userId: userId) + } + + /// Starts an update queue with the given attribute. + func add(attribute: String, forKey key: String) { + UpdateQueue.current.add(attribute: attribute, forKey: key) + } + + /// Starts an update queue with the given attributes. + func set(attributes: [String: String]) { + UpdateQueue.current.set(attributes: attributes) + } + + /// Starts an update queue with the given language.. + func set(language: String) { + UpdateQueue.current.set(language: language) + } + + /// Saves `surveyId` to the `displays` property and the current date to the `lastDisplayedAt` property. + func onDisplay(surveyId: String) { + let lastDisplayedAt = Date() + var newDisplays = displays ?? [] + newDisplays.append(Display(surveyId: surveyId, createdAt: DateFormatter.isoFormatter.string(from: lastDisplayedAt))) + displays = newDisplays + self.lastDisplayedAt = lastDisplayedAt + } + + /// Saves `surveyId` to the `responses` property. + func onResponse(surveyId: String) { + var newResponses = responses ?? [] + newResponses.append(surveyId) + responses = newResponses + } + + /// Syncs the user state with the server if the user id is set and the expiration date has passed. + func syncUserStateIfNeeded() { + guard let id = userId, let expiresAt = self.expiresAt, expiresAt.timeIntervalSinceNow <= 0 else { + backingSegments = [] + backingDisplays = [] + backingResponses = [] + return + } + + syncUser(withId: id) + } + + /// Syncs the user state with the server, calls the `SurveyManager.shared.filterSurveys()` method and starts the sync timer. + func syncUser(withId id: String, attributes: [String: String]? = nil) { + service.postUser(id: id, attributes: attributes) { [weak self] result in + switch result { + case .success(let userResponse): + self?.userId = userResponse.data.state?.data?.userId + self?.segments = userResponse.data.state?.data?.segments + self?.displays = userResponse.data.state?.data?.displays + self?.responses = userResponse.data.state?.data?.responses + self?.lastDisplayedAt = userResponse.data.state?.data?.lastDisplayAt + self?.expiresAt = userResponse.data.state?.expiresAt + UpdateQueue.current.reset() + SurveyManager.shared.filterSurveys() + self?.startSyncTimer() + case .failure(let error): + Formbricks.logger.error(error) + } + } + } + + /// Logs out the user and clears the user state. + func logout() { + UserDefaults.standard.removeObject(forKey: UserManager.userIdKey) + UserDefaults.standard.removeObject(forKey: UserManager.segmentsKey) + UserDefaults.standard.removeObject(forKey: UserManager.displaysKey) + UserDefaults.standard.removeObject(forKey: UserManager.responsesKey) + UserDefaults.standard.removeObject(forKey: UserManager.lastDisplayedAtKey) + UserDefaults.standard.removeObject(forKey: UserManager.expiresAtKey) + backingUserId = nil + backingSegments = nil + backingDisplays = nil + backingResponses = nil + backingLastDisplayedAt = nil + backingExpiresAt = nil + UpdateQueue.current.reset() + } +} + +// MARK: - Timer - +private extension UserManager { + func startSyncTimer() { + guard let expiresAt = expiresAt, let id = userId else { return } + syncTimer?.invalidate() + syncTimer = Timer.scheduledTimer(withTimeInterval: expiresAt.timeIntervalSinceNow, repeats: false) { [weak self] _ in + self?.syncUser(withId: id) + } + } + +} + +// MARK: - Getters - +extension UserManager { + private(set) var userId: String? { + get { + backingUserId = backingUserId ?? UserDefaults.standard.string(forKey: UserManager.userIdKey) + return backingUserId + } set { + UserDefaults.standard.set(newValue, forKey: UserManager.userIdKey) + backingUserId = newValue + } + } + private(set) var segments: [String]? { + get { + backingSegments = backingSegments ?? UserDefaults.standard.stringArray(forKey: UserManager.segmentsKey) + return backingSegments + } set { + UserDefaults.standard.set(newValue, forKey: UserManager.segmentsKey) + backingSegments = newValue + } + } + private(set) var displays: [Display]? { + get { + guard let jsonData = UserDefaults.standard.string(forKey: UserManager.displaysKey)?.data(using: .utf8) else { + return nil + } + let decodedDisplays = try? JSONDecoder().decode([Display].self, from: jsonData) + backingDisplays = decodedDisplays + return backingDisplays + } set { + guard let jsonData = try? JSONEncoder().encode(newValue), let jsonString = String(data: jsonData, encoding: .utf8) else { return } + UserDefaults.standard.set(jsonString, forKey: UserManager.displaysKey) + backingDisplays = newValue + } + } + private(set) var responses: [String]? { + get { + backingResponses = backingResponses ?? UserDefaults.standard.stringArray(forKey: UserManager.responsesKey) + return backingResponses + } set { + UserDefaults.standard.set(newValue, forKey: UserManager.responsesKey) + backingResponses = newValue + } + } + private(set) var lastDisplayedAt: Date? { + get { + if let backingLastDisplayedAt = backingLastDisplayedAt { + return backingLastDisplayedAt + } else { + let timeInterval = UserDefaults.standard.double(forKey: UserManager.lastDisplayedAtKey) + return timeInterval > 0 ? Date(timeIntervalSince1970: timeInterval) : nil + } + } set { + UserDefaults.standard.set(newValue?.timeIntervalSince1970, forKey: UserManager.lastDisplayedAtKey) + backingLastDisplayedAt = newValue + } + } + private(set) var expiresAt: Date? { + get { + if let backingExpiresAt = backingExpiresAt { + return backingExpiresAt + } else { + let timeInterval = UserDefaults.standard.double(forKey: UserManager.expiresAtKey) + return timeInterval > 0 ? Date(timeIntervalSince1970: timeInterval) : nil + } + } set { + UserDefaults.standard.set(newValue?.timeIntervalSince1970, forKey: UserManager.expiresAtKey) + backingExpiresAt = newValue + } + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/ActionClass/ActionClass.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/ActionClass/ActionClass.swift new file mode 100644 index 0000000000..6d1ded7379 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/ActionClass/ActionClass.swift @@ -0,0 +1,6 @@ +struct ActionClass: Codable { + let id: String? + let type: String? + let name: String? + let key: String? +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Common/LocalizedText.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Common/LocalizedText.swift new file mode 100644 index 0000000000..386cc57f35 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Common/LocalizedText.swift @@ -0,0 +1,7 @@ +struct LocalizedText: Codable { + let defaultText: String? + + enum CodingKeys: String, CodingKey { + case defaultText = "default" + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/EnvironmentData.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/EnvironmentData.swift new file mode 100644 index 0000000000..1a5f7b0d36 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/EnvironmentData.swift @@ -0,0 +1,5 @@ +struct EnvironmentData: Codable { + let surveys: [Survey]? + let actionClasses: [ActionClass]? + let project: Project +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/EnvironmentResponse.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/EnvironmentResponse.swift new file mode 100644 index 0000000000..a473fa9740 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/EnvironmentResponse.swift @@ -0,0 +1,40 @@ +struct EnvironmentResponse: Codable { + let data: EnvironmentResponseData + + var responseString: String? + + enum CodingKeys: CodingKey { + case data + case responseString + } +} + +extension EnvironmentResponse { + func getSurveyJson(forSurveyId surveyId: String) -> [String: Any]? { + guard let jsonData = responseString?.data(using: .utf8) else { return nil } + let responseDictionary = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] + let responseDict = responseDictionary?["data"] as? [String: Any] + let dataDict = responseDict?["data"] as? [String: Any] + let surveysArray = dataDict?["surveys"] as? [[String: Any]] + return surveysArray?.first(where: { $0["id"] as? String == surveyId }) as? [String: Any] + } + + func getSurveyStylingJson(forSurveyId surveyId: String) -> [String: Any]? { + guard let jsonData = responseString?.data(using: .utf8) else { return nil } + let responseDictionary = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] + let responseDict = responseDictionary?["data"] as? [String: Any] + let dataDict = responseDict?["data"] as? [String: Any] + let surveysArray = dataDict?["surveys"] as? [[String: Any]] + let survey = surveysArray?.first(where: { $0["id"] as? String == surveyId }) as? [String: Any] + return survey?["styling"] as? [String: Any] + } + + func getProjectStylingJson() -> [String: Any]? { + guard let jsonData = responseString?.data(using: .utf8) else { return nil } + let responseDictionary = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] + let responseDict = responseDictionary?["data"] as? [String: Any] + let dataDict = responseDict?["data"] as? [String: Any] + let projectDict = dataDict?["project"] as? [String: Any] + return projectDict?["styling"] as? [String: Any] + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/EnvironmentResponseData.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/EnvironmentResponseData.swift new file mode 100644 index 0000000000..ef1e580c41 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/EnvironmentResponseData.swift @@ -0,0 +1,4 @@ +struct EnvironmentResponseData: Codable { + let data: EnvironmentData + let expiresAt: Date +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Project/BrandColor.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Project/BrandColor.swift new file mode 100644 index 0000000000..0dcb456878 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Project/BrandColor.swift @@ -0,0 +1,3 @@ +struct BrandColor: Codable { + let light: String? +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Project/Project.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Project/Project.swift new file mode 100644 index 0000000000..cb89287d12 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Project/Project.swift @@ -0,0 +1,9 @@ +struct Project: Codable { + let id: String? + let recontactDays: Int? + let clickOutsideClose: Bool? + let darkOverlay: Bool? + let placement: String? + let inAppSurveyBranding: Bool? + let styling: Styling? +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Project/Styling.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Project/Styling.swift new file mode 100644 index 0000000000..d8840270bd --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Project/Styling.swift @@ -0,0 +1,4 @@ +struct Styling: Codable { + let brandColor: BrandColor? + let allowStyleOverwrite: Bool? +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Survey.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Survey.swift new file mode 100644 index 0000000000..61ca0a6d6a --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Survey.swift @@ -0,0 +1,20 @@ +enum DisplayOptionType: String, Codable { + case respondMultiple = "respondMultiple" + case displayOnce = "displayOnce" + case displayMultiple = "displayMultiple" + case displaySome = "displaySome" +} + +struct Survey: Codable { + let id: String + let name: String + let triggers: [Trigger]? + let recontactDays: Int? + let displayLimit: Int? + let delay: Int? + let displayPercentage: Double? + let displayOption: DisplayOptionType? + let segment: Segment? + let styling: Styling? +} + diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Surveys/ActionClassReference.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Surveys/ActionClassReference.swift new file mode 100644 index 0000000000..8ece30eeb8 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Surveys/ActionClassReference.swift @@ -0,0 +1,3 @@ +struct ActionClassReference: Codable { + let name: String? +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Surveys/Segment.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Surveys/Segment.swift new file mode 100644 index 0000000000..994bb75a7e --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Surveys/Segment.swift @@ -0,0 +1,11 @@ +struct Segment: Codable { + let id: String? + let createdAt: String? + let updatedAt: String? + let title: String? + let description: String? + let isPrivate: Bool? + let filters: [String]? + let environmentId: String? + let surveys: [String]? +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Surveys/Trigger.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Surveys/Trigger.swift new file mode 100644 index 0000000000..49d1eb3266 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Environment/Surveys/Trigger.swift @@ -0,0 +1,3 @@ +struct Trigger: Codable { + let actionClass: ActionClassReference? +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Error/APIError.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Error/APIError.swift new file mode 100644 index 0000000000..4e361599e8 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Error/APIError.swift @@ -0,0 +1,33 @@ +enum FormbricksAPIErrorType: Int { + case invalidResponse + case responseError + + var description: String { + switch self { + case .invalidResponse: + return "Unknown error. Please check your connection and try again." + case .responseError: + return "Response error." + } + } +} + +final class FormbricksAPIClientError: LocalizedError { + let type: FormbricksAPIErrorType + let statusCodeInt: Int? + let statusCode: HTTPStatusCode? + + var errorDescription: String + + init(type: FormbricksAPIErrorType, statusCode: Int? = nil) { + self.type = type + if let statusCode = statusCode { + self.statusCodeInt = statusCode + self.statusCode = HTTPStatusCode(rawValue: statusCode) + } else { + self.statusCodeInt = nil + self.statusCode = nil + } + self.errorDescription = type.description + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Error/FormbricksAPIError.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Error/FormbricksAPIError.swift new file mode 100644 index 0000000000..78aa186125 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Error/FormbricksAPIError.swift @@ -0,0 +1,15 @@ +struct FormbricksAPIError: Codable, LocalizedError { + let code: String + let message: String + let details: [String:String]? +} + +extension FormbricksAPIError { + func getDetailedErrorMessage() -> String { + if let errorDetails = details?.map({ "\($0.key): \($0.value)" }) { + return "\(message)\n\(errorDetails)" + } else { + return message + } + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Error/RuntimeError.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Error/RuntimeError.swift new file mode 100644 index 0000000000..6ed6d448d2 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Error/RuntimeError.swift @@ -0,0 +1,7 @@ +struct RuntimeError: Error, Codable { + let message: String + + var localizedDescription: String { + return message + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Error/SDKError.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Error/SDKError.swift new file mode 100644 index 0000000000..cd51a6eb86 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Error/SDKError.swift @@ -0,0 +1,53 @@ +enum FormbricksSDKErrorType: Int { + case sdkIsNotInitialized + case sdkIsAlreadyInitialized + case invalidAppUrl + case unableToRefreshEnvironment + case unableToPersistEnvironment + case unableToRetrieveEnvironment + case invalidJavascriptMessage + case unableToRetrieveUser + case unableToPersistUser + case userIdIsNotSetYet + case invalidDisplayOption + case networkError + + var description: String { + switch self { + case .sdkIsNotInitialized: + return "The SDK is not initialized" + case .sdkIsAlreadyInitialized: + return "The SDK is already initialized" + case .invalidAppUrl: + return "Invalid App URL" + case .unableToRefreshEnvironment: + return "Unable to refresh the environment object. Will try again in \(Config.Environment.refreshStateOnErrorTimeoutInMinutes) minutes." + case .unableToPersistEnvironment: + return "Unable to persist the environment object." + case .unableToRetrieveEnvironment: + return "Unable to retrieve the environment object." + case .invalidJavascriptMessage: + return "Invalid Javascript Message" + case .unableToRetrieveUser: + return "Unable to retrieve the user object." + case .unableToPersistUser: + return "Unable to persist the user object." + case .userIdIsNotSetYet: + return "Unable to commit user attributes because userId is not set." + case .invalidDisplayOption: + return "Invalid Display Option" + case .networkError: + return "No internet connection" + } + } +} + +final class FormbricksSDKError: LocalizedError { + let type: FormbricksSDKErrorType + var errorDescription: String + + init(type: FormbricksSDKErrorType) { + self.type = type + self.errorDescription = type.description + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Javascript/EventType.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Javascript/EventType.swift new file mode 100644 index 0000000000..f5b6264ab9 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Javascript/EventType.swift @@ -0,0 +1,7 @@ +enum EventType: String, Codable { + case onClose = "onClose" + case onFinished = "onFinished" + case onDisplayCreated = "onDisplayCreated" + case onResponseCreated = "onResponseCreated" + case onOpenExternalURL = "onOpenExternalURL" +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Javascript/JsMessageData.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Javascript/JsMessageData.swift new file mode 100644 index 0000000000..4b1f88d3a2 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Javascript/JsMessageData.swift @@ -0,0 +1,3 @@ +struct JsMessageData: Codable { + let event: EventType +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/Javascript/OpenExternalUrlMessage.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Javascript/OpenExternalUrlMessage.swift new file mode 100644 index 0000000000..f93c2c4dd6 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/Javascript/OpenExternalUrlMessage.swift @@ -0,0 +1,8 @@ +struct OpenExternalUrlMessage: Codable { + let event: EventType + let onOpenExternalURLParams: OnOpenExternalURLParams +} + +struct OnOpenExternalURLParams: Codable { + let url: String +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/User/Display.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/User/Display.swift new file mode 100644 index 0000000000..7509bfd870 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/User/Display.swift @@ -0,0 +1,4 @@ +struct Display: Codable { + let surveyId: String + let createdAt: String +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/User/UserResponseData.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/User/UserResponseData.swift new file mode 100644 index 0000000000..98a8bffec2 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/User/UserResponseData.swift @@ -0,0 +1,3 @@ +struct UserResponseData: Codable { + let state: UserState? +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/User/UserState.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/User/UserState.swift new file mode 100644 index 0000000000..2a10288b71 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/User/UserState.swift @@ -0,0 +1,4 @@ +struct UserState: Codable { + let data: UserStateDetails? + let expiresAt: Date +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Model/User/UserStateDetails.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Model/User/UserStateDetails.swift new file mode 100644 index 0000000000..cb5320577b --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Model/User/UserStateDetails.swift @@ -0,0 +1,7 @@ +struct UserStateDetails: Codable { + let userId: String + let segments: [String]? + let displays: [Display]? + let responses: [String]? + let lastDisplayAt: Date? +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Base/APIClient.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Base/APIClient.swift new file mode 100644 index 0000000000..8d9dc3e8c9 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Base/APIClient.swift @@ -0,0 +1,169 @@ +import Foundation + +class APIClient: Operation, @unchecked Sendable { + + private let session = URLSession.shared + private let request: Request + private let completion: ((ResultType) -> Void)? + + init(request: Request, completion: ((ResultType) -> Void)?) { + self.request = request + self.completion = completion + } + + override func main() { + guard let apiURL = request.baseURL, var baseUrlComponents = URLComponents(string: apiURL) else { + completion?(.failure(FormbricksSDKError(type: .sdkIsNotInitialized))) + return + } + + baseUrlComponents.queryItems = request.queryParams?.map { URLQueryItem(name: $0.key, value: $0.value) } + + guard var finalURL = baseUrlComponents.url else { + completion?(.failure(FormbricksSDKError(type: .invalidAppUrl))) + return + } + + guard let requestEndPoint = setPathParams(request.requestEndPoint) else { + completion?(.failure(FormbricksSDKError(type: .sdkIsNotInitialized))) + return + } + + finalURL.appendPathComponent(requestEndPoint) + + let urlRequest = createURLRequest(forURL: finalURL) + + // LOG + var requestLogMessage = "\(request.requestType.rawValue) >>> " + if let urlString = urlRequest.url?.absoluteString { + requestLogMessage.append(urlString) + } + if let headers = urlRequest.allHTTPHeaderFields { + requestLogMessage.append("\nHeaders: \(headers)") + } + if let body = urlRequest.httpBody { + requestLogMessage.append("\nBody: \(String(data: body, encoding: .utf8) ?? "")") + } + + Formbricks.logger.info(requestLogMessage) + + session.dataTask(with: urlRequest) { (data, response, error) in + if let httpStatus = (response as? HTTPURLResponse)?.status { + var responseLogMessage = "\(httpStatus.rawValue) <<< " + if let urlString = response?.url?.absoluteString { + responseLogMessage.append(urlString) + } + + switch httpStatus.responseType { + case .success: + guard let data = data else { + self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue))) + return + } + if let responseString = String(data: data, encoding: .utf8) { + responseLogMessage.append("\n\(responseString)\n") + } + + do { + if Request.Response.self == VoidResponse.self { + Formbricks.logger.info(responseLogMessage) + self.completion?(.success(VoidResponse() as! Request.Response)) + } else { + var body = try self.request.decoder.decode(Request.Response.self, from: data) + Formbricks.logger.info(responseLogMessage) + + // We want to save the entire response dictionary for the environment response + if var environmentResponse = body as? EnvironmentResponse { + if let jsonString = String(data: data, encoding: .utf8) { + environmentResponse.responseString = jsonString + body = environmentResponse as! Request.Response + } + } + + self.completion?(.success(body)) + } + } + catch let DecodingError.dataCorrupted(context) { + responseLogMessage.append("Data corrupted \(context)\n") + Formbricks.logger.error(responseLogMessage) + self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue))) + } + catch let DecodingError.keyNotFound(key, context) { + responseLogMessage.append("Key '\(key)' not found: \(context.debugDescription)\n") + responseLogMessage.append("codingPath: \(context.codingPath)") + Formbricks.logger.error(responseLogMessage) + self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue))) + } + catch let DecodingError.valueNotFound(value, context) { + responseLogMessage.append("Value '\(value)' not found: \(context.debugDescription)\n") + responseLogMessage.append("codingPath: \(context.codingPath)") + Formbricks.logger.error(responseLogMessage) + self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue))) + } + catch let DecodingError.typeMismatch(type, context) { + responseLogMessage.append("Type '\(type)' mismatch: \(context.debugDescription)\n") + responseLogMessage.append("codingPath: \(context.codingPath)") + Formbricks.logger.error(responseLogMessage) + self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue))) + } + catch { + responseLogMessage.append("error: \(error.message)") + Formbricks.logger.error(responseLogMessage) + self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue))) + } + + default: + if let error = error { + responseLogMessage.append("\nError: \(error.localizedDescription)") + Formbricks.logger.error(responseLogMessage) + self.completion?(.failure(error)) + } else if let data = data, let apiError = try? self.request.decoder.decode(FormbricksAPIError.self, from: data) { + Formbricks.logger.error("\(responseLogMessage)\n\(apiError.getDetailedErrorMessage())") + self.completion?(.failure(apiError)) + } else { + let error = FormbricksAPIClientError(type: .responseError, statusCode: httpStatus.rawValue) + Formbricks.logger.error("\(responseLogMessage)\n\(error.message)") + self.completion?(.failure(error)) + } + } + } + else { + let error = FormbricksAPIClientError(type: .invalidResponse) + Formbricks.logger.error("ERROR \(error.message)") + self.completion?(.failure(error)) + } + }.resume() + } +} + +private extension APIClient { + func createURLRequest(forURL url: URL) -> URLRequest { + var urlRequest = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 10) + + request.headers?.forEach { + urlRequest.addValue($0.value, forHTTPHeaderField: $0.key) + } + + urlRequest.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + urlRequest.httpMethod = request.requestType.rawValue + + if let body = request.requestBody { + urlRequest.httpBody = body + } + + return urlRequest + } + + func setPathParams(_ path: String) -> String? { + var newPath = path + if let environmentId = Formbricks.environmentId { + newPath = newPath.replacingOccurrences(of: "{environmentId}", with: environmentId) + } + + request.pathParams?.forEach { key, value in + newPath = newPath.replacingOccurrences(of: key, with: value) + } + + return newPath + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Base/EncodableRequest.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Base/EncodableRequest.swift new file mode 100644 index 0000000000..c33700b6a7 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Base/EncodableRequest.swift @@ -0,0 +1,97 @@ +import Foundation + +typealias ResultType = Result +struct VoidResponse: Codable {} + +// MARK: - Method types - +enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case patch = "PATCH" + case delete = "DELETE" +} + +// MARK: - Encoding type - +enum EncodingType { + case url + case json +} + +// MARK: - Base API protocol - +protocol BaseApiRequest { + var requestEndPoint: String { get } + var requestType: HTTPMethod { get } + var encoding: EncodingType { get } + var headers: [String:String]? { get } + var requestBody: Data? { get } +} + +extension BaseApiRequest { + + var encoding: EncodingType { + return .json + } + + var requestBody: Data? { + return nil + } + + var headers: [String:String]? { + return [:] + } +} + +// MARK: - Codable protocol - +protocol CodableRequest: BaseApiRequest { + associatedtype Response: Decodable + associatedtype ErrorType: Error & Decodable + + var baseURL: String? { get } + + var decoder: JSONDecoder { get } + + var queryParams: [String: String]? { get } + + var pathParams: [String: String]? { get } +} + +extension CodableRequest { + typealias ErrorType = RuntimeError + + var baseURL: String? { + return Formbricks.appUrl + } + + var decoder: JSONDecoder { + return JSONDecoder.iso8601Full + } + + var queryParams: [String: String]? { + return nil + } + + + var pathParams: [String: String]? { + return nil + } +} + +// MARK: - Encodable protocol - +class EncodableRequest { + let object: EncodableObject + let encoder: JSONEncoder + + init(object: EncodableObject, encoder: JSONEncoder = JSONEncoder.iso8601Full) { + self.object = object + self.encoder = encoder + } + + var requestBody: Data? { + guard let data = try? self.encoder.encode(self.object) else { + assertionFailure("Unable to encode object: \(self.object)") + return nil + } + return data + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Base/HTTPStatusCode.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Base/HTTPStatusCode.swift new file mode 100644 index 0000000000..a72adf9975 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Base/HTTPStatusCode.swift @@ -0,0 +1,273 @@ +import Foundation + +enum HTTPStatusCode: Int, Error { + + enum ResponseType { + /// - informational: This class of status code indicates a provisional response, consisting only of the Status-Line and optional headers, and is terminated by an empty line. + case informational + /// - success: This class of status codes indicates the action requested by the client was received, understood, accepted, and processed successfully. + case success + /// - redirection: This class of status code indicates the client must take additional action to complete the request. + case redirection + /// - clientError: This class of status code is intended for situations in which the client seems to have erred. + case clientError + /// - serverError: This class of status code indicates the server failed to fulfill an apparently valid request. + case serverError + /// - undefined: The class of the status code cannot be resolved. + case undefined + } + + // MARK: - Informational - 1xx - + + /// - continue: The server has received the request headers and the client should proceed to send the request body. + case `continue` = 100 + + /// - switchingProtocols: The requester has asked the server to switch protocols and the server has agreed to do so. + case switchingProtocols = 101 + + /// - processing: This code indicates that the server has received and is processing the request, but no response is available yet. + case processing = 102 + + // MARK: - Success - 2xx - + + /// - ok: Standard response for successful HTTP requests. + case ok = 200 + + /// - created: The request has been fulfilled, resulting in the creation of a new resource. + case created = 201 + + /// - accepted: The request has been accepted for processing, but the processing has not been completed. + case accepted = 202 + + /// - nonAuthoritativeInformation: The server is a transforming proxy (e.g. a Web accelerator) that received a 200 OK from its origin, but is returning a modified version of the origin's response. + case nonAuthoritativeInformation = 203 + + /// - noContent: The server successfully processed the request and is not returning any content. + case noContent = 204 + + /// - resetContent: The server successfully processed the request, but is not returning any content. + case resetContent = 205 + + /// - partialContent: The server is delivering only part of the resource (byte serving) due to a range header sent by the client. + case partialContent = 206 + + /// - multiStatus: The message body that follows is an XML message and can contain a number of separate response codes, depending on how many sub-requests were made. + case multiStatus = 207 + + /// - alreadyReported: The members of a DAV binding have already been enumerated in a previous reply to this request, and are not being included again. + case alreadyReported = 208 + + /// - IMUsed: The server has fulfilled a request for the resource, and the response is a representation of the result of one or more instance-manipulations applied to the current instance. + case IMUsed = 226 + + // MARK: - Redirection - 3xx - + + /// - multipleChoices: Indicates multiple options for the resource from which the client may choose + case multipleChoices = 300 + + /// - movedPermanently: This and all future requests should be directed to the given URI. + case movedPermanently = 301 + + /// - found: The resource was found. + case found = 302 + + /// - seeOther: The response to the request can be found under another URI using a GET method. + case seeOther = 303 + + /// - notModified: Indicates that the resource has not been modified since the version specified by the request headers If-Modified-Since or If-None-Match. + case notModified = 304 + + /// - useProxy: The requested resource is available only through a proxy, the address for which is provided in the response. + case useProxy = 305 + + /// - switchProxy: No longer used. Originally meant "Subsequent requests should use the specified proxy. + case switchProxy = 306 + + /// - temporaryRedirect: The request should be repeated with another URI. + case temporaryRedirect = 307 + + /// - permenantRedirect: The request and all future requests should be repeated using another URI. + case permenantRedirect = 308 + + // MARK: - Client Error - 4xx - + + /// - badRequest: The server cannot or will not process the request due to an apparent client error. + case badRequest = 400 + + /// - unauthorized: Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet been provided. + case unauthorized = 401 + + /// - paymentRequired: The content available on the server requires payment. + case paymentRequired = 402 + + /// - forbidden: The request was a valid request, but the server is refusing to respond to it. + case forbidden = 403 + + /// - notFound: The requested resource could not be found but may be available in the future. + case notFound = 404 + + /// - methodNotAllowed: A request method is not supported for the requested resource. e.g. a GET request on a form which requires data to be presented via POST + case methodNotAllowed = 405 + + /// - notAcceptable: The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request. + case notAcceptable = 406 + + /// - proxyAuthenticationRequired: The client must first authenticate itself with the proxy. + case proxyAuthenticationRequired = 407 + + /// - requestTimeout: The server timed out waiting for the request. + case requestTimeout = 408 + + /// - conflict: Indicates that the request could not be processed because of conflict in the request, such as an edit conflict between multiple simultaneous updates. + case conflict = 409 + + /// - gone: Indicates that the resource requested is no longer available and will not be available again. + case gone = 410 + + /// - lengthRequired: The request did not specify the length of its content, which is required by the requested resource. + case lengthRequired = 411 + + /// - preconditionFailed: The server does not meet one of the preconditions that the requester put on the request. + case preconditionFailed = 412 + + /// - payloadTooLarge: The request is larger than the server is willing or able to process. + case payloadTooLarge = 413 + + /// - URITooLong: The URI provided was too long for the server to process. + case URITooLong = 414 + + /// - unsupportedMediaType: The request entity has a media type which the server or resource does not support. + case unsupportedMediaType = 415 + + /// - rangeNotSatisfiable: The client has asked for a portion of the file (byte serving), but the server cannot supply that portion. + case rangeNotSatisfiable = 416 + + /// - expectationFailed: The server cannot meet the requirements of the Expect request-header field. + case expectationFailed = 417 + + /// - teapot: This HTTP status is used as an Easter egg in some websites. + case teapot = 418 + + /// - misdirectedRequest: The request was directed at a server that is not able to produce a response. + case misdirectedRequest = 421 + + /// - unprocessableEntity: The request was well-formed but was unable to be followed due to semantic errors. + case unprocessableEntity = 422 + + /// - locked: The resource that is being accessed is locked. + case locked = 423 + + /// - failedDependency: The request failed due to failure of a previous request (e.g., a PROPPATCH). + case failedDependency = 424 + + /// - App update + case appUpgradeRequired = 425 + + /// - upgradeRequired: The client should switch to a different protocol such as TLS/1.0, given in the Upgrade header field. + case upgradeRequired = 426 + + /// - preconditionRequired: The origin server requires the request to be conditional. + case preconditionRequired = 428 + + /// - tooManyRequests: The user has sent too many requests in a given amount of time. + case tooManyRequests = 429 + + /// - requestHeaderFieldsTooLarge: The server is unwilling to process the request because either an individual header field, or all the header fields collectively, are too large. + case requestHeaderFieldsTooLarge = 431 + + /// - noResponse: Used to indicate that the server has returned no information to the client and closed the connection. + case noResponse = 444 + + /// - unavailableForLegalReasons: A server operator has received a legal demand to deny access to a resource or to a set of resources that includes the requested resource. + case unavailableForLegalReasons = 451 + + /// - SSLCertificateError: An expansion of the 400 Bad Request response code, used when the client has provided an invalid client certificate. + case SSLCertificateError = 495 + + /// - SSLCertificateRequired: An expansion of the 400 Bad Request response code, used when a client certificate is required but not provided. + case SSLCertificateRequired = 496 + + /// - HTTPRequestSentToHTTPSPort: An expansion of the 400 Bad Request response code, used when the client has made a HTTP request to a port listening for HTTPS requests. + case HTTPRequestSentToHTTPSPort = 497 + + /// - clientClosedRequest: Used when the client has closed the request before the server could send a response. + case clientClosedRequest = 499 + + // MARK: - Server Error - 5xx - + + /// - internalServerError: A generic error message, given when an unexpected condition was encountered and no more specific message is suitable. + case internalServerError = 500 + + /// - notImplemented: The server either does not recognize the request method, or it lacks the ability to fulfill the request. + case notImplemented = 501 + + /// - badGateway: The server was acting as a gateway or proxy and received an invalid response from the upstream server. + case badGateway = 502 + + /// - serviceUnavailable: The server is currently unavailable (because it is overloaded or down for maintenance). Generally, this is a temporary state. + case serviceUnavailable = 503 + + /// - gatewayTimeout: The server was acting as a gateway or proxy and did not receive a timely response from the upstream server. + case gatewayTimeout = 504 + + /// - HTTPVersionNotSupported: The server does not support the HTTP protocol version used in the request. + case HTTPVersionNotSupported = 505 + + /// - variantAlsoNegotiates: Transparent content negotiation for the request results in a circular reference. + case variantAlsoNegotiates = 506 + + /// - insufficientStorage: The server is unable to store the representation needed to complete the request. + case insufficientStorage = 507 + + /// - loopDetected: The server detected an infinite loop while processing the request. + case loopDetected = 508 + + /// - notExtended: Further extensions to the request are required for the server to fulfill it. + case notExtended = 510 + + /// - networkAuthenticationRequired: The client needs to authenticate to gain network access. + case networkAuthenticationRequired = 511 + + /// The class (or group) which the status code belongs to. + var responseType: ResponseType { + switch self.rawValue { + case 100..<200: + return .informational + case 200..<300: + return .success + case 300..<400: + return .redirection + case 400..<500: + return .clientError + case 500..<600: + return .serverError + default: + return .undefined + } + } + + var description: String? { + switch self { + case .unauthorized: + return "Not authorized" + case .notFound: + return "Not found" + case .unprocessableEntity: + return "Error processing input" + case .appUpgradeRequired: + return "Please update to the latest version of the app" + default: + return nil + } + } + +} + +extension HTTPURLResponse { + + var status: HTTPStatusCode? { + return HTTPStatusCode(rawValue: statusCode) + } + +} + diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/ClientAPI/Endpoints/Environment/GetEnvironmentRequest.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/ClientAPI/Endpoints/Environment/GetEnvironmentRequest.swift new file mode 100644 index 0000000000..744c32b657 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/ClientAPI/Endpoints/Environment/GetEnvironmentRequest.swift @@ -0,0 +1,5 @@ +struct GetEnvironmentRequest: CodableRequest { + typealias Response = EnvironmentResponse + var requestEndPoint: String { return "/api/v1/client/{environmentId}/environment" } + var requestType: HTTPMethod { return .get } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/ClientAPI/Endpoints/User/PostUserRequest.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/ClientAPI/Endpoints/User/PostUserRequest.swift new file mode 100644 index 0000000000..2ab27f7fbc --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/ClientAPI/Endpoints/User/PostUserRequest.swift @@ -0,0 +1,19 @@ +final class PostUserRequest: EncodableRequest, CodableRequest { + var requestEndPoint: String { return "/api/v1/client/{environmentId}/user" } + var requestType: HTTPMethod { return .post } + + + struct Response: Codable { + let data: UserResponseData + } + + struct Body: Codable { + let userId: String + let attributes: [String: String]? + } + + + init(userId: String, attributes: [String: String]?) { + super.init(object: Body(userId: userId, attributes: attributes)) + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Queue/UpdateQueue.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Queue/UpdateQueue.swift new file mode 100644 index 0000000000..37304348ff --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Queue/UpdateQueue.swift @@ -0,0 +1,66 @@ +/// 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. +final class UpdateQueue { + + private static var debounceInterval: TimeInterval = 0.5 + static var current = UpdateQueue() + + private let semaphore = DispatchSemaphore(value: 1) + private var userId: String? + private var attributes: [String : String]? + private var language: String? + private var timer: Timer? + + func set(userId: String) { + semaphore.wait() + self.userId = userId + startDebounceTimer() + } + + func set(attributes: [String : String]) { + semaphore.wait() + self.attributes = attributes + startDebounceTimer() + } + + func add(attribute: String, forKey key: String) { + semaphore.wait() + if var attr = self.attributes { + attr[key] = attribute + self.attributes = attr + } else { + self.attributes = [key: attribute] + } + startDebounceTimer() + } + + func set(language: String) { + semaphore.wait() + add(attribute: "language", forKey: language) + startDebounceTimer() + } + + func reset() { + userId = nil + attributes = nil + language = nil + } +} + +private extension UpdateQueue { + func startDebounceTimer() { + timer?.invalidate() + timer = Timer.scheduledTimer(timeInterval: UpdateQueue.debounceInterval, target: self, selector: #selector(commit), userInfo: nil, repeats: false) + semaphore.signal() + } + + @objc func commit() { + guard let userId = userId else { + Formbricks.logger.error(FormbricksSDKError(type: .userIdIsNotSetYet).message) + return + } + + Formbricks.logger.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(attributes ?? [:])") + UserManager.shared.syncUser(withId: userId, attributes: attributes) + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Service/FormbricksService.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Service/FormbricksService.swift new file mode 100644 index 0000000000..743863678a --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Service/FormbricksService.swift @@ -0,0 +1,25 @@ +/// FormbricksService is a service class that handles the network requests for Formbricks API. +final class FormbricksService { + + // MARK: - Environment - + /// Get the current environment state. + func getEnvironmentState(completion: @escaping (ResultType) -> Void) { + let endPointRequest = GetEnvironmentRequest() + execute(endPointRequest, withCompletion: completion) + } + + // MARK: - User - + /// Logs in a user with the given ID or creates one if it doesn't exist. + func postUser(id: String, attributes: [String: String]?, completion: @escaping (ResultType) -> Void) { + let endPointRequest = PostUserRequest(userId: id, attributes: attributes) + execute(endPointRequest, withCompletion: completion) + } +} + +private extension FormbricksService { + /// Creates the APIClient operation and adds it to the queue + func execute(_ request: Request, withCompletion completion: @escaping (ResultType) -> Void) { + let operation = APIClient(request: request, completion: completion) + Formbricks.apiQueue.addOperation(operation) + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/WebView/FormbricksView.swift b/packages/ios/FormbricksSDK/FormbricksSDK/WebView/FormbricksView.swift new file mode 100644 index 0000000000..c1ee78c59f --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/WebView/FormbricksView.swift @@ -0,0 +1,12 @@ +import SwiftUI + +/// SwiftUI view for the Formbricks survey webview. +struct FormbricksView: View { + @ObservedObject var viewModel: FormbricksViewModel + + var body: some View { + if let htmlString = viewModel.htmlString { + SurveyWebView(surveyId: viewModel.surveyId, htmlString: htmlString) + } + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/WebView/FormbricksViewModel.swift b/packages/ios/FormbricksSDK/FormbricksSDK/WebView/FormbricksViewModel.swift new file mode 100644 index 0000000000..6b3a1a7fd8 --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/WebView/FormbricksViewModel.swift @@ -0,0 +1,115 @@ +import SwiftUI + +/// A view model for the Formbricks WebView. +/// It generates the HTML string with the necessary data to render the survey. +final class FormbricksViewModel: ObservableObject { + @Published var htmlString: String? + let surveyId: String + + init(environmentResponse: EnvironmentResponse, surveyId: String) { + self.surveyId = surveyId + if let webviewDataJson = WebViewData(environmentResponse: environmentResponse, surveyId: surveyId).getJsonString() { + htmlString = htmlTemplate.replacingOccurrences(of: "{{WEBVIEW_DATA}}", with: webviewDataJson) + } + } +} + +private extension FormbricksViewModel { + /// The HTML template to render the Formbricks WebView. + var htmlTemplate: String { + return """ + + + + + + Formbricks WebView Survey + + + + +
+ + + + + """ + } + +} + +// MARK: - Helper class - +private class WebViewData { + var data: [String: Any] = [:] + + init(environmentResponse: EnvironmentResponse, surveyId: String) { + data["survey"] = environmentResponse.getSurveyJson(forSurveyId: surveyId) + data["isBrandingEnabled"] = true + data["languageCode"] = Formbricks.language + data["apiHost"] = Formbricks.appUrl + data["environmentId"] = Formbricks.environmentId + data["userId"] = UserManager.shared.userId + + let hasCustomStyling = environmentResponse.data.data.surveys?.first(where: { $0.id == surveyId })?.styling != nil + let enabled = environmentResponse.data.data.project.styling?.allowStyleOverwrite ?? false + + data["styling"] = hasCustomStyling && enabled ? environmentResponse.getSurveyStylingJson(forSurveyId: surveyId): environmentResponse.getProjectStylingJson() + } + + func getJsonString() -> String? { + do { + let jsonData = try JSONSerialization.data(withJSONObject: data, options: []) + return String(data: jsonData, encoding: .utf8)?.replacingOccurrences(of: "\\\"", with: "'") + } catch { + Formbricks.logger.error(error.message) + return nil + } + } + +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/WebView/SurveyWebView.swift b/packages/ios/FormbricksSDK/FormbricksSDK/WebView/SurveyWebView.swift new file mode 100644 index 0000000000..d96db152cf --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDK/WebView/SurveyWebView.swift @@ -0,0 +1,147 @@ +import SwiftUI +@preconcurrency import WebKit +import JavaScriptCore +import SafariServices + +/// SwiftUI wrapper for the WKWebView to display a survey. +struct SurveyWebView: UIViewRepresentable { + let surveyId: String + let htmlString: String + + /// Assemble the WKWebView with the necessary configuration. + public func makeUIView(context: Context) -> WKWebView { + clean() + + // Add javascript message handlers + let userContentController = WKUserContentController() + userContentController.add(LoggingMessageHandler(), name: "logging") + userContentController.add(JsMessageHandler(surveyId: surveyId), name: "jsMessage") + userContentController.addUserScript(WKUserScript(source: overrideConsole, injectionTime: .atDocumentStart, forMainFrameOnly: true)) + + let webViewConfig = WKWebViewConfiguration() + webViewConfig.userContentController = userContentController + + let webView = WKWebView(frame: .zero, configuration: webViewConfig) + webView.configuration.defaultWebpagePreferences.allowsContentJavaScript = true + webView.isOpaque = false + webView.backgroundColor = UIColor.clear + webView.isInspectable = true + webView.uiDelegate = context.coordinator + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + webView.loadHTMLString(htmlString, baseURL: nil) + } + + func makeCoordinator() -> Coordinator { + return Coordinator() + } + + + /// Clean up cookies and website data. + func clean() { + HTTPCookieStorage.shared.removeCookies(since: Date.distantPast) + WKWebsiteDataStore.default().fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in + records.forEach { record in + WKWebsiteDataStore.default().removeData(ofTypes: record.dataTypes, for: [record], completionHandler: {}) + } + } + } +} + +extension SurveyWebView { + class Coordinator: NSObject, WKUIDelegate { + // webView function handles Javascipt alert + func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) { + let alertController = UIAlertController(title: "", message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: "OK", style: .default) { _ in }) + UIApplication.safeKeyWindow?.rootViewController?.presentedViewController?.present(alertController, animated: true) + completionHandler() + } + } +} + +// MARK: - Javascript --> Native message handler - +/// Handle messages coming from the Javascript in the WebView. +final class JsMessageHandler: NSObject, WKScriptMessageHandler { + + let surveyId: String + + init(surveyId: String) { + self.surveyId = surveyId + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + Formbricks.logger.debug(message.body) + + if let body = message.body as? String, let data = body.data(using: .utf8), let obj = try? JSONDecoder().decode(JsMessageData.self, from: data) { + + switch obj.event { + /// Happens when the user submits an answer. + case .onResponseCreated: + SurveyManager.shared.postResponse(surveyId: surveyId) + + /// Happens when a survey is shown. + case .onDisplayCreated: + SurveyManager.shared.onNewDisplay(surveyId: surveyId) + + /// Happens when the user closes the survey view with the close button. + case .onClose: + SurveyManager.shared.dismissSurveyWebView() + + /// Happens when the survey view is finished by the user submitting the last question. + case .onFinished: + SurveyManager.shared.delayedDismiss() + + /// Happens when the survey wants to open an external link in the default browser. + case .onOpenExternalURL: + if let message = try? JSONDecoder().decode(OpenExternalUrlMessage.self, from: data), let url = URL(string: message.onOpenExternalURLParams.url) { + UIApplication.shared.open(url) + } + } + + } else { + Formbricks.logger.error("\(FormbricksSDKError(type: .invalidJavascriptMessage).message): \(message.body)") + } + } +} + +// MARK: - Handle Javascript console.log - +/// Handle and send console.log messages from the Javascript to the local logger. +final class LoggingMessageHandler: NSObject, WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + Formbricks.logger.debug(message.body) + } +} + +private extension SurveyWebView { + // https://stackoverflow.com/a/61489361 + var overrideConsole: String { + return + """ + function log(emoji, type, args) { + window.webkit.messageHandlers.logging.postMessage( + `${emoji} JS ${type}: ${Object.values(args) + .map(v => typeof(v) === "undefined" ? "undefined" : typeof(v) === "object" ? JSON.stringify(v) : v.toString()) + .map(v => v.substring(0, 3000)) // Limit msg to 3000 chars + .join(", ")}` + ) + } + + let originalLog = console.log + let originalWarn = console.warn + let originalError = console.error + let originalDebug = console.debug + + console.log = function() { log("📗", "log", arguments); originalLog.apply(null, arguments) } + console.warn = function() { log("📙", "warning", arguments); originalWarn.apply(null, arguments) } + console.error = function() { log("📕", "error", arguments); originalError.apply(null, arguments) } + console.debug = function() { log("📘", "debug", arguments); originalDebug.apply(null, arguments) } + + window.addEventListener("error", function(e) { + log("💥", "Uncaught", [`${e.message} at ${e.filename}:${e.lineno}:${e.colno}`]) + }) + """ + } +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDKTests/FormbricksSDKTests.swift b/packages/ios/FormbricksSDK/FormbricksSDKTests/FormbricksSDKTests.swift new file mode 100644 index 0000000000..95de204a5c --- /dev/null +++ b/packages/ios/FormbricksSDK/FormbricksSDKTests/FormbricksSDKTests.swift @@ -0,0 +1,36 @@ +// +// FormbricksSDKTests.swift +// FormbricksSDKTests +// +// Created by Peter Pesti-Varga on 2025. 02. 03.. +// + +import XCTest +@testable import FormbricksSDK + +final class FormbricksSDKTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +}