iteratehq/iterate-ios

View on GitHub
IterateSDK/SDK/Iterate.swift

Summary

Maintainability
A
1 hr
Test Coverage
//
//  Iterate.swift
//  Iterate
//
//  Created by Michael Singleton on 12/20/19.
//  Copyright © 2020 Pickaxe LLC. (DBA Iterate). All rights reserved.
//

import UIKit

/// The Iterate class is the primary class of the SDK, the main entry point is the shared singleton property
public final class Iterate {
    // MARK: Properties
    
    /// The shared singleton instance is the primary entrypoint into the Iterate iOS SDK.
    /// Unless you have uncommon needs you should use this singleton to call methods
    /// on the Iterate class.
    public static let shared = Iterate()
    
    /// Query parameter used to set the preview mode
    public static let PreviewParameter = "iterate_preview"
    
    // Current version number, will be updated on each release
    static let Version = "1.3.3"
    
    /// Default API host
    public static let DefaultAPIHost = "https://iteratehq.com"
    
    /// URL Scheme of the app, used for previewing surveys
    lazy var urlScheme = UIApplication.URLScheme()
    
    /// API Client, which will be initialized when the API key is
    var api: APIClient?
    
    /// Optional API host override to use when creating the API client
    var apiHost: String?
    
    /// The id of the survey being previewed
    var previewingSurveyId: String?
    
    /// The name of a custom font to be used for text the prompt and survey UI
    var surveyTextFontName: String?
    
    /// The name of a custom font to be used for buttons in the prompt and survey UI
    var buttonFontName: String?
    
    /// Storage engine for storing user data like their API key and user attributes
    private var storage: StorageEngine
    
    /// Container manages the overlay window
    private let container = ContainerWindowDelegate()
    
    /// Get the bundle by identifier or by url (needed when packaging in cocoapods) or Bundle.module generated by SPM
    var bundle: Bundle {
        #if SWIFT_PACKAGE
        Bundle.module
        #else
        Bundle(for: ContainerWindowDelegate.self)
            .url(forResource: "Iterate", withExtension: "bundle")
            .flatMap { .init(url: $0) }
            ?? .init(identifier: "com.iteratehq.Iterate")
            ?? .main
        #endif
    }
    
    // MARK: API Keys

    /// You Iterate API Key, you can get this from your settings page
    var companyApiKey: String? {
        didSet {
            // If we're changing the company API key to a different company API key
            // clear the user api key since it won't work for a different company.
            // We don't want to clear the user api key if the companyApiKey is nil
            // since that would cause us to always clear the userApiKey that was loaded
            // from storage when Iterate.shared.configure is called to set the
            // companyApiKey.
            if oldValue != nil && companyApiKey != oldValue  {
                userApiKey = nil
            }
            
            updateApiKey()
        }
    }
    
    /// The API key for a user, this is returned by the server the first time a request is made by a new user
    var userApiKey: String? {
        get {
            storage.value(for: StorageKeys.UserApiKey)
        }
        
        set(newUserApiKey) {
            if let newUserApiKey = newUserApiKey {
                storage.set(value: newUserApiKey, for: StorageKeys.UserApiKey)
            } else {
                storage.delete(for: StorageKeys.UserApiKey)
            }
            
            updateApiKey()
        }
    }
    
    
    // MARK: User Properties
    
    var userProperties: UserProperties? {
        get {
            if let properties = storage.value(for: StorageKeys.UserProperties),
                let data = properties.data(using: .utf8) {
                return try? JSONDecoder().decode(UserProperties.self, from: data)
            }
            
            return nil
        }
        set (newUserProperties) {
            if let newUserProperties = newUserProperties,
                let encodedNewUserProperties = try? JSONEncoder().encode(newUserProperties),
                let userProperties = String(data: encodedNewUserProperties, encoding: .utf8) {
                storage.set(value: userProperties, for: StorageKeys.UserProperties)
            }
        }
    }
    
    // MARK: Tracking Last Updated
    
    var trackingLastUpdated: Int? {
        get {
            if let trackingLastUpdatedString = storage.value(for: StorageKeys.TrackingLastUpdated) {
                return Int(trackingLastUpdatedString)
            }
            
            return nil
        }
        set (newTrackingLastUpdated) {
            if let newTrackingLastUpdated = newTrackingLastUpdated {
                storage.set(value: String(newTrackingLastUpdated), for: StorageKeys.TrackingLastUpdated)
            }
        }
    }
    
    var responseProperties: ResponseProperties?
    
    // MARK: Init
    
    /// Initializer
    /// - Parameter storage: Storage engine to use
    init(storage: StorageEngine = Storage.shared) {
        self.storage = storage
    }
    
    // MARK: Public methods
    
    /// Send event to determine if a survey should be shown
    /// - Parameters:
    ///   - name: Event name
    ///   - complete: optional callback with the results of the request
    public func sendEvent(name: String, complete: ((Survey?, Error?) -> Void)? = nil) {
        guard self.api != nil else {
            if let callback = complete {
                callback(nil, IterateError.invalidAPIKey)
            }
            
            return
        }
        
        embedRequest(context: EmbedContext(self, withEventName: name)) { (response, error) in
            if let callback = complete {
                callback(response?.survey, error)
            }
            
            if let survey = response?.survey {
                // Show the survey after N seconds otherwise show immediately
                if survey.triggers?.first?.type == TriggerType.seconds {
                    let seconds: Int = survey.triggers?.first?.options?.seconds ?? 0
                    Timer.scheduledTimer(withTimeInterval: Double(seconds), repeats: false) { timer in
                        self.container.show(survey)
                    }
                } else {
                    self.container.show(survey)
                }
            }
        }
    }
    
    /// Configure sets the necessary configuration properties. This should be called before any other methods.
    /// - Parameter apiKey: Your Iterate API Key, you can find this on your settings page
    public func configure(apiKey: String, apiHost: String? = Iterate.DefaultAPIHost, surveyTextFontName: String? = nil, buttonFontName: String? = nil) {
        // Note: we need to set the apiHost before setting the companyApiKey
        // since updating the companyApiKey is what triggers to API client
        // to be set via a custom setter
        self.apiHost = apiHost
        
        self.companyApiKey = apiKey
        
        self.surveyTextFontName = surveyTextFontName
        
        self.buttonFontName = buttonFontName
    }
    
    /// Preview processes the custom scheme url that was used to open the app and sets
    /// the preview mode to the surveyId passed in
    /// - Parameter url: The URL that opened the application
    public func preview(url: URL) {
        let result = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == Iterate.PreviewParameter })?.value
        previewingSurveyId = result
    }
    
    /// Preview a specific survey using it's id
    /// - Parameter surveyId: The id of the survey to preview
    public func preview(surveyId: String) {
        previewingSurveyId = surveyId
    }
    
    public func identify(userProperties: UserProperties?) {
        self.userProperties = userProperties
    }
    
    public func identify(responseProperties: ResponseProperties?) {
        self.responseProperties = responseProperties
    }
    
    public func reset() {
        // Clear everything from storage
        self.storage.clear()
        
        // Update the API key
        updateApiKey()
        
        // Clear response properties
        responseProperties = nil
    }
    
    // MARK: Private methods
    
    /// Helper method used when calling the embed endpoint which is responsible for updating the user API key
    /// if a new one is returned
    /// - Parameters:
    ///   - context: Embed context data
    ///   - complete: Callback returning the response and error from the embed endpoint
    private func embedRequest(context: EmbedContext, complete: @escaping (EmbedResponse?, Error?) -> Void) {
        api?.embed(context: context, completion: { (response, error) in
            // Update the user API key if one was returned
            if let token = response?.auth?.token {
                self.userApiKey = token
            }
            
            // Update the last tracked date if one was returned
            if let lastUpdated = response?.tracking?.lastUpdated {
                self.trackingLastUpdated = lastUpdated
            }
            
            complete(response, error)
        })
    }
    
    /// Update the API client to use the latest API key. We prefer to use the user API key and fall back to the company key
    private func updateApiKey() {
        if let apiKey = userApiKey ?? companyApiKey {
            api = APIClient(apiKey: apiKey, apiHost: apiHost ?? Iterate.DefaultAPIHost)
        }
    }
}