diff --git a/FlagShip.podspec b/FlagShip.podspec index daac6e3c..6b9ae890 100644 --- a/FlagShip.podspec +++ b/FlagShip.podspec @@ -8,7 +8,7 @@ Pod::Spec.new do |s| s.name = "FlagShip" - s.version = "5.0.0-beta.5" + s.version = "5.2.0-beta.0" s.summary = "Flagship SDK" # This description is used to generate tags and improve search results. diff --git a/FlagShip/FlagShipTests/FSOnExposureTest.swift b/FlagShip/FlagShipTests/FSOnExposureTest.swift index 46fbbae6..d3c6c896 100644 --- a/FlagShip/FlagShipTests/FSOnExposureTest.swift +++ b/FlagShip/FlagShipTests/FSOnExposureTest.swift @@ -78,12 +78,29 @@ final class FSOnExposureTest: XCTestCase { let variation = FSVariation(idVariation: "varId", variationName: "varName", nil, isReference: false) let modif = FSModification(aCampaign: camp, aVariation: variation, valueForFlag: "flagVlaue") let mettdata = FSFlagMetadata(modif) - let flagTest = FSExposedFlag(key: "keyFlag", defaultValue: "dfl", metadata: mettdata, value: "flagVlaue") + let flagTest = FSExposedFlag(key: "keyFlag", defaultValue: "dfl", metadata: mettdata, value: "flagVlaue", alreadyActivatedCampaign: true) XCTAssertTrue(flagTest.toDictionary()["value"] as? String == "flagVlaue") XCTAssertTrue(flagTest.toDictionary()["key"] as? String == "keyFlag") XCTAssertTrue(flagTest.metadata.campaignId == "campId") - XCTAssertTrue(flagTest.toJson()?.count ?? 0 > 0) + XCTAssertTrue(flagTest.toDictionary()["alreadyActivatedCampaign"] as? Bool == true) + + XCTAssertTrue(flagTest.toJson()?.length ?? 0 > 0) + + var dicoFlag = flagTest.toDictionary() + + // create FSExposedFlag obj + let obj = FSExposedFlag(exposedInfo: dicoFlag) + + // Modify the dico flag + dicoFlag.removeValue(forKey: "alreadyActivatedCampaign") + let objBis = FSExposedFlag(exposedInfo: dicoFlag) + XCTAssertFalse(objBis.alreadyActivatedCampaign ?? true) // true to rise error + + // Modify the entire object + dicoFlag.removeAll() + let objTer = FSExposedFlag(exposedInfo: dicoFlag) + XCTAssertTrue(objTer.metadata.campaignId.count == 0) } // Test Visitor Object @@ -92,6 +109,36 @@ final class FSOnExposureTest: XCTestCase { XCTAssertTrue(visitorObject.toDictionary()["id"] as? String == "testId") XCTAssertTrue(visitorObject.toDictionary()["anonymousId"] as? String == "ano1") XCTAssertTrue((visitorObject.toDictionary()["context"] as? [String: Any])?["key1"] as? String == "val1") - XCTAssertTrue(visitorObject.toJson()?.count ?? 0 > 0) + XCTAssertTrue(visitorObject.toJson()?.length ?? 0 > 0) + + var dico = visitorObject.toDictionary() + + // Modify dico + dico.removeValue(forKey: "context") + let obj = FSVisitorExposed(dico: dico) + XCTAssertTrue(obj.context.isEmpty) + + // Modify dico + dico.removeAll() + let objBis = FSVisitorExposed(dico: dico) + XCTAssertNil(objBis.anonymousId) + } + + func testDeduplication() { + let expectationSync = XCTestExpectation(description: "Service-deduplication") + testVisitor?.fetchFlags(onFetchCompleted: { + if let flag = self.testVisitor?.getFlag(key: "btnTitle") { + XCTAssertTrue(flag.value(defaultValue: "dfl") == "Alpha_demoApp") + // Activate again + XCTAssertTrue(flag.value(defaultValue: "dfl") == "Alpha_demoApp") + // Check deduplication + XCTAssertTrue(self.testVisitor?.isDeduplicatedFlag(campId: "bvcdqksmicqghldq9agg", varGrpId: "bvcdqksmicqghldq9ahg") ?? false) + // Send hit to reset timer + self.testVisitor?.sendHit(FSScreen("testLocatoin")) + XCTAssertTrue(self.testVisitor?.isDeduplicatedFlag(campId: "bvcdqksmicqghldq9agg", varGrpId: "bvcdqksmicqghldq9ahg") ?? false) + } + expectationSync.fulfill() + }) + wait(for: [expectationSync], timeout: 5.0) } } diff --git a/FlagShip/Flagship.xcodeproj/project.pbxproj b/FlagShip/Flagship.xcodeproj/project.pbxproj index c2a3b733..4182af7c 100644 --- a/FlagShip/Flagship.xcodeproj/project.pbxproj +++ b/FlagShip/Flagship.xcodeproj/project.pbxproj @@ -1900,7 +1900,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = "5.0.0-beta.5"; + MARKETING_VERSION = "5.2.0-beta.0"; OTHER_LDFLAGS = ""; OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = ABTasty.FlagShip; @@ -1934,7 +1934,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = "5.0.0-beta.5"; + MARKETING_VERSION = "5.2.0-beta.0"; OTHER_LDFLAGS = ""; OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = ABTasty.FlagShip; diff --git a/FlagShip/Source/Core/FSExposedFlag.swift b/FlagShip/Source/Core/FSExposedFlag.swift index bc8fe728..8352b6e5 100644 --- a/FlagShip/Source/Core/FSExposedFlag.swift +++ b/FlagShip/Source/Core/FSExposedFlag.swift @@ -17,6 +17,9 @@ protocol IFlag { // Get metadata var metadata: FSFlagMetadata { get set } + + // Already activated campaign + var alreadyActivatedCampaign: Bool? { get } } @objc public class FSExposedFlag: NSObject, IFlag { @@ -32,18 +35,23 @@ protocol IFlag { // Value for flag public private(set) var value: Any? - init(key: String, defaultValue: Any? = nil, metadata: FSFlagMetadata, value: Any?) { + // Already activated campaign + public var alreadyActivatedCampaign: Bool? = true + + init(key: String, defaultValue: Any? = nil, metadata: FSFlagMetadata, value: Any?, alreadyActivatedCampaign: Bool = false) { self.key = key self.defaultValue = defaultValue self.metadata = metadata self.value = value + self.alreadyActivatedCampaign = alreadyActivatedCampaign } - init(expoedInfo: [String: Any]) { - self.key = expoedInfo["key"] as? String ?? "" - self.value = expoedInfo["value"] - self.defaultValue = expoedInfo["defaultValue"] - self.metadata = FSFlagMetadata(metadataDico: expoedInfo["metadata"] as? [String: Any] ?? [:]) + init(exposedInfo: [String: Any]) { + self.key = exposedInfo["key"] as? String ?? "" + self.value = exposedInfo["value"] + self.defaultValue = exposedInfo["defaultValue"] + self.metadata = FSFlagMetadata(metadataDico: exposedInfo["metadata"] as? [String: Any] ?? [:]) + self.alreadyActivatedCampaign = exposedInfo["alreadyActivatedCampaign"] as? Bool ?? false // TODO: Review & test later } /// Dictionary that represent the Exposed Flag @@ -62,6 +70,10 @@ protocol IFlag { result.updateValue(aValue, forKey: "value") } + if let aAlreadyActivatedCampaign = alreadyActivatedCampaign { + result.updateValue(aAlreadyActivatedCampaign, forKey: "alreadyActivatedCampaign") + } + return result } @@ -81,8 +93,12 @@ protocol IFlag { result.updateValue(aValue, forKey: "value") } + if let aAlreadyActivated = alreadyActivatedCampaign { + result.updateValue(aAlreadyActivated, forKey: "alreadyActivatedCampaign") + } + guard let jsonData = try? JSONSerialization.data(withJSONObject: result, options: .prettyPrinted) else { - return nil + return "" } return jsonData.jsonString } diff --git a/FlagShip/Source/Core/FSVisitor+Flags.swift b/FlagShip/Source/Core/FSVisitor+Flags.swift index e1c50bfd..b78495ed 100644 --- a/FlagShip/Source/Core/FSVisitor+Flags.swift +++ b/FlagShip/Source/Core/FSVisitor+Flags.swift @@ -8,11 +8,15 @@ import Foundation +let FS_SESSION_VISITOR: TimeInterval = 30 * 60 /// 30 mn + public extension FSVisitor { /// Get FSFlag object /// - Parameter key: key represent key modification /// - Returns: FSFLag object func getFlag(key: String) -> FSFlag { + /// Init the session + // self.sessionDuration = Date() // We dispaly a warning if the flag's status is not fetched if self.fetchStatus != .FETCHED { FlagshipLogManager.Log(level: .ALL, tag: .FLAG, messageToDisplay: FSLogMessage.MESSAGE(self.requiredFetchReason.warningMessage(key, self.visitorId))) @@ -23,6 +27,8 @@ public extension FSVisitor { /// Getting FSFlagCollection /// - Returns: an instance of FSFlagCollection with flags func getFlags() -> FSFlagCollection { + /// Init the session + // self.sessionDuration = Date() var ret: [String: FSFlag] = [:] self.currentFlags.forEach { (key: String, _: FSModification) in @@ -30,6 +36,29 @@ public extension FSVisitor { } return FSFlagCollection(flags: ret) } + + internal func isDeduplicatedFlag(campId: String, varGrpId: String) -> Bool { + let elapsed = Date().timeIntervalSince(self.sessionDuration) + + // print("⏱️ Time session since last activity is : \(Int(elapsed)) s") + + // Reset the timestamp at the end of function + defer { sessionDuration = Date() } + + // Session expired ==> reset the dico and return false + guard elapsed <= FS_SESSION_VISITOR else { + activatedVariations = [campId: varGrpId] + return false + } + // Session still valide + if activatedVariations[campId] == varGrpId { + // The ids already exist ==> is deduplicated flag + return true + } + // We have a new flag ==> save it and return false + activatedVariations[campId] = varGrpId + return false + } } // We keep FlagMap instead using FSFlagV4 wich is also possible diff --git a/FlagShip/Source/Core/FSVisitor+Reconcilliation.swift b/FlagShip/Source/Core/FSVisitor+Reconcilliation.swift index ac2fef03..9e1a54a4 100644 --- a/FlagShip/Source/Core/FSVisitor+Reconcilliation.swift +++ b/FlagShip/Source/Core/FSVisitor+Reconcilliation.swift @@ -15,6 +15,8 @@ public extension FSVisitor { /// - Important: After using this method, you should use Flagship.fetchFlags method to update the visitor informations /// - Requires: Make sure that the experience continuity option is enabled on the flagship platform before using this method @objc func authenticate(visitorId: String) { + /// Init the session + self.sessionDuration = Date() if configManager.flagshipConfig.mode != .DECISION_API { FlagshipLogManager.Log(level: .ALL, tag: .AUTHENTICATE, messageToDisplay: FSLogMessage.IGNORE_AUTHENTICATE) return @@ -27,6 +29,8 @@ public extension FSVisitor { /// Use authenticate methode to go from Logged in session to logged out session @objc func unauthenticate() { + /// Init the session + self.sessionDuration = Date() if configManager.flagshipConfig.mode != .DECISION_API { FlagshipLogManager.Log(level: .ALL, tag: .UNAUTHENTICATE, messageToDisplay: FSLogMessage.IGNORE_AUTHENTICATE) return diff --git a/FlagShip/Source/Core/FSVisitor.swift b/FlagShip/Source/Core/FSVisitor.swift index c5fecbd3..34895797 100644 --- a/FlagShip/Source/Core/FSVisitor.swift +++ b/FlagShip/Source/Core/FSVisitor.swift @@ -61,9 +61,22 @@ import UIKit self.configManager.updateAid(newValue) } } + + var currentFlags: [String: FSModification] { + get { + return self.fsQueue.sync { + self._currentFlags + } + } + set { + self.fsQueue.async(flags: .barrier) { + self._currentFlags = newValue + } + } + } /// Modifications - var currentFlags: [String: FSModification] = [:] + private var _currentFlags: [String: FSModification] = [:] /// Context var context: FSContext /// Strategy @@ -87,6 +100,12 @@ import UIKit // Score value public internal(set) var emotionScoreAI: String? = nil + + // Session duration + var sessionDuration: Date + + // List of activated variations + var activatedVariations: [String: String] = [:] /// campId:varGrpId // Refonte status public internal(set) var fetchStatus: FSFlagStatus = .FETCH_REQUIRED { @@ -129,7 +148,7 @@ import UIKit self.visitorId = FSTools.manageVisitorId(aVisitorId) self.anonymousId = nil } - + // Set the user context self.context = FSContext(aContext, visitorId: aVisitorId) @@ -149,9 +168,14 @@ import UIKit self._onFlagStatusChanged = pOnFlagStatusChanged self._onFlagStatusFetchRequired = pOnFlagStatusFetchRequired self._onFlagStatusFetched = pOnFlagStatusFetched + + // init sessionStartTimestamp + self.sessionDuration = Date() } @objc public func fetchFlags(onFetchCompleted: @escaping () -> Void) { + /// Init the session + self.sessionDuration = Date() self.prepareEmotionAI(onCompleted: { score, _ in // Set the score self.emotionScoreAI = score @@ -213,6 +237,8 @@ import UIKit } public func collectEmotionsAIEvents(window: UIWindow?, screenName: String? = nil, usingSwizzling: Bool = false) { + /// Init the session + self.sessionDuration = Date() if Flagship.sharedInstance.eaiCollectEnabled == true { self.strategy?.getStrategy().collectEmotionsAIEvents(window: window, screenName: screenName, usingSwizzling: usingSwizzling) } else { @@ -231,6 +257,8 @@ import UIKit // Update Context // - Parameter newContext: user's context @objc public func updateContext(_ context: [String: Any]) { + /// Init the session + self.sessionDuration = Date() self._updateContext(context) } @@ -239,6 +267,8 @@ import UIKit // - key: key for the given value // - newValue: value for teh given key public func updateContext(_ key: String, _ newValue: Any) { + /// Init the session + self.sessionDuration = Date() self._updateContext([key: newValue]) } @@ -247,6 +277,8 @@ import UIKit // - presetKey: name of the preset context, see PresetContext // - newValue: the value for the given key public func updateContext(_ flagshipContext: FlagshipContext, _ value: Any) { + /// Init the session + self.sessionDuration = Date() /// Check the validity value if !flagshipContext.chekcValidity(value) { FlagshipLogManager.Log(level: .ALL, tag: .UPDATE_CONTEXT, messageToDisplay: FSLogMessage.UPDATE_PRE_CONTEXT_FAILED(flagshipContext.rawValue)) @@ -266,17 +298,24 @@ import UIKit // Get the current context // - Returns: Dictionary that represent a user context @objc public func getContext() -> [String: Any] { + /// Init the session + self.sessionDuration = Date() return self.context.getCurrentContext() } // Clear the current context @objc public func clearContext() { + /// Init the session + self.sessionDuration = Date() self.context.clearContext() } // Send Hits // - Parameter T: Hit object public func sendHit(_ event: T) { + /// Init the session + self.sessionDuration = Date() + self.strategy?.getStrategy().sendHit(event) } @@ -285,6 +324,9 @@ import UIKit // Set the conssent // - Parameter newValue: if true, then flush all stored visitor data @objc public func setConsent(hasConsented: Bool) { + /// Init the session + self.sessionDuration = Date() + self.hasConsented = hasConsented self.strategy?.getStrategy().setConsent(newValue: hasConsented) diff --git a/FlagShip/Source/Core/FSVisitorExposed.swift b/FlagShip/Source/Core/FSVisitorExposed.swift index 14b065b2..723be93d 100644 --- a/FlagShip/Source/Core/FSVisitorExposed.swift +++ b/FlagShip/Source/Core/FSVisitorExposed.swift @@ -58,7 +58,7 @@ import Foundation } guard let jsonData = try? JSONSerialization.data(withJSONObject: result, options: .prettyPrinted) else { - return nil + return "" } return jsonData.jsonString } diff --git a/FlagShip/Source/Models/Tracking/FSBatch.swift b/FlagShip/Source/Models/Tracking/FSBatch.swift index c7069deb..f3c53a07 100644 --- a/FlagShip/Source/Models/Tracking/FSBatch.swift +++ b/FlagShip/Source/Models/Tracking/FSBatch.swift @@ -176,7 +176,7 @@ class Activate: FSTrackingProtocol, Codable { if let dataExposedFalg = self.exposure_flag?.data(using: .utf8), let dataExposedVisitor = self.exposure_visitor?.data(using: .utf8) { do { if let dicoExposedFalg = (try? JSONSerialization.jsonObject(with: dataExposedFalg, options: [])) as? [String: Any] { - let expoFlag = FSExposedFlag(expoedInfo: dicoExposedFalg) + let expoFlag = FSExposedFlag(exposedInfo: dicoExposedFalg) if let dicoExposedVisitor = (try? JSONSerialization.jsonObject(with: dataExposedVisitor, options: [])) as? [String: Any] { let expoVisitor = FSVisitorExposed(dico: dicoExposedVisitor) diff --git a/FlagShip/Source/Strategy/FSDefaultStrategy.swift b/FlagShip/Source/Strategy/FSDefaultStrategy.swift index ac33b124..203c0497 100644 --- a/FlagShip/Source/Strategy/FSDefaultStrategy.swift +++ b/FlagShip/Source/Strategy/FSDefaultStrategy.swift @@ -43,37 +43,74 @@ class FSDefaultStrategy: FSDelegateStrategy { self.visitor = pVisitor } - /// Activate Flag func activateFlag(_ flag: FSFlag) { - if let aModification = visitor.currentFlags[flag.key] { - // Define Exposed flag and exposed visitor - var exposedFlag, exposedVisitor: String? - if visitor.configManager.flagshipConfig.onVisitorExposed != nil { - // Create flag exposed object - exposedFlag = FSExposedFlag(key: flag.key, defaultValue: flag.defaultValue, metadata: flag.metadata(), value: flag.value(defaultValue: flag.defaultValue, visitorExposed: false)).toJson() - // Create visitor expose object - exposedVisitor = FSVisitorExposed(id: visitor.visitorId, anonymousId: visitor.anonymousId, context: visitor.getContext()).toJson() + // Exit if we don’t have a modification present in current flag + guard let modification = visitor.currentFlags[flag.key] else { return } + + // Get the informations + let flagshipConfig = visitor.configManager.flagshipConfig + let callback = flagshipConfig.onVisitorExposed + let metadata = flag.metadata() + let value = flag.value(defaultValue: flag.defaultValue, visitorExposed: false) + + // Prepare objetcs when callback exist + var exposedFlag: FSExposedFlag? + var exposedVisitor: FSVisitorExposed? + if callback != nil { + exposedFlag = FSExposedFlag( + key: flag.key, + defaultValue: flag.defaultValue, + metadata: metadata, + value: value + ) + exposedVisitor = FSVisitorExposed( + id: visitor.visitorId, + anonymousId: visitor.anonymousId, + context: visitor.context.currentContext + ) + } + + // Build the activation hit + let activateToSend = Activate( + visitor.visitorId, + visitor.anonymousId, + modification: modification, exposedFlag?.toJson(), + exposedVisitor?.toJson() + ) + + // Handle deduplication before sending hit + let isDuplicate = visitor.isDeduplicatedFlag( + campId: metadata.campaignId, + varGrpId: metadata.variationGroupId + ) + if isDuplicate { + FlagshipLogManager.Log(level: .DEBUG, tag: .ACTIVATE, messageToDisplay: FSLogMessage.MESSAGE("Skip sending activation… variation already activated in this current session.")) + // if we have an exposedFlag, mark it and fire callback once + if let ef = exposedFlag, let ev = exposedVisitor { + ef.alreadyActivatedCampaign = true + callback?(ev, ef) + } + return + } + + // Send activation + visitor.configManager.trackingManager?.sendActivate( + activateToSend + ) { error, exposedInfosArray in + guard error == nil, let infos = exposedInfosArray else { return } + infos.forEach { info in + callback?(info.visitorExposed, info.exposedFlag) } - - let activateToSend = Activate(visitor.visitorId, visitor.anonymousId, modification: aModification, exposedFlag, exposedVisitor) - visitor.configManager.trackingManager?.sendActivate(activateToSend, onCompletion: { error, exposedInfosArray in - - if error == nil { - /// Is callback is defined ===> Trigger it - if let aOnVisitorExposed = self.visitor.configManager.flagshipConfig.onVisitorExposed { - exposedInfosArray?.forEach { item in - aOnVisitorExposed(item.visitorExposed, item.exposedFlag) - } - } - } else { - // The flag error - } - }) - // Troubleshooitng activate - FSDataUsageTracking.sharedInstance.processTSHits(label: CriticalPoints.VISITOR_SEND_ACTIVATE.rawValue, visitor: visitor, hit: activateToSend) } + + // Troubleshooting / TS hit + FSDataUsageTracking.sharedInstance.processTSHits( + label: CriticalPoints.VISITOR_SEND_ACTIVATE.rawValue, + visitor: visitor, + hit: activateToSend + ) } - + func synchronize(onSyncCompleted: @escaping (FSFlagStatus, FetchFlagsRequiredStatusReason) -> Void) { let startFetchingDate = Date() // To comunicate for TR diff --git a/FlagShip/Source/Tools/FlagShipVersion.swift b/FlagShip/Source/Tools/FlagShipVersion.swift index 23acfc2d..6c08ffe4 100644 --- a/FlagShip/Source/Tools/FlagShipVersion.swift +++ b/FlagShip/Source/Tools/FlagShipVersion.swift @@ -8,4 +8,4 @@ import Foundation /// This file is automatically updated 2.0.0 -public let FlagShipVersion = "5.0.0-beta.5" +public let FlagShipVersion = "5.2.0-beta.0" diff --git a/iOS Example/QApp/FSConfigViewController.swift b/iOS Example/QApp/FSConfigViewController.swift index cf7d9157..853be57f 100644 --- a/iOS Example/QApp/FSConfigViewController.swift +++ b/iOS Example/QApp/FSConfigViewController.swift @@ -103,10 +103,11 @@ class FSConfigViewController: UIViewController, UITextFieldDelegate, FSJsonEdito } } } - }.withLogLevel(.ALL).withOnVisitorExposed { visitorExposed, fromFlag in + }.withLogLevel(.ALL).withOnVisitorExposed { v, fromFlag in print("------- On visitor exposed callback ----------") - print(visitorExposed.toJson()) + print(v.toJson()) + print(fromFlag.toJson()) print("------- On visitor exposed callback ----------") } @@ -166,9 +167,9 @@ class FSConfigViewController: UIViewController, UITextFieldDelegate, FSJsonEdito } func createVisitor() -> FSVisitor { - let userIdToSet: String = visitorIdTextField?.text ?? "" + let userIdToSet: String = visitorIdTextField?.text ?? "" - //let userIdToSet = "iosUser_\(UUID().uuidString)" + // let userIdToSet = "iosUser_\(UUID().uuidString)" return Flagship.sharedInstance.newVisitor(visitorId: userIdToSet, hasConsented: allowTrackingSwitch?.isOn ?? true).withContext(context: ["segment": "coffee", "isQA": true, "testing_tracking_manager": true, "isPreRelease": true, "test": 12]).isAuthenticated(authenticateSwitch?.isOn ?? false).withOnFlagStatusChanged { newStatus in