diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx
index 18a0b6737e..dfa4db8c75 100644
--- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx
@@ -22,7 +22,7 @@ export const OrganizationSettingsNavbar = ({
loading,
}: OrganizationSettingsNavbarProps) => {
const pathname = usePathname();
- const { isMember } = getAccessFlags(membershipRole);
+ const { isMember, isOwner } = getAccessFlags(membershipRole);
const isPricingDisabled = isMember;
const { t } = useTranslate();
@@ -59,6 +59,7 @@ export const OrganizationSettingsNavbar = ({
label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/api-keys`,
current: pathname?.includes("/api-keys"),
+ hidden: !isOwner,
},
];
diff --git a/apps/web/modules/organization/settings/api-keys/actions.ts b/apps/web/modules/organization/settings/api-keys/actions.ts
index 0969fd9057..8ea5b388b6 100644
--- a/apps/web/modules/organization/settings/api-keys/actions.ts
+++ b/apps/web/modules/organization/settings/api-keys/actions.ts
@@ -25,7 +25,7 @@ export const deleteApiKeyAction = authenticatedActionClient
access: [
{
type: "organization",
- roles: ["owner", "manager"],
+ roles: ["owner"],
},
],
});
@@ -47,7 +47,7 @@ export const createApiKeyAction = authenticatedActionClient
access: [
{
type: "organization",
- roles: ["owner", "manager"],
+ roles: ["owner"],
},
],
});
@@ -69,7 +69,7 @@ export const updateApiKeyAction = authenticatedActionClient
access: [
{
type: "organization",
- roles: ["owner", "manager"],
+ roles: ["owner"],
},
],
});
diff --git a/apps/web/modules/organization/settings/api-keys/page.tsx b/apps/web/modules/organization/settings/api-keys/page.tsx
index ddcfae4a89..6c0871a764 100644
--- a/apps/web/modules/organization/settings/api-keys/page.tsx
+++ b/apps/web/modules/organization/settings/api-keys/page.tsx
@@ -2,7 +2,6 @@ import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmen
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectsByOrganizationId } from "@/modules/organization/settings/api-keys/lib/projects";
-import { Alert } from "@/modules/ui/components/alert";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
@@ -19,7 +18,9 @@ export const APIKeysPage = async (props) => {
const projects = await getProjectsByOrganizationId(organization.id);
- const isReadOnly = currentUserMembership.role !== "owner" && currentUserMembership.role !== "manager";
+ const isNotOwner = currentUserMembership.role !== "owner";
+
+ if (isNotOwner) throw new Error(t("common.not_authorized"));
return (
@@ -31,22 +32,16 @@ export const APIKeysPage = async (props) => {
activeId="api-keys"
/>
- {isReadOnly ? (
-
- {t("environments.settings.api_keys.only_organization_owners_and_managers_can_manage_api_keys")}
-
- ) : (
-
-
-
- )}
+
+
+
);
};
diff --git a/packages/ios/Demo/Demo/AppDelegate.swift b/packages/ios/Demo/Demo/AppDelegate.swift
index 256bb2deac..b6867d3666 100644
--- a/packages/ios/Demo/Demo/AppDelegate.swift
+++ b/packages/ios/Demo/Demo/AppDelegate.swift
@@ -3,18 +3,8 @@ import FormbricksSDK
class AppDelegate: NSObject, UIApplicationDelegate {
- func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
- let config = FormbricksConfig.Builder(appUrl: "[appUrl]", environmentId: "[environmentId]")
- .setLogLevel(.debug)
- .build()
-
- Formbricks.setup(with: config)
-
-
- Formbricks.logout()
- Formbricks.setUserId(UUID().uuidString)
-
+ func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
return true
}
-
}
diff --git a/packages/ios/Demo/Demo/ContentView.swift b/packages/ios/Demo/Demo/ContentView.swift
deleted file mode 100644
index c99bc69ea5..0000000000
--- a/packages/ios/Demo/Demo/ContentView.swift
+++ /dev/null
@@ -1,24 +0,0 @@
-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
index 17c21302e9..9603805451 100644
--- a/packages/ios/Demo/Demo/DemoApp.swift
+++ b/packages/ios/Demo/Demo/DemoApp.swift
@@ -7,7 +7,7 @@ struct DemoApp: App {
var body: some Scene {
WindowGroup {
- ContentView()
+ SetupView()
}
}
}
diff --git a/packages/ios/Demo/Demo/SetupView.swift b/packages/ios/Demo/Demo/SetupView.swift
new file mode 100644
index 0000000000..e2eac11b4f
--- /dev/null
+++ b/packages/ios/Demo/Demo/SetupView.swift
@@ -0,0 +1,72 @@
+import SwiftUI
+import FormbricksSDK
+
+struct SetupView: View {
+ @State private var isSetup = false
+ @State private var isLoading = false
+
+ var body: some View {
+ VStack(spacing: 20) {
+ if !isSetup {
+ if isLoading {
+ ProgressView("Setting up...")
+ .padding()
+ } else {
+ Button("Setup Formbricks SDK") {
+ isLoading = true
+ let config = FormbricksConfig.Builder(appUrl: "[appUrl]", environmentId: "[environmentId]")
+ .setLogLevel(.debug)
+ .build()
+ // Simulate async setup delay
+ DispatchQueue.global().async {
+ Formbricks.setup(with: config)
+ Formbricks.setUserId(UUID().uuidString)
+
+ DispatchQueue.main.async {
+ isSetup = true
+ isLoading = false
+ }
+ }
+ }
+ .padding()
+ }
+ } else {
+ Button("Call Formbricks.track") {
+ Formbricks.track("click_demo_button")
+ }
+ .padding()
+
+ Button("Call Formbricks.setAttribute") {
+ Formbricks.setAttribute("test@example.com", forKey: "user_email")
+ }
+ .padding()
+
+ Button("Call Formbricks.setAttributes") {
+ Formbricks.setAttributes(["user_name": "John Doe", "user_age": "30"])
+ }
+ .padding()
+
+ Button("Call Formbricks.setLanguage") {
+ Formbricks.setLanguage("vi")
+ }.padding()
+
+ Button("Call Formbricks.logout") {
+ Formbricks.logout()
+ }.padding()
+
+ Button("Call Formbricks.cleanup") {
+ Formbricks.cleanup(waitForOperations: true) {
+ print(">>> Cleanup complete")
+ isSetup = false
+ }
+ }
+ .padding()
+ }
+ }
+ .navigationTitle("Setup SDK")
+ }
+}
+
+#Preview {
+ SetupView()
+}
diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Formbricks.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Formbricks.swift
index 25ca22d67d..c5cca4bdd3 100644
--- a/packages/ios/FormbricksSDK/FormbricksSDK/Formbricks.swift
+++ b/packages/ios/FormbricksSDK/FormbricksSDK/Formbricks.swift
@@ -9,9 +9,12 @@ import Network
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()
+ static internal var userManager: UserManager?
+ static internal var presentSurveyManager: PresentSurveyManager?
+ static internal var surveyManager: SurveyManager?
+ static internal var apiQueue: OperationQueue? = OperationQueue()
+ static internal var logger: Logger?
+ static internal var service = FormbricksService()
// make this class not instantiatable outside of the SDK
internal override init() {
@@ -36,30 +39,39 @@ import Network
Formbricks.setup(with: config)
```
*/
- @objc public static func setup(with config: FormbricksConfig) {
+ @objc public static func setup(with config: FormbricksConfig, force: Bool = false) {
+ logger = Logger()
+
+ if (force == true) {
+ isInitialized = false
+ }
guard !isInitialized else {
- Formbricks.logger.error(FormbricksSDKError(type: .sdkIsAlreadyInitialized).message)
+ Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsAlreadyInitialized).message)
return
}
self.appUrl = config.appUrl
self.environmentId = config.environmentId
- self.logger.logLevel = config.logLevel
+ self.logger?.logLevel = config.logLevel
+ userManager = UserManager()
if let userId = config.userId {
- UserManager.shared.set(userId: userId)
+ userManager?.set(userId: userId)
}
if let attributes = config.attributes {
- UserManager.shared.set(attributes: attributes)
+ userManager?.set(attributes: attributes)
}
if let language = config.attributes?["language"] {
- UserManager.shared.set(language: language)
+ userManager?.set(language: language)
self.language = language
}
+
+ presentSurveyManager = PresentSurveyManager()
+ surveyManager = SurveyManager.create(userManager: userManager!, presentSurveyManager: presentSurveyManager!)
+ userManager?.surveyManager = surveyManager
- SurveyManager.shared.refreshEnvironmentIfNeeded()
- UserManager.shared.syncUserStateIfNeeded()
-
+ surveyManager?.refreshEnvironmentIfNeeded(force: force)
+ userManager?.syncUserStateIfNeeded()
self.isInitialized = true
}
@@ -75,11 +87,11 @@ import Network
*/
@objc public static func setUserId(_ userId: String) {
guard Formbricks.isInitialized else {
- Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
+ Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
- UserManager.shared.set(userId: userId)
+ userManager?.set(userId: userId)
}
/**
@@ -93,11 +105,11 @@ import Network
*/
@objc public static func setAttribute(_ attribute: String, forKey key: String) {
guard Formbricks.isInitialized else {
- Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
+ Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
- UserManager.shared.add(attribute: attribute, forKey: key)
+ userManager?.add(attribute: attribute, forKey: key)
}
/**
@@ -111,11 +123,11 @@ import Network
*/
@objc public static func setAttributes(_ attributes: [String : String]) {
guard Formbricks.isInitialized else {
- Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
+ Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
- UserManager.shared.set(attributes: attributes)
+ userManager?.set(attributes: attributes)
}
/**
@@ -129,12 +141,16 @@ import Network
*/
@objc public static func setLanguage(_ language: String) {
guard Formbricks.isInitialized else {
- Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
+ Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
+ return
+ }
+
+ if (Formbricks.language == language) {
return
}
Formbricks.language = language
- UserManager.shared.set(language: language)
+ userManager?.set(language: language)
}
/**
@@ -148,15 +164,15 @@ import Network
*/
@objc public static func track(_ action: String) {
guard Formbricks.isInitialized else {
- Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
+ Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
Formbricks.isInternetAvailabile { available in
if available {
- SurveyManager.shared.track(action)
+ surveyManager?.track(action)
} else {
- Formbricks.logger.warning(FormbricksSDKError.init(type: .networkError).message)
+ Formbricks.logger?.warning(FormbricksSDKError.init(type: .networkError).message)
}
}
@@ -173,11 +189,58 @@ import Network
*/
@objc public static func logout() {
guard Formbricks.isInitialized else {
- Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
+ Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
- UserManager.shared.logout()
+ userManager?.logout()
+ }
+
+ /**
+ Cleans up the SDK. This will clear the user attributes, the user id and the environment state.
+ The SDK must be initialized before calling this method.
+ If `waitForOperations` is set to `true`, it will wait for all operations to finish before cleaning up.
+ If `waitForOperations` is set to `false`, it will clean up immediately.
+ You can also provide a completion block that will be called when the cleanup is finished.
+
+ Example:
+ ```swift
+ Formbricks.cleanup()
+
+ Formbricks.cleanup(waitForOperations: true) {
+ // Cleanup completed
+ }
+ ```
+ */
+
+ @objc public static func cleanup(waitForOperations: Bool = false, completion: (() -> Void)? = nil) {
+ if waitForOperations, let queue = apiQueue {
+ DispatchQueue.global(qos: .background).async {
+ queue.waitUntilAllOperationsAreFinished()
+ performCleanup()
+ DispatchQueue.main.async {
+ completion?()
+ }
+ }
+ } else {
+ apiQueue?.cancelAllOperations()
+ performCleanup()
+ completion?()
+ }
+ }
+
+ private static func performCleanup() {
+ userManager?.cleanupUpdateQueue()
+ presentSurveyManager?.dismissView()
+ presentSurveyManager = nil
+ userManager = nil
+ surveyManager = nil
+ apiQueue = nil
+ isInitialized = false
+ appUrl = nil
+ environmentId = nil
+ logger = nil
+ language = "default"
}
}
diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Logger/Logger.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Logger/Logger.swift
index 63eae7a156..fbc54eb889 100644
--- a/packages/ios/FormbricksSDK/FormbricksSDK/Logger/Logger.swift
+++ b/packages/ios/FormbricksSDK/FormbricksSDK/Logger/Logger.swift
@@ -52,8 +52,8 @@ private extension Logger {
logString.append(messageListString)
}
if logLevel == .error || logLevel.rawValue >= self.logLevel.rawValue {
- DispatchQueue.main.async {
- let str = logString + "\(self.emoji)\n"
+ DispatchQueue.main.async { [weak self] in
+ let str = logString + "\(self?.emoji ?? "")\n"
print(str)
}
}
diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Manager/PresentSurveyManager.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/PresentSurveyManager.swift
index 815cc16e77..3cd731f9fc 100644
--- a/packages/ios/FormbricksSDK/FormbricksSDK/Manager/PresentSurveyManager.swift
+++ b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/PresentSurveyManager.swift
@@ -2,9 +2,8 @@ import SwiftUI
/// Presents a survey webview to the window's root
final class PresentSurveyManager {
- static let shared = PresentSurveyManager()
- private init() {
- /*
+ init() {
+ /*
This empty initializer prevents external instantiation of the PresentSurveyManager class.
The class serves as a namespace for the present method, so instance creation is not needed and should be restricted.
*/
@@ -15,9 +14,10 @@ final class PresentSurveyManager {
/// Present the webview
func present(environmentResponse: EnvironmentResponse, id: String) {
- DispatchQueue.main.async {
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
if let window = UIApplication.safeKeyWindow {
- let view = FormbricksView(viewModel: FormbricksViewModel(environmentResponse: environmentResponse, surveyId: id))
+ 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)
@@ -34,4 +34,8 @@ final class PresentSurveyManager {
func dismissView() {
viewController?.dismiss(animated: true)
}
+
+ deinit {
+ Formbricks.logger?.debug("Deinitializing \(self)")
+ }
}
diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Manager/SurveyManager.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/SurveyManager.swift
index e7e7084b1b..04d5a1de4a 100644
--- a/packages/ios/FormbricksSDK/FormbricksSDK/Manager/SurveyManager.swift
+++ b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/SurveyManager.swift
@@ -3,12 +3,16 @@ 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() {
- /*
- This empty initializer prevents external instantiation of the SurveyManager class.
- The class serves as a namespace for the shared instance, so instance creation is not needed and should be restricted.
- */
+ private let userManager: UserManager
+ private let presentSurveyManager: PresentSurveyManager
+
+ private init(userManager: UserManager, presentSurveyManager: PresentSurveyManager) {
+ self.userManager = userManager
+ self.presentSurveyManager = presentSurveyManager
+ }
+
+ static func create(userManager: UserManager, presentSurveyManager: PresentSurveyManager) -> SurveyManager {
+ return SurveyManager(userManager: userManager, presentSurveyManager: presentSurveyManager)
}
private static let environmentResponseObjectKey = "environmentResponseObjectKey"
@@ -26,15 +30,15 @@ final class SurveyManager {
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 ?? []
+ let displays = userManager.displays ?? []
+ let responses = userManager.responses ?? []
+ let segments = userManager.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 userManager.userId != nil {
if segments.isEmpty {
filteredSurveys = []
return
@@ -48,22 +52,23 @@ final class SurveyManager {
/// 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)
+ DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in
+ self?.showSurvey(withId: surveyId)
}
}
}
@@ -74,7 +79,7 @@ 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)")
+ Formbricks.logger?.debug("Environment state is still valid until \(environmentResponse.data.expiresAt)")
filterSurveys()
return
}
@@ -88,7 +93,7 @@ extension SurveyManager {
self?.filterSurveys()
case .failure:
self?.hasApiError = true
- Formbricks.logger.error(FormbricksSDKError(type: .unableToRefreshEnvironment).message)
+ Formbricks.logger?.error(FormbricksSDKError(type: .unableToRefreshEnvironment).message)
self?.startErrorTimer()
}
}
@@ -96,12 +101,12 @@ extension SurveyManager {
/// Posts a survey response to the Formbricks API.
func postResponse(surveyId: String) {
- UserManager.shared.onResponse(surveyId: surveyId)
+ userManager.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)
+ userManager.onDisplay(surveyId: surveyId)
}
}
@@ -110,7 +115,7 @@ extension SurveyManager {
/// Dismisses the presented survey window.
func dismissSurveyWebView() {
isShowingSurvey = false
- PresentSurveyManager.shared.dismissView()
+ presentSurveyManager.dismissView()
}
}
@@ -120,7 +125,7 @@ private extension SurveyManager {
/// The view controller is presented over the current context.
func showSurvey(withId id: String) {
if let environmentResponse = environmentResponse {
- PresentSurveyManager.shared.present(environmentResponse: environmentResponse, id: id)
+ presentSurveyManager.present(environmentResponse: environmentResponse, id: id)
}
}
@@ -142,9 +147,9 @@ private extension SurveyManager {
return
}
- DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
- Formbricks.logger.debug("Refreshing environment state.")
- self.refreshEnvironmentIfNeeded(force: true)
+ DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in
+ Formbricks.logger?.debug("Refreshing environment state.")
+ self?.refreshEnvironmentIfNeeded(force: true)
}
}
@@ -166,7 +171,7 @@ extension SurveyManager {
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)
+ Formbricks.logger?.error(FormbricksSDKError(type: .unableToRetrieveEnvironment).message)
return nil
}
}
@@ -175,7 +180,7 @@ extension SurveyManager {
UserDefaults.standard.set(data, forKey: SurveyManager.environmentResponseObjectKey)
backingEnvironmentResponse = newValue
} else {
- Formbricks.logger.error(FormbricksSDKError(type: .unableToPersistEnvironment).message)
+ Formbricks.logger?.error(FormbricksSDKError(type: .unableToPersistEnvironment).message)
}
}
}
@@ -207,7 +212,7 @@ private extension SurveyManager {
}
default:
- Formbricks.logger.error(FormbricksSDKError(type: .invalidDisplayOption).message)
+ Formbricks.logger?.error(FormbricksSDKError(type: .invalidDisplayOption).message)
return false
}
@@ -218,7 +223,7 @@ private extension SurveyManager {
/// 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 }
+ guard let lastDisplayedAt = userManager.lastDisplayedAt else { return true }
let recontactDays = survey.recontactDays ?? defaultRecontactDays
if let recontactDays = recontactDays {
diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Manager/UserManager.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/UserManager.swift
index 431885243f..08f0a344b5 100644
--- a/packages/ios/FormbricksSDK/FormbricksSDK/Manager/UserManager.swift
+++ b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/UserManager.swift
@@ -1,13 +1,11 @@
import Foundation
/// Store and manage user state and sync with the server when needed.
-final class UserManager {
- static let shared = UserManager()
- private init() {
- /*
- This empty initializer prevents external instantiation of the UserManager class.
- The class serves as a namespace for the user state, so instance creation is not needed and should be restricted.
- */
+final class UserManager: UserManagerSyncable {
+ weak var surveyManager: SurveyManager?
+
+ init(surveyManager: SurveyManager? = nil) {
+ self.surveyManager = surveyManager
}
private static let userIdKey = "userIdKey"
@@ -28,26 +26,30 @@ final class UserManager {
private var backingLastDisplayedAt: Date?
private var backingExpiresAt: Date?
+ lazy private var updateQueue: UpdateQueue? = {
+ return UpdateQueue(userManager: self)
+ }()
+
internal var syncTimer: Timer?
/// Starts an update queue with the given user id.
func set(userId: String) {
- UpdateQueue.current.set(userId: userId)
+ updateQueue?.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)
+ updateQueue?.add(attribute: attribute, forKey: key)
}
/// Starts an update queue with the given attributes.
func set(attributes: [String: String]) {
- UpdateQueue.current.set(attributes: attributes)
+ updateQueue?.set(attributes: attributes)
}
/// Starts an update queue with the given language..
func set(language: String) {
- UpdateQueue.current.set(language: language)
+ updateQueue?.set(language: language)
}
/// Saves `surveyId` to the `displays` property and the current date to the `lastDisplayedAt` property.
@@ -57,7 +59,7 @@ final class UserManager {
newDisplays.append(Display(surveyId: surveyId, createdAt: DateFormatter.isoFormatter.string(from: lastDisplayedAt)))
displays = newDisplays
self.lastDisplayedAt = lastDisplayedAt
- SurveyManager.shared.filterSurveys()
+ surveyManager?.filterSurveys()
}
/// Saves `surveyId` to the `responses` property.
@@ -65,7 +67,7 @@ final class UserManager {
var newResponses = responses ?? []
newResponses.append(surveyId)
responses = newResponses
- SurveyManager.shared.filterSurveys()
+ surveyManager?.filterSurveys()
}
/// Syncs the user state with the server if the user id is set and the expiration date has passed.
@@ -80,7 +82,7 @@ final class UserManager {
syncUser(withId: id)
}
- /// Syncs the user state with the server, calls the `SurveyManager.shared.filterSurveys()` method and starts the sync timer.
+ /// Syncs the user state with the server, calls the `self?.surveyManager?.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 {
@@ -92,11 +94,12 @@ final class UserManager {
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?.updateQueue?.reset()
+ self?.surveyManager?.filterSurveys()
self?.startSyncTimer()
case .failure(let error):
- Formbricks.logger.error(error)
+ Formbricks.logger?.error(error)
}
}
}
@@ -104,18 +107,31 @@ final class UserManager {
/// Logs out the user and clears the user state.
func logout() {
UserDefaults.standard.removeObject(forKey: UserManager.userIdKey)
+ UserDefaults.standard.removeObject(forKey: UserManager.contactIdKey)
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
+ backingContactId = nil
backingSegments = nil
backingDisplays = nil
backingResponses = nil
backingLastDisplayedAt = nil
backingExpiresAt = nil
- UpdateQueue.current.reset()
+ updateQueue?.reset()
+
+ Formbricks.logger?.debug("Successfully logged out user and reset the user state.")
+ }
+
+ func cleanupUpdateQueue() {
+ updateQueue?.cleanup()
+ updateQueue = nil // Release the instance so memory can be reclaimed.
+ }
+
+ deinit {
+ Formbricks.logger?.debug("Deinitializing \(self)")
}
}
diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Queue/UpdateQueue.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Queue/UpdateQueue.swift
index 60fba47e1b..5d1ea197cb 100644
--- a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Queue/UpdateQueue.swift
+++ b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Queue/UpdateQueue.swift
@@ -1,68 +1,122 @@
import Foundation
+protocol UserManagerSyncable: AnyObject {
+ func syncUser(withId id: String, attributes: [String: String]?)
+}
+
/// 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 let syncQueue = DispatchQueue(label: "com.formbricks.updateQueue")
private var userId: String?
private var attributes: [String : String]?
private var language: String?
private var timer: Timer?
+ private weak var userManager: UserManagerSyncable?
+
+ init(userManager: UserManagerSyncable) {
+ self.userManager = userManager
+ }
+
func set(userId: String) {
- semaphore.wait()
- self.userId = userId
- startDebounceTimer()
+ syncQueue.sync {
+ self.userId = userId
+ startDebounceTimer()
+ }
}
func set(attributes: [String : String]) {
- semaphore.wait()
- self.attributes = attributes
- startDebounceTimer()
+ syncQueue.sync {
+ 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()
+ syncQueue.sync {
+ 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()
+ syncQueue.sync {
+ self.language = language
+
+ // Check if we have an effective userId
+ let effectiveUserId = self.userId ?? Formbricks.userManager?.userId
+
+ if effectiveUserId != nil {
+ // If we have a userId, set attributes
+ self.attributes = ["language": language]
+ } else {
+ // If no userId, just update locally without API call
+ Formbricks.logger?.debug("UpdateQueue - updating language locally: \(language)")
+ }
+
+ startDebounceTimer()
+ }
}
func reset() {
- userId = nil
- attributes = nil
- language = nil
+ syncQueue.sync {
+ userId = nil
+ attributes = nil
+ language = nil
+ }
+ }
+
+ deinit {
+ Formbricks.logger?.debug("Deinitializing \(self)")
}
}
private extension UpdateQueue {
func startDebounceTimer() {
timer?.invalidate()
- timer = Timer.scheduledTimer(timeInterval: UpdateQueue.debounceInterval, target: self, selector: #selector(commit), userInfo: nil, repeats: false)
- semaphore.signal()
+ timer = nil
+
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ self.timer = Timer.scheduledTimer(timeInterval: UpdateQueue.debounceInterval,
+ target: self,
+ selector: #selector(self.commit),
+ userInfo: nil,
+ repeats: false)
+ }
}
@objc func commit() {
- guard let userId = userId else {
- Formbricks.logger.error(FormbricksSDKError(type: .userIdIsNotSetYet).message)
+ let effectiveUserId: String? = self.userId ?? Formbricks.userManager?.userId ?? nil
+
+ guard let userId = effectiveUserId 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)
+ Formbricks.logger?.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(attributes ?? [:])")
+ userManager?.syncUser(withId: userId, attributes: attributes)
+ }
+}
+
+// Add a function to to stop the timer for cleanup
+extension UpdateQueue {
+ func cleanup() {
+ syncQueue.sync {
+ timer?.invalidate()
+ timer = nil
+ userId = nil
+ attributes = nil
+ language = nil
+ }
}
}
diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Service/FormbricksService.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Service/FormbricksService.swift
index bf8602d12e..338485f8f6 100644
--- a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Service/FormbricksService.swift
+++ b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Service/FormbricksService.swift
@@ -20,6 +20,6 @@ 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)
+ Formbricks.apiQueue?.addOperation(operation)
}
}
diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/WebView/FormbricksViewModel.swift b/packages/ios/FormbricksSDK/FormbricksSDK/WebView/FormbricksViewModel.swift
index cddcae7b59..7127c3b948 100644
--- a/packages/ios/FormbricksSDK/FormbricksSDK/WebView/FormbricksViewModel.swift
+++ b/packages/ios/FormbricksSDK/FormbricksSDK/WebView/FormbricksViewModel.swift
@@ -64,7 +64,7 @@ private extension FormbricksViewModel {
}
const script = document.createElement("script");
- script.src = "\(FormbricksEnvironment.surveyScriptUrl)";
+ script.src = "\(Formbricks.appUrl ?? "http://localhost:3000")/js/surveys.umd.cjs";
script.async = true;
script.onload = () => loadSurvey();
script.onerror = (error) => {
@@ -89,7 +89,7 @@ private class WebViewData {
data["languageCode"] = Formbricks.language
data["appUrl"] = Formbricks.appUrl
data["environmentId"] = Formbricks.environmentId
- data["contactId"] = UserManager.shared.contactId
+ data["contactId"] = Formbricks.userManager?.contactId
data["isWebEnvironment"] = false
let hasCustomStyling = environmentResponse.data.data.surveys?.first(where: { $0.id == surveyId })?.styling != nil
@@ -103,7 +103,7 @@ private class WebViewData {
let jsonData = try JSONSerialization.data(withJSONObject: data, options: [])
return String(data: jsonData, encoding: .utf8)?.replacingOccurrences(of: "\\\"", with: "'")
} catch {
- Formbricks.logger.error(error.message)
+ 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
index d560189409..7865da02c8 100644
--- a/packages/ios/FormbricksSDK/FormbricksSDK/WebView/SurveyWebView.swift
+++ b/packages/ios/FormbricksSDK/FormbricksSDK/WebView/SurveyWebView.swift
@@ -39,6 +39,16 @@ struct SurveyWebView: UIViewRepresentable {
return Coordinator()
}
+ /// Called automatically by SwiftUI when the view is torn down.
+ static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
+ let userContentController = uiView.configuration.userContentController
+ userContentController.removeScriptMessageHandler(forName: "logging")
+ userContentController.removeScriptMessageHandler(forName: "jsMessage")
+
+ uiView.navigationDelegate = nil
+ uiView.uiDelegate = nil
+ Formbricks.logger?.debug("SurveyWebView: Dismantled")
+ }
/// Clean up cookies and website data.
func clean() {
@@ -98,22 +108,22 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
- Formbricks.logger.debug(message.body)
+ 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)
+ Formbricks.surveyManager?.postResponse(surveyId: surveyId)
/// Happens when a survey is shown.
case .onDisplayCreated:
- SurveyManager.shared.onNewDisplay(surveyId: surveyId)
+ Formbricks.surveyManager?.onNewDisplay(surveyId: surveyId)
/// Happens when the user closes the survey view with the close button.
case .onClose:
- SurveyManager.shared.dismissSurveyWebView()
+ Formbricks.surveyManager?.dismissSurveyWebView()
/// Happens when the survey wants to open an external link in the default browser.
case .onOpenExternalURL:
@@ -123,11 +133,11 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
/// Happens when the survey library fails to load.
case .onSurveyLibraryLoadError:
- SurveyManager.shared.dismissSurveyWebView()
+ Formbricks.surveyManager?.dismissSurveyWebView()
}
} else {
- Formbricks.logger.error("\(FormbricksSDKError(type: .invalidJavascriptMessage).message): \(message.body)")
+ Formbricks.logger?.error("\(FormbricksSDKError(type: .invalidJavascriptMessage).message): \(message.body)")
}
}
}
@@ -136,7 +146,7 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
/// 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)
+ Formbricks.logger?.debug(message.body)
}
}
diff --git a/packages/ios/FormbricksSDK/FormbricksSDKTests/FormbricksSDKTests.swift b/packages/ios/FormbricksSDK/FormbricksSDKTests/FormbricksSDKTests.swift
index 24767467ab..e6ad594be3 100644
--- a/packages/ios/FormbricksSDK/FormbricksSDKTests/FormbricksSDKTests.swift
+++ b/packages/ios/FormbricksSDK/FormbricksSDKTests/FormbricksSDKTests.swift
@@ -1,10 +1,3 @@
-//
-// FormbricksSDKTests.swift
-// FormbricksSDKTests
-//
-// Created by Peter Pesti-Varga on 2025. 02. 03..
-//
-
import XCTest
@testable import FormbricksSDK
@@ -15,128 +8,153 @@ final class FormbricksSDKTests: XCTestCase {
let userId = "6CCCE716-6783-4D0F-8344-9C7DFA43D8F7"
let surveyID = "cm6ovw6j7000gsf0kduf4oo4i"
let mockService = MockFormbricksService()
-
- override func setUpWithError() throws {
- UserManager.shared.logout()
- SurveyManager.shared.service = mockService
- UserManager.shared.service = mockService
- SurveyManager.shared.environmentResponse = nil
- }
func testFormbricks() throws {
- // Everything should be in the default state
+ // Everything should be in the default state before initialization.
XCTAssertFalse(Formbricks.isInitialized)
- XCTAssertEqual(SurveyManager.shared.filteredSurveys.count, 0)
- XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
- XCTAssertNil(SurveyManager.shared.environmentResponse)
- XCTAssertNil(UserManager.shared.syncTimer)
- XCTAssertNil(UserManager.shared.userId)
+ XCTAssertNil(Formbricks.surveyManager)
+ XCTAssertNil(Formbricks.userManager)
XCTAssertEqual(Formbricks.language, "default")
- // Use methods before init should have no effect
+ // User manager default state: there is no user yet.
+ XCTAssertNil(Formbricks.userManager?.displays)
+ XCTAssertNil(Formbricks.userManager?.responses)
+ XCTAssertNil(Formbricks.userManager?.segments)
+
+ // Use methods before init should have no effect.
Formbricks.setUserId("userId")
Formbricks.setLanguage("de")
Formbricks.setAttributes(["testA" : "testB"])
Formbricks.setAttribute("test", forKey: "testKey")
- XCTAssertNil(UserManager.shared.userId)
+ XCTAssertNil(Formbricks.userManager?.userId)
XCTAssertEqual(Formbricks.language, "default")
+
+ // Setup the SDK using your new instance-based design.
+ // This creates new instances for both the UserManager and SurveyManager.
+ Formbricks.setup(with: FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId)
+ .set(attributes: ["a": "b"])
+ .add(attribute: "test", forKey: "key")
+ .setLogLevel(.debug)
+ .build())
+
+ // Set up the service dependency on both managers.
+ Formbricks.userManager?.service = mockService
+ Formbricks.surveyManager?.service = mockService
- // Call the setup and initialize the SDK
- Formbricks.setup(with:
- FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId)
- .set(attributes: ["a":"b"])
- .add(attribute: "test", forKey: "key")
- .setLogLevel(.debug)
- .build()
- )
- // Should be ignored, becuase we don't have user ID yet
- Formbricks.setAttributes(["testA" : "testB"])
- Formbricks.setAttribute("test", forKey: "testKey")
- XCTAssertNil(UserManager.shared.userId)
-
- // Verify the base variables are set properly
- XCTAssertTrue(Formbricks.isInitialized)
- XCTAssertEqual(Formbricks.appUrl, appUrl)
- XCTAssertEqual(Formbricks.environmentId, environmentId)
-
- // User manager default state. There is no user yet.
- XCTAssertNil(FormbricksSDK.UserManager.shared.displays)
- XCTAssertNil(FormbricksSDK.UserManager.shared.responses)
- XCTAssertNil(FormbricksSDK.UserManager.shared.segments)
-
- // Check error state handling
- mockService.isErrorResponseNeeded = true
- XCTAssertFalse(SurveyManager.shared.hasApiError)
- SurveyManager.shared.refreshEnvironmentIfNeeded(force: true)
- XCTAssertTrue(SurveyManager.shared.hasApiError)
- mockService.isErrorResponseNeeded = false
-
- // Authenticate the user
- Formbricks.setUserId(userId)
- _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 2.0)
- XCTAssertEqual(UserManager.shared.userId, userId)
- // User refresh timer should be set
- XCTAssertNotNil(UserManager.shared.syncTimer)
-
- // The environment should be fetched already
- XCTAssertNotNil(SurveyManager.shared.environmentResponse)
-
- // Check if the filter method works properly
- XCTAssertEqual(SurveyManager.shared.filteredSurveys.count, 1)
-
- // Make sure we don't show any survey
- XCTAssertNotNil(SurveyManager.shared.filteredSurveys)
- XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
-
- // Track an unknown event, shouldn't show the survey
- Formbricks.track("unknown_event")
- XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
-
- // Track a known event, thus, the survey should be shown.
- Formbricks.track("click_demo_button")
- _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
- XCTAssertTrue(SurveyManager.shared.isShowingSurvey)
-
- // "Dismiss" the webview
- SurveyManager.shared.dismissSurveyWebView()
- XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
-
- // Validate display and response
- SurveyManager.shared.postResponse(surveyId: surveyID)
- SurveyManager.shared.onNewDisplay(surveyId: surveyID)
- XCTAssertEqual(UserManager.shared.responses?.count, 1)
- XCTAssertEqual(UserManager.shared.displays?.count, 1)
-
- // Track a valid event, but the survey should not shown, because we already gave a response.
- Formbricks.track("click_demo_button")
- _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
- XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
-
- // Validate logout
- XCTAssertNotNil(UserManager.shared.userId)
- XCTAssertNotNil(UserManager.shared.lastDisplayedAt)
- XCTAssertNotNil(UserManager.shared.responses)
- XCTAssertNotNil(UserManager.shared.displays)
- XCTAssertNotNil(UserManager.shared.segments)
- XCTAssertNotNil(UserManager.shared.expiresAt)
- Formbricks.logout()
- XCTAssertNil(UserManager.shared.userId)
- XCTAssertNil(UserManager.shared.lastDisplayedAt)
- XCTAssertNil(UserManager.shared.responses)
- XCTAssertNil(UserManager.shared.displays)
- XCTAssertNil(UserManager.shared.segments)
- XCTAssertNil(UserManager.shared.expiresAt)
-
- // Clear the responses
- Formbricks.logout()
- SurveyManager.shared.filterSurveys()
-
- Formbricks.track("click_demo_button")
- _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
- XCTAssertTrue(SurveyManager.shared.isShowingSurvey)
-
- SurveyManager.shared.dismissSurveyWebView()
- XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
+ XCTAssertTrue(Formbricks.isInitialized)
+ XCTAssertEqual(Formbricks.appUrl, appUrl)
+ XCTAssertEqual(Formbricks.environmentId, environmentId)
+
+ // Check error state handling.
+ mockService.isErrorResponseNeeded = true
+ XCTAssertFalse(Formbricks.surveyManager?.hasApiError ?? false)
+ Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true)
+ XCTAssertTrue(Formbricks.surveyManager?.hasApiError ?? false)
+
+ mockService.isErrorResponseNeeded = false
+ Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true)
+
+ // Authenticate the user.
+ Formbricks.setUserId(userId)
+ _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 2.0)
+ XCTAssertEqual(Formbricks.userManager?.userId, userId)
+ // User refresh timer should be set.
+ XCTAssertNotNil(Formbricks.userManager?.syncTimer)
+
+ // The environment should be fetched.
+ XCTAssertNotNil(Formbricks.surveyManager?.environmentResponse)
+
+ // Check if the filter method works properly.
+ XCTAssertEqual(Formbricks.surveyManager?.filteredSurveys.count, 1)
+
+ // Verify that we’re not showing any survey initially.
+ XCTAssertNotNil(Formbricks.surveyManager?.filteredSurveys)
+ XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false)
+
+ // Track an unknown event—survey should not be shown.
+ Formbricks.track("unknown_event")
+ XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false)
+
+ // Track a known event—the survey should be shown.
+ Formbricks.track("click_demo_button")
+ _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
+ XCTAssertTrue(Formbricks.surveyManager?.isShowingSurvey ?? false)
+
+ // "Dismiss" the webview.
+ Formbricks.surveyManager?.dismissSurveyWebView()
+ XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false)
+
+ // Validate display and response.
+ Formbricks.surveyManager?.postResponse(surveyId: surveyID)
+ Formbricks.surveyManager?.onNewDisplay(surveyId: surveyID)
+ XCTAssertEqual(Formbricks.userManager?.responses?.count, 1)
+ XCTAssertEqual(Formbricks.userManager?.displays?.count, 1)
+
+ // Track a valid event, but survey should not be shown because a response was already submitted.
+ Formbricks.track("click_demo_button")
+ _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
+ XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false)
+
+ // Validate logout.
+ XCTAssertNotNil(Formbricks.userManager?.userId)
+ XCTAssertNotNil(Formbricks.userManager?.lastDisplayedAt)
+ XCTAssertNotNil(Formbricks.userManager?.responses)
+ XCTAssertNotNil(Formbricks.userManager?.displays)
+ XCTAssertNotNil(Formbricks.userManager?.segments)
+ XCTAssertNotNil(Formbricks.userManager?.expiresAt)
+ Formbricks.logout()
+ XCTAssertNil(Formbricks.userManager?.userId)
+ XCTAssertNil(Formbricks.userManager?.lastDisplayedAt)
+ XCTAssertNil(Formbricks.userManager?.responses)
+ XCTAssertNil(Formbricks.userManager?.displays)
+ XCTAssertNil(Formbricks.userManager?.segments)
+ XCTAssertNil(Formbricks.userManager?.expiresAt)
+
+ // Clear the responses and verify survey behavior.
+ Formbricks.logout()
+ Formbricks.surveyManager?.filterSurveys()
+
+ Formbricks.track("click_demo_button")
+ _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
+ XCTAssertTrue(Formbricks.surveyManager?.isShowingSurvey ?? false)
+
+ // Test the cleanup
+ Formbricks.cleanup()
+ XCTAssertNil(Formbricks.userManager)
+ XCTAssertNil(Formbricks.surveyManager)
+ XCTAssertNil(Formbricks.apiQueue)
+ XCTAssertNil(Formbricks.presentSurveyManager)
+ XCTAssertFalse(Formbricks.isInitialized)
+ XCTAssertNil(Formbricks.appUrl)
+ XCTAssertNil(Formbricks.environmentId)
+ XCTAssertNil(Formbricks.logger)
}
+ func testCleanupWithCompletion() {
+ // Setup the SDK
+ let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId)
+ .setLogLevel(.debug)
+ .build()
+ Formbricks.setup(with: config)
+ XCTAssertTrue(Formbricks.isInitialized)
+
+ // Set expectation for cleanup completion
+ let cleanupExpectation = expectation(description: "Cleanup completed")
+
+ Formbricks.cleanup(waitForOperations: true) {
+ cleanupExpectation.fulfill()
+ }
+
+ wait(for: [cleanupExpectation], timeout: 2.0)
+
+ // Validate cleanup: all main properties should be nil or false
+ XCTAssertNil(Formbricks.userManager)
+ XCTAssertNil(Formbricks.surveyManager)
+ XCTAssertNil(Formbricks.presentSurveyManager)
+ XCTAssertNil(Formbricks.apiQueue)
+ XCTAssertFalse(Formbricks.isInitialized)
+ XCTAssertNil(Formbricks.appUrl)
+ XCTAssertNil(Formbricks.environmentId)
+ XCTAssertNil(Formbricks.logger)
+ }
}
diff --git a/packages/ios/README.md b/packages/ios/README.md
index 2bfe86b7db..dbbbedde50 100644
--- a/packages/ios/README.md
+++ b/packages/ios/README.md
@@ -5,7 +5,7 @@ To run the project, open `Formbricks.xcworkspace` in **Xcode**. The workspace co
- **FormbricksSDK**: The SDK package.
- **Demo**: A demo application to exercise the SDK.
-Before launching the `Demo` app, update the mandatory variables in `AppDelegate`:
+Before launching the `Demo` app, update the mandatory variables in `SetupView`:
```swift
let config = FormbricksConfig.Builder(appUrl: "[APP_URL]", environmentId: "[ENVIRONMENT_ID]")
@@ -13,15 +13,9 @@ let config = FormbricksConfig.Builder(appUrl: "[APP_URL]", environmentId: "[ENVI
.build()
```
-Once these values are properly set, the demo app can be launched.
-The demo app consists of a single view, `ContentView`. It is a SwiftUI view with a single button.
-The button's action should be updated according to the survey actions:
+Once these values are properly set, launch the demo app. The demo app uses a single view, `SetupView`, as the initial screen. In this view, tap the **Setup Formbricks SDK** button to initialize the SDK.
-```swift
-Formbricks.track("click_demo_button")
-```
-
-Replace `"click_demo_button"` with the desired action.
+Once setup is complete, you can use the **Call Formbricks.track** button to trigger your survey and the **Call Formbricks.cleanup** button to reset the SDK and return to the setup screen.
---
@@ -33,9 +27,9 @@ You can generate developer documentation for the SDK by pressing **Shift + Comma
## Unit Tests
-The SDK includes a unit test to verify the Manager's functionality. To run it:
+The SDK includes unit tests to verify the functionality of various components. To run them:
1. Select the `Test Navigator` tab in Xcode.
-2. Run the `testFormbricks()` method.
+2. Run the desired test methods.
The coverage report can be found in the `Report Navigator` tab.
diff --git a/packages/lib/messages/de-DE.json b/packages/lib/messages/de-DE.json
index 095a956713..6930448e1c 100644
--- a/packages/lib/messages/de-DE.json
+++ b/packages/lib/messages/de-DE.json
@@ -478,7 +478,6 @@
"password_changed_email_heading": "Passwort geändert",
"password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.",
"password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert",
- "powered_by_formbricks": "Unterstützt von Formbricks",
"privacy_policy": "Datenschutzerklärung",
"reject": "Ablehnen",
"render_email_response_value_file_upload_response_link_not_included": "Link zur hochgeladenen Datei ist aus Datenschutzgründen nicht enthalten",
@@ -995,8 +994,7 @@
"api_keys": {
"add_api_key": "API-Schlüssel hinzufügen",
"add_permission": "Berechtigung hinzufügen",
- "api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen",
- "only_organization_owners_and_managers_can_manage_api_keys": "Nur Organisationsinhaber und -manager können API-Schlüssel verwalten"
+ "api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
},
"billing": {
"10000_monthly_responses": "10,000 monatliche Antworten",
diff --git a/packages/lib/messages/en-US.json b/packages/lib/messages/en-US.json
index 5e0e617e62..ffeefe4544 100644
--- a/packages/lib/messages/en-US.json
+++ b/packages/lib/messages/en-US.json
@@ -478,7 +478,6 @@
"password_changed_email_heading": "Password changed",
"password_changed_email_text": "Your password has been changed successfully.",
"password_reset_notify_email_subject": "Your Formbricks password has been changed",
- "powered_by_formbricks": "Powered by Formbricks",
"privacy_policy": "Privacy Policy",
"reject": "Reject",
"render_email_response_value_file_upload_response_link_not_included": "Link to uploaded file is not included for data privacy reasons",
@@ -995,8 +994,7 @@
"api_keys": {
"add_api_key": "Add API key",
"add_permission": "Add permission",
- "api_keys_description": "Manage API keys to access Formbricks management APIs",
- "only_organization_owners_and_managers_can_manage_api_keys": "Only organization owners and managers can manage API keys"
+ "api_keys_description": "Manage API keys to access Formbricks management APIs"
},
"billing": {
"10000_monthly_responses": "10000 Monthly Responses",
diff --git a/packages/lib/messages/fr-FR.json b/packages/lib/messages/fr-FR.json
index a344cdfba9..a7b915892d 100644
--- a/packages/lib/messages/fr-FR.json
+++ b/packages/lib/messages/fr-FR.json
@@ -478,7 +478,6 @@
"password_changed_email_heading": "Mot de passe changé",
"password_changed_email_text": "Votre mot de passe a été changé avec succès.",
"password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé",
- "powered_by_formbricks": "Propulsé par Formbricks",
"privacy_policy": "Politique de confidentialité",
"reject": "Rejeter",
"render_email_response_value_file_upload_response_link_not_included": "Le lien vers le fichier téléchargé n'est pas inclus pour des raisons de confidentialité des données",
@@ -995,8 +994,7 @@
"api_keys": {
"add_api_key": "Ajouter une clé API",
"add_permission": "Ajouter une permission",
- "api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks",
- "only_organization_owners_and_managers_can_manage_api_keys": "Seuls les propriétaires et les gestionnaires de l'organisation peuvent gérer les clés API"
+ "api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Réponses Mensuelles",
diff --git a/packages/lib/messages/pt-BR.json b/packages/lib/messages/pt-BR.json
index 63e53bfcad..ecc6728bf5 100644
--- a/packages/lib/messages/pt-BR.json
+++ b/packages/lib/messages/pt-BR.json
@@ -478,7 +478,6 @@
"password_changed_email_heading": "Senha alterada",
"password_changed_email_text": "Sua senha foi alterada com sucesso.",
"password_reset_notify_email_subject": "Sua senha Formbricks foi alterada",
- "powered_by_formbricks": "Desenvolvido por Formbricks",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"render_email_response_value_file_upload_response_link_not_included": "O link para o arquivo enviado não está incluído por motivos de privacidade de dados",
@@ -995,8 +994,7 @@
"api_keys": {
"add_api_key": "Adicionar chave de API",
"add_permission": "Adicionar permissão",
- "api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks",
- "only_organization_owners_and_managers_can_manage_api_keys": "Apenas proprietários e gerentes da organização podem gerenciar chaves de API"
+ "api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Respostas Mensais",
diff --git a/packages/lib/messages/pt-PT.json b/packages/lib/messages/pt-PT.json
index c69077ebae..9930e9de90 100644
--- a/packages/lib/messages/pt-PT.json
+++ b/packages/lib/messages/pt-PT.json
@@ -478,7 +478,6 @@
"password_changed_email_heading": "Palavra-passe alterada",
"password_changed_email_text": "A sua palavra-passe foi alterada com sucesso.",
"password_reset_notify_email_subject": "A sua palavra-passe do Formbricks foi alterada",
- "powered_by_formbricks": "Desenvolvido por Formbricks",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"render_email_response_value_file_upload_response_link_not_included": "O link para o ficheiro carregado não está incluído por razões de privacidade de dados",
@@ -995,8 +994,7 @@
"api_keys": {
"add_api_key": "Adicionar chave API",
"add_permission": "Adicionar permissão",
- "api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks",
- "only_organization_owners_and_managers_can_manage_api_keys": "Apenas os proprietários e gestores da organização podem gerir chaves API"
+ "api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Respostas Mensais",
diff --git a/packages/lib/messages/zh-Hant-TW.json b/packages/lib/messages/zh-Hant-TW.json
index 096ed256cf..6a6f694875 100644
--- a/packages/lib/messages/zh-Hant-TW.json
+++ b/packages/lib/messages/zh-Hant-TW.json
@@ -478,7 +478,6 @@
"password_changed_email_heading": "密碼已變更",
"password_changed_email_text": "您的密碼已成功變更。",
"password_reset_notify_email_subject": "您的 Formbricks 密碼已變更",
- "powered_by_formbricks": "由 Formbricks 提供技術支援",
"privacy_policy": "隱私權政策",
"reject": "拒絕",
"render_email_response_value_file_upload_response_link_not_included": "由於資料隱私原因,未包含上傳檔案的連結",
@@ -995,8 +994,7 @@
"api_keys": {
"add_api_key": "新增 API 金鑰",
"add_permission": "新增權限",
- "api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API",
- "only_organization_owners_and_managers_can_manage_api_keys": "只有組織擁有者和管理員才能管理 API 金鑰"
+ "api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
},
"billing": {
"10000_monthly_responses": "10000 個每月回應",