diff --git a/CustomerIOMessagingInApp.podspec b/CustomerIOMessagingInApp.podspec index c272a2d3d..fcf6440ab 100644 --- a/CustomerIOMessagingInApp.podspec +++ b/CustomerIOMessagingInApp.podspec @@ -26,6 +26,4 @@ Pod::Spec.new do |spec| spec.module_name = "CioMessagingInApp" # the `import X` name when using SDK in Swift files spec.dependency "CustomerIOCommon", "= #{spec.version.to_s}" - # Redux-like implementation for Swift, used for managing in-app message state in MessagingInApp module. - spec.dependency "ReSwift", "= 6.1.1" end diff --git a/Package.swift b/Package.swift index ff097c43b..111abeb1e 100644 --- a/Package.swift +++ b/Package.swift @@ -46,8 +46,6 @@ let package = Package( // Make sure the version number is same for DataPipelines cocoapods. .package(name: "CioAnalytics", url: "https://github.com/customerio/cdp-analytics-swift.git", .exact("1.5.14+cio.1")), - // Redux-like implementation for Swift, used for managing in-app message state in MessagingInApp module. - .package(name: "ReSwift", url: "https://github.com/ReSwift/ReSwift.git", .exact("6.1.1")), ], targets: [ // Common - Code used by multiple modules in the SDK project. @@ -115,7 +113,7 @@ let package = Package( // Messaging in-app .target(name: "CioMessagingInApp", - dependencies: ["CioInternalCommon", .product(name: "ReSwift", package: "ReSwift")], + dependencies: ["CioInternalCommon"], path: "Sources/MessagingInApp", resources: [ .process("Resources/PrivacyInfo.xcprivacy"), diff --git a/Sources/MessagingInApp/State/Aliases.swift b/Sources/MessagingInApp/State/Aliases.swift index 47ebe7ec6..8a0c7baa6 100644 --- a/Sources/MessagingInApp/State/Aliases.swift +++ b/Sources/MessagingInApp/State/Aliases.swift @@ -1,6 +1,5 @@ import CioInternalCommon import Foundation -import ReSwift // This file contains typealias for InAppMessage module which are used to decouple the module from ReSwift library // by leveraging custom types. This allows for easier testing and swapping out the ReSwift library in the future. @@ -14,6 +13,7 @@ typealias InAppMessageReducer = Reducer // Middleware function alias for InAppMessage store with custom defined types typealias InAppMessageMiddleware = Middleware + /// Middleware completion closure /// - Parameters: /// - dispatch: Dispatch function to dispatch actions @@ -27,25 +27,6 @@ typealias MiddlewareCompletion = ( InAppMessageAction ) -> Void -/// Helper function to create middleware for InAppMessage module -/// - Parameter completion: A closure that takes in the necessary parameters to perform the middleware logic -/// - Returns: Middleware function with given completion closure -func middleware( - completion: @escaping MiddlewareCompletion -) -> Middleware { - { dispatch, getState in { next in { action in - guard let inAppAction = action as? InAppMessageAction else { - DIGraphShared.shared.logger.logWithModuleTag("Invalid action type: \(action), skipping middleware", level: .debug) - return next(action) - } - - let getStateOrDefault = { getState() ?? InAppMessageState() } - completion(dispatch, getStateOrDefault, next, inAppAction) - } - } - } -} - // MARK: - StoreSubscriber /// StoreSubscriber implementation for InAppMessage store with custom defined types diff --git a/Sources/MessagingInApp/State/Core/Assertions.swift b/Sources/MessagingInApp/State/Core/Assertions.swift new file mode 100644 index 000000000..fa2c002a9 --- /dev/null +++ b/Sources/MessagingInApp/State/Core/Assertions.swift @@ -0,0 +1,36 @@ +// swiftlint:disable:next file_header +// +// Assertions +// Copyright © 2015 mohamede1945. All rights reserved. +// https://github.com/mohamede1945/AssertionsTestingExample +// + +import Foundation + +/// drop-in fatalError replacement for testing + +/** + Swift.fatalError wrapper for catching in tests + + - parameter message: Message to be wrapped + - parameter file: Calling file + - parameter line: Calling line + */ +func raiseFatalError( + _ message: @autoclosure () -> String = "", + file: StaticString = #file, + line: UInt = #line +) -> Never { + Assertions.fatalErrorClosure(message(), file, line) + repeat { + RunLoop.current.run() + } while true +} + +/// Stores custom assertions closures, by default it points to Swift functions. But test target can +/// override them. +enum Assertions { + static var fatalErrorClosure = swiftFatalErrorClosure + static let swiftFatalErrorClosure: (String, StaticString, UInt) -> Void + = { Swift.fatalError($0, file: $1, line: $2) } +} diff --git a/Sources/MessagingInApp/State/Core/Middleware.swift b/Sources/MessagingInApp/State/Core/Middleware.swift new file mode 100644 index 000000000..d892281c9 --- /dev/null +++ b/Sources/MessagingInApp/State/Core/Middleware.swift @@ -0,0 +1,16 @@ +// swiftlint:disable:next file_header +// +// Middleware.swift +// ReSwift +// +// Created by Benji Encz on 12/24/15. +// Copyright © 2015 ReSwift Community. All rights reserved. +// +// Modifications made: +// - Replaced Action with InAppMessageAction from Customer.io. +// - Updated visibility to internal to prevent exposing non-public types. +// + +typealias DispatchFunction = (InAppMessageAction) -> Void +typealias Middleware = (@escaping DispatchFunction, @escaping () -> State?) + -> (@escaping DispatchFunction) -> DispatchFunction diff --git a/Sources/MessagingInApp/State/Core/Reducer.swift b/Sources/MessagingInApp/State/Core/Reducer.swift new file mode 100644 index 000000000..8ae837023 --- /dev/null +++ b/Sources/MessagingInApp/State/Core/Reducer.swift @@ -0,0 +1,15 @@ +// swiftlint:disable:next file_header +// +// Reducer.swift +// ReSwift +// +// Created by Benjamin Encz on 12/14/15. +// Copyright © 2015 ReSwift Community. All rights reserved. +// +// Modifications made: +// - Replaced Action with InAppMessageAction from Customer.io. +// - Updated visibility to internal to prevent exposing non-public types. +// + +typealias Reducer = + (_ action: InAppMessageAction, _ state: ReducerStateType?) -> ReducerStateType diff --git a/Sources/MessagingInApp/State/Core/Store.swift b/Sources/MessagingInApp/State/Core/Store.swift new file mode 100644 index 000000000..3190d9e32 --- /dev/null +++ b/Sources/MessagingInApp/State/Core/Store.swift @@ -0,0 +1,168 @@ +// swiftlint:disable:next file_header +// +// Store.swift +// ReSwift +// +// Created by Benjamin Encz on 11/11/15. +// Copyright © 2015 ReSwift Community. All rights reserved. +// +// Modifications made: +// - Replaced Action with InAppMessageAction from Customer.io. +// - Constrained State to Equatable to simplify state comparison as InAppMessageState is Equatable. +// - Updated visibility to internal to prevent exposing non-public types. +// - Updated initializer to require non-optional initial State to avoid dispatching dummy init action. +// - Removed subscriptionsAutomaticallySkipRepeats as it will always be true. +// - Simplified subscription handling by removing unused subscription options. +// - Removed unused functions. +// + +/** + This class is the default implementation of the `StoreType` protocol. You will use this store in most + of your applications. You shouldn't need to implement your own store. + You initialize the store with a reducer and an initial application state. If your app has multiple + reducers you can combine them by initializing a `MainReducer` with all of your reducers as an + argument. + */ +class Store { + typealias SubscriptionType = SubscriptionBox + + public private(set) var state: State! { + didSet { + subscriptions.forEach { + if $0.subscriber == nil { + subscriptions.remove($0) + } else { + $0.newValues(oldState: oldValue, newState: state) + } + } + } + } + + public lazy var dispatchFunction: DispatchFunction! = createDispatchFunction() + + private var reducer: Reducer + + var subscriptions: Set = [] + + private var isDispatching = Synchronized(false) + + public var middleware: [Middleware] { + didSet { + dispatchFunction = createDispatchFunction() + } + } + + /// Initializes the store with a reducer, an initial state and a list of middleware. + /// + /// Middleware is applied in the order in which it is passed into this constructor. + /// + /// - parameter reducer: Main reducer that processes incoming actions. + /// - parameter state: Initial state, if any. Can be `nil` and will be + /// provided by the reducer in that case. + /// - parameter middleware: Ordered list of action pre-processors, acting + /// before the root reducer. + /// - parameter automaticallySkipsRepeats: If `true`, the store will attempt + /// to skip idempotent state updates when a subscriber's state type + /// implements `Equatable`. Defaults to `true`. + public required init( + reducer: @escaping Reducer, + state: State, + middleware: [Middleware] = [] + ) { + self.reducer = reducer + self.middleware = middleware + self.state = state + } + + private func createDispatchFunction() -> DispatchFunction! { + // Wrap the dispatch function with all middlewares + middleware + .reversed() + .reduce({ [unowned self] action in + _defaultDispatch(action: action) }, { dispatchFunction, middleware in + // If the store get's deinitialized before the middleware is complete; drop + // the action without dispatching. + let dispatch: (InAppMessageAction) -> Void = { [weak self] in self?.dispatch($0) } + let getState: () -> State? = { [weak self] in self?.state } + return middleware(dispatch, getState)(dispatchFunction) + } + ) + } + + private func _subscribe( + _ subscriber: S, + originalSubscription: Subscription, + transformedSubscription: Subscription? + ) where S.StoreSubscriberStateType == State { + let subscriptionBox = self.subscriptionBox( + originalSubscription: originalSubscription, + transformedSubscription: transformedSubscription, + subscriber: subscriber + ) + + subscriptions.update(with: subscriptionBox) + + if let state = state { + originalSubscription.newValues(oldState: nil, newState: state) + } + } + + public func subscribe( + _ subscriber: S, + transform: ((Subscription) -> Subscription) = { $0.skipRepeats() } + ) where S.StoreSubscriberStateType == State { + let originalSubscription = Subscription() + + _subscribe( + subscriber, + originalSubscription: originalSubscription, + transformedSubscription: transform(originalSubscription) + ) + } + + func subscriptionBox( + originalSubscription: Subscription, + transformedSubscription: Subscription?, + subscriber: AnyStoreSubscriber + ) -> SubscriptionBox { + SubscriptionBox( + originalSubscription: originalSubscription, + transformedSubscription: transformedSubscription, + subscriber: subscriber + ) + } + + func unsubscribe(_ subscriber: AnyStoreSubscriber) { + #if swift(>=5.0) + if let index = subscriptions.firstIndex(where: { $0.subscriber === subscriber }) { + subscriptions.remove(at: index) + } + #else + if let index = subscriptions.index(where: { $0.subscriber === subscriber }) { + subscriptions.remove(at: index) + } + #endif + } + + // swiftlint:disable:next identifier_name + func _defaultDispatch(action: InAppMessageAction) { + guard !isDispatching.value else { + raiseFatalError( + "ReSwift:ConcurrentMutationError- Action has been dispatched while" + + " a previous action is being processed. A reducer" + + " is dispatching an action, or ReSwift is used in a concurrent context" + + " (e.g. from multiple threads). Action: \(action)" + ) + } + + isDispatching.value { $0 = true } + let newState = reducer(action, state) + isDispatching.value { $0 = false } + + state = newState + } + + open func dispatch(_ action: InAppMessageAction) { + dispatchFunction(action) + } +} diff --git a/Sources/MessagingInApp/State/Core/StoreSubscriber.swift b/Sources/MessagingInApp/State/Core/StoreSubscriber.swift new file mode 100644 index 000000000..a72a2557e --- /dev/null +++ b/Sources/MessagingInApp/State/Core/StoreSubscriber.swift @@ -0,0 +1,28 @@ +// swiftlint:disable:next file_header +// +// StoreSubscriber.swift +// ReSwift +// +// Created by Benjamin Encz on 12/14/15. +// Copyright © 2015 ReSwift Community. All rights reserved. +// + +public protocol AnyStoreSubscriber: AnyObject { + // swiftlint:disable:next identifier_name + func _newState(state: Any) +} + +public protocol StoreSubscriber: AnyStoreSubscriber { + associatedtype StoreSubscriberStateType + + func newState(state: StoreSubscriberStateType) +} + +public extension StoreSubscriber { + // swiftlint:disable:next identifier_name + func _newState(state: Any) { + if let typedState = state as? StoreSubscriberStateType { + newState(state: typedState) + } + } +} diff --git a/Sources/MessagingInApp/State/Core/Subscription.swift b/Sources/MessagingInApp/State/Core/Subscription.swift new file mode 100644 index 000000000..3ee228c2b --- /dev/null +++ b/Sources/MessagingInApp/State/Core/Subscription.swift @@ -0,0 +1,159 @@ +// swiftlint:disable:next file_header +// +// SubscriberWrapper.swift +// ReSwift +// +// Created by Virgilio Favero Neto on 4/02/2016. +// Copyright © 2016 ReSwift Community. All rights reserved. +// +// Modifications made: +// - Replaced Action with InAppMessageAction from Customer.io. +// - Added `legacy_hashing` to suppress SwiftLint warnings when using legacy `hashValue` in older Swift versions. +// - Removed unused functions. +// + +/// A box around subscriptions and subscribers. +/// +/// Acts as a type-erasing wrapper around a subscription and its transformed subscription. +/// The transformed subscription has a type argument that matches the selected substate of the +/// subscriber; however that type cannot be exposed to the store. +/// +/// The box subscribes either to the original subscription, or if available to the transformed +/// subscription and passes any values that come through this subscriptions to the subscriber. +class SubscriptionBox: Hashable { + private let originalSubscription: Subscription + weak var subscriber: AnyStoreSubscriber? + private let objectIdentifier: ObjectIdentifier + + #if swift(>=5.0) + func hash(into hasher: inout Hasher) { + hasher.combine(objectIdentifier) + } + + #elseif swift(>=4.2) + #if compiler(>=5.0) + func hash(into hasher: inout Hasher) { + hasher.combine(objectIdentifier) + } + #else + // swiftlint:disable:next legacy_hashing + var hashValue: Int { + objectIdentifier.hashValue + } + #endif + #else + // swiftlint:disable:next legacy_hashing + var hashValue: Int { + objectIdentifier.hashValue + } + #endif + + init( + originalSubscription: Subscription, + transformedSubscription: Subscription?, + subscriber: AnyStoreSubscriber + ) { + self.originalSubscription = originalSubscription + self.subscriber = subscriber + self.objectIdentifier = ObjectIdentifier(subscriber) + + // If we received a transformed subscription, we subscribe to that subscription + // and forward all new values to the subscriber. + if let transformedSubscription = transformedSubscription { + transformedSubscription.observer = { [unowned self] _, newState in + self.subscriber?._newState(state: newState as Any) + } + // If we haven't received a transformed subscription, we forward all values + // from the original subscription. + } else { + originalSubscription.observer = { [unowned self] _, newState in + self.subscriber?._newState(state: newState as Any) + } + } + } + + func newValues(oldState: State, newState: State) { + // We pass all new values through the original subscription, which accepts + // values of type ``. If present, transformed subscriptions will + // receive this update and transform it before passing it on to the subscriber. + originalSubscription.newValues(oldState: oldState, newState: newState) + } + + static func == (left: SubscriptionBox, right: SubscriptionBox) -> Bool { + left.objectIdentifier == right.objectIdentifier + } +} + +/// Represents a subscription of a subscriber to the store. The subscription determines which new +/// values from the store are forwarded to the subscriber, and how they are transformed. +/// The subscription acts as a very-light weight signal/observable that you might know from +/// reactive programming libraries. +public class Subscription { + // MARK: Public Interface + + /// Initializes a subscription with a sink closure. The closure provides a way to send + /// new values over this subscription. + public init(sink: @escaping (@escaping (State?, State) -> Void) -> Void) { + // Provide the caller with a closure that will forward all values + // to observers of this subscription. + sink { old, new in + self.newValues(oldState: old, newState: new) + } + } + + /// Provides a subscription that skips certain state updates of the original subscription. + /// - parameter isRepeat: A closure that determines whether a given state update is a repeat and + /// thus should be skipped and not forwarded to subscribers. + /// - parameter oldState: The store's old state, before the action is reduced. + /// - parameter newState: The store's new state, after the action has been reduced. + public func skipRepeats(_ isRepeat: @escaping (_ oldState: State, _ newState: State) -> Bool) + -> Subscription { + return Subscription { sink in + self.observer = { oldState, newState in + switch (oldState, newState) { + case (let old?, let new): + if !isRepeat(old, new) { + sink(oldState, newState) + } else { + return + } + default: + sink(oldState, newState) + } + } + } + } + + /// The closure called with changes from the store. + /// This closure can be written to for use in extensions to Subscription similar to `skipRepeats` + public var observer: ((State?, State) -> Void)? + + // MARK: Internals + + init() {} + + /// Sends new values over this subscription. Observers will be notified of these new values. + func newValues(oldState: State?, newState: State) { + observer?(oldState, newState) + } +} + +public extension Subscription where State: Equatable { + func skipRepeats() -> Subscription { + skipRepeats(==) + } +} + +/// Subscription skipping convenience methods +public extension Subscription { + /// Provides a subscription that skips certain state updates of the original subscription. + /// + /// This is identical to `skipRepeats` and is provided simply for convenience. + /// - parameter when: A closure that determines whether a given state update is a repeat and + /// thus should be skipped and not forwarded to subscribers. + /// - parameter oldState: The store's old state, before the action is reduced. + /// - parameter newState: The store's new state, after the action has been reduced. + func skip(when: @escaping (_ oldState: State, _ newState: State) -> Bool) -> Subscription { + skipRepeats(when) + } +} diff --git a/Sources/MessagingInApp/State/Core/Synchronized.swift b/Sources/MessagingInApp/State/Core/Synchronized.swift new file mode 100644 index 000000000..6f283eecc --- /dev/null +++ b/Sources/MessagingInApp/State/Core/Synchronized.swift @@ -0,0 +1,32 @@ +// swiftlint:disable:next file_header +// +// Synchronized.swift +// ReSwift +// +// Created by Basem Emara on 2020-08-18. +// https://basememara.com/creating-thread-safe-generic-values-in-swift/ +// +// Copyright © 2020 ReSwift Community. All rights reserved. +// +// Modifications made: +// - Updated DispatchQueue label from "reswift.github.io.ReSwift.Utils.Synchronized" to +// "io.customer.MessagingInApp.Utils.Synchronized" for clarity. +// + +import Foundation + +/// An object that manages the execution of tasks atomically. +struct Synchronized { + private let mutex = DispatchQueue(label: "io.customer.MessagingInApp.Utils.Synchronized", attributes: .concurrent) + private var _value: Value + init(_ value: Value) { + self._value = value + } + + /// Returns or modify the thread-safe value. + var value: Value { mutex.sync { _value } } + /// Submits a block for synchronous, thread-safe execution. + mutating func value(execute task: (inout Value) throws -> T) rethrows -> T { + try mutex.sync(flags: .barrier) { try task(&_value) } + } +} diff --git a/Sources/MessagingInApp/State/InAppMessageAction.swift b/Sources/MessagingInApp/State/InAppMessageAction.swift index 8d2437856..a660baf68 100644 --- a/Sources/MessagingInApp/State/InAppMessageAction.swift +++ b/Sources/MessagingInApp/State/InAppMessageAction.swift @@ -1,9 +1,8 @@ import Foundation -import ReSwift /// Represents an action that can be dispatched to InAppMessage store. /// It acts like a sealed class, so that only the cases defined here can be used with InAppMessage store. -enum InAppMessageAction: Equatable, Action { +enum InAppMessageAction: Equatable { case initialize(siteId: String, dataCenter: String, environment: GistEnvironment) case setPollingInterval(interval: Double) case setUserIdentifier(user: String) diff --git a/Sources/MessagingInApp/State/InAppMessageMiddleware.swift b/Sources/MessagingInApp/State/InAppMessageMiddleware.swift index ff7f66e6c..9291324c2 100644 --- a/Sources/MessagingInApp/State/InAppMessageMiddleware.swift +++ b/Sources/MessagingInApp/State/InAppMessageMiddleware.swift @@ -1,6 +1,20 @@ import CioInternalCommon import Foundation +/// Helper function to create middleware for InAppMessage module +/// - Parameter completion: A closure that takes in the necessary parameters to perform the middleware logic +/// - Returns: Middleware function with given completion closure +private func middleware( + completion: @escaping MiddlewareCompletion +) -> InAppMessageMiddleware { + { dispatch, getState in { next in { action in + let getStateOrDefault = { getState() ?? InAppMessageState() } + completion(dispatch, getStateOrDefault, next, action) + } + } + } +} + func userAuthenticationMiddleware() -> InAppMessageMiddleware { middleware { _, getState, next, action in let state = getState() diff --git a/Sources/MessagingInApp/State/InAppMessageReducer.swift b/Sources/MessagingInApp/State/InAppMessageReducer.swift index d4355d6f6..054039b31 100644 --- a/Sources/MessagingInApp/State/InAppMessageReducer.swift +++ b/Sources/MessagingInApp/State/InAppMessageReducer.swift @@ -1,6 +1,5 @@ import CioInternalCommon import Foundation -import ReSwift /// Returns a reducer function after wrapping it in a logging function. /// The wrapper function logs the action and state before and after the reducer is called. @@ -8,14 +7,6 @@ import ReSwift /// of referring to ReSwift's Action type directly. func inAppMessageReducer(logger: Logger) -> InAppMessageReducer { { action, state in - // Avoid force unwrapping the action as InAppMessageAction to prevent unexpected crashes. - guard let action = action as? InAppMessageAction else { - // This should never happen ideally since we are only dispatching InAppMessageAction as actions, - // but if it does, we should log it and return the current state. - logger.logWithModuleTag("Error: Action (\(action)) is not of type InAppMessageAction", level: .debug) - return InAppMessageState() - } - let currentState = state ?? InAppMessageState() logger.logWithModuleTag("Action received: \(action) with current state: \(currentState)", level: .debug) let result = reducer(action: action, state: currentState) diff --git a/Sources/MessagingInApp/State/InAppMessageStore.swift b/Sources/MessagingInApp/State/InAppMessageStore.swift index c98fcaad2..502961a84 100644 --- a/Sources/MessagingInApp/State/InAppMessageStore.swift +++ b/Sources/MessagingInApp/State/InAppMessageStore.swift @@ -1,5 +1,4 @@ import Foundation -import ReSwift /// Store class wrapped as actor to maintain consistency and thread safety /// This also decouples store callers from ReSwift dependency by exposing only required methods @@ -9,16 +8,14 @@ actor InAppMessageStore { var state: InAppMessageState { store.state } init( - reducer: @escaping Reducer, - state: InAppMessageState?, - middleware: [Middleware], - automaticallySkipsRepeats: Bool = true + reducer: @escaping InAppMessageReducer, + state: InAppMessageState, + middleware: [InAppMessageMiddleware] ) { self.store = Store( reducer: reducer, state: state, - middleware: middleware, - automaticallySkipsRepeats: automaticallySkipsRepeats + middleware: middleware ) }