diff --git a/.swiftlint.yml b/.swiftlint.yml
index 527f7a2d..da57db44 100644
--- a/.swiftlint.yml
+++ b/.swiftlint.yml
@@ -1,8 +1,17 @@
disabled_rules:
- function_body_length
+ - type_body_length
- trailing_comma
+ - opening_brace
- todo
identifier_name:
allowed_symbols: "_"
min_length: 1
cyclomatic_complexity: 25
+nesting:
+ type_level:
+ warning: 3
+ error: 6
+ function_level:
+ warning: 5
+ error: 10
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Amplitude-Swift.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Amplitude-Swift.xcscheme
new file mode 100644
index 00000000..6f1b50ee
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/Amplitude-Swift.xcscheme
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Sources/Amplitude/Amplitude.swift b/Sources/Amplitude/Amplitude.swift
index 0f7c6693..7a0fe8b7 100644
--- a/Sources/Amplitude/Amplitude.swift
+++ b/Sources/Amplitude/Amplitude.swift
@@ -5,13 +5,13 @@ public class Amplitude {
var instanceName: String
internal var inForeground = false
- lazy var storage: Storage = {
+ lazy var storage: any Storage = {
return self.configuration.storageProvider
}()
lazy var timeline: Timeline = {
return Timeline()
}()
- lazy var logger: any Logger = {
+ lazy var logger: (any Logger)? = {
return self.configuration.loggerProvider
}()
@@ -108,7 +108,7 @@ public class Amplitude {
return self
}
- func onEnterForeground(timestamp: Double) {
+ func onEnterForeground(timestamp: Int64) {
inForeground = true
let dummySessionStartEvent = BaseEvent(eventType: "session_start")
@@ -131,10 +131,10 @@ public class Amplitude {
private func process(event: BaseEvent) {
if configuration.optOut {
- logger.log(message: "Skip event based on opt out configuration")
+ logger?.log(message: "Skip event based on opt out configuration")
return
}
- event.timestamp = event.timestamp ?? NSDate().timeIntervalSince1970
+ event.timestamp = event.timestamp ?? Int64(NSDate().timeIntervalSince1970 * 1000)
timeline.process(event: event)
}
}
diff --git a/Sources/Amplitude/Configuration.swift b/Sources/Amplitude/Configuration.swift
index 34e21996..6bb4fa97 100644
--- a/Sources/Amplitude/Configuration.swift
+++ b/Sources/Amplitude/Configuration.swift
@@ -13,7 +13,7 @@ public class Configuration {
var flushIntervalMillis: Int
var instanceName: String
var optOut: Bool
- var storageProvider: Storage
+ var storageProvider: any Storage
var logLevel: LogLevelEnum
var loggerProvider: any Logger
var minIdLength: Int?
@@ -38,7 +38,7 @@ public class Configuration {
flushIntervalMillis: Int = Constants.Configuration.FLUSH_INTERVAL_MILLIS,
instanceName: String = Constants.Configuration.DEFAULT_INSTANCE,
optOut: Bool = false,
- storageProvider: Storage = PersistentStorage(),
+ storageProvider: (any Storage)? = nil,
logLevel: LogLevelEnum = LogLevelEnum.WARN,
loggerProvider: any Logger = ConsoleLogger(),
minIdLength: Int? = nil,
@@ -63,7 +63,7 @@ public class Configuration {
self.flushIntervalMillis = flushIntervalMillis
self.instanceName = instanceName
self.optOut = optOut
- self.storageProvider = storageProvider
+ self.storageProvider = storageProvider ?? PersistentStorage(apiKey: apiKey)
self.logLevel = logLevel
self.loggerProvider = loggerProvider
self.minIdLength = minIdLength
diff --git a/Sources/Amplitude/Constants.swift b/Sources/Amplitude/Constants.swift
index 3f9d051a..8677e735 100644
--- a/Sources/Amplitude/Constants.swift
+++ b/Sources/Amplitude/Constants.swift
@@ -58,7 +58,6 @@ public struct Constants {
static let MIN_TIME_BETWEEN_SESSIONS_MILLIS = 300000
}
- struct Storage {
- static let STORAGE_PREFIX = "amplitude-swift"
+ public struct Storage {
}
}
diff --git a/Sources/Amplitude/Events/BaseEvent.swift b/Sources/Amplitude/Events/BaseEvent.swift
index 09eaccf5..8b124a2c 100644
--- a/Sources/Amplitude/Events/BaseEvent.swift
+++ b/Sources/Amplitude/Events/BaseEvent.swift
@@ -7,19 +7,61 @@
import Foundation
-public class BaseEvent: EventOptions {
+public class BaseEvent: EventOptions, Codable {
public var eventType: String
public var eventProperties: [String: Any]?
public var userProperties: [String: Any]?
public var groups: [String: Any]?
public var groupProperties: [String: Any]?
+ enum CodingKeys: String, CodingKey {
+ case eventType = "event_type"
+ case eventProperties = "event_properties"
+ case userProperties = "user_properties"
+ case groups
+ case groupProperties = "group_properties"
+ case userId = "user_id"
+ case deviceId = "device_id"
+ case timestamp = "time"
+ case eventId = "event_id"
+ case sessionId = "session_id"
+ case locationLat = "location_lat"
+ case locationLng = "location_lng"
+ case appVersion = "app_version"
+ case versionName = "version_name"
+ case platform
+ case osName = "os_name"
+ case osVersion = "os_version"
+ case deviceBrand = "device_brand"
+ case deviceManufacturer = "device_manufacturer"
+ case deviceModel = "device_model"
+ case carrier
+ case country
+ case region
+ case city
+ case dma
+ case idfa
+ case idfv
+ case adid
+ case language
+ case library
+ case ip
+ case plan
+ case ingestionMetadata = "ingestion_metadata"
+ case revenue
+ case price
+ case quantity
+ case productId = "product_id"
+ case revenueType = "revenue_type"
+ case partnerId = "partner_id"
+ }
+
init(
userId: String? = nil,
deviceId: String? = nil,
- timestamp: Double? = nil,
- eventId: Double? = nil,
- sessionId: Double? = -1,
+ timestamp: Int64? = nil,
+ eventId: Int64? = nil,
+ sessionId: Int64? = -1,
insertId: String? = nil,
locationLat: Double? = nil,
locationLng: Double? = nil,
@@ -147,4 +189,107 @@ public class BaseEvent: EventOptions {
func isValid() -> Bool {
return userId != nil || deviceId != nil
}
+
+ required public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ eventType = try values.decode(String.self, forKey: .eventType)
+ eventProperties = try values.decode([String: Any].self, forKey: .eventProperties)
+ userProperties = try values.decode([String: Any].self, forKey: .userProperties)
+ groups = try values.decode([String: Any].self, forKey: .groups)
+ groupProperties = try values.decode([String: Any].self, forKey: .groupProperties)
+ super.init()
+ userId = try values.decode(String.self, forKey: .userId)
+ deviceId = try values.decode(String.self, forKey: .deviceId)
+ timestamp = try values.decode(Int64.self, forKey: .timestamp)
+ eventId = try values.decode(Int64.self, forKey: .eventId)
+ sessionId = try values.decode(Int64.self, forKey: .sessionId)
+ locationLat = try values.decode(Double.self, forKey: .locationLat)
+ locationLng = try values.decode(Double.self, forKey: .locationLng)
+ appVersion = try values.decode(String.self, forKey: .appVersion)
+ versionName = try values.decode(String.self, forKey: .versionName)
+ platform = try values.decode(String.self, forKey: .platform)
+ osName = try values.decode(String.self, forKey: .osName)
+ osVersion = try values.decode(String.self, forKey: .osVersion)
+ deviceBrand = try values.decode(String.self, forKey: .deviceBrand)
+ deviceManufacturer = try values.decode(String.self, forKey: .deviceManufacturer)
+ deviceModel = try values.decode(String.self, forKey: .deviceModel)
+ carrier = try values.decode(String.self, forKey: .carrier)
+ country = try values.decode(String.self, forKey: .country)
+ region = try values.decode(String.self, forKey: .region)
+ city = try values.decode(String.self, forKey: .city)
+ dma = try values.decode(String.self, forKey: .dma)
+ idfa = try values.decode(String.self, forKey: .idfa)
+ idfv = try values.decode(String.self, forKey: .idfv)
+ adid = try values.decode(String.self, forKey: .adid)
+ language = try values.decode(String.self, forKey: .language)
+ library = try values.decode(String.self, forKey: .library)
+ ip = try values.decode(String.self, forKey: .ip)
+ plan = try values.decode(Plan.self, forKey: .plan)
+ ingestionMetadata = try values.decode(IngestionMetadata.self, forKey: .ingestionMetadata)
+ revenue = try values.decode(Double.self, forKey: .revenue)
+ price = try values.decode(Double.self, forKey: .price)
+ quantity = try values.decode(Int.self, forKey: .quantity)
+ productId = try values.decode(String.self, forKey: .productId)
+ revenueType = try values.decode(String.self, forKey: .revenueType)
+ partnerId = try values.decode(String.self, forKey: .partnerId)
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(eventType, forKey: .eventType)
+ try container.encodeIfPresent(eventProperties, forKey: .eventProperties)
+ try container.encodeIfPresent(userProperties, forKey: .userProperties)
+ try container.encodeIfPresent(groups, forKey: .groups)
+ try container.encodeIfPresent(groupProperties, forKey: .groupProperties)
+ try container.encode(userId, forKey: .userId)
+ try container.encode(deviceId, forKey: .deviceId)
+ try container.encode(timestamp, forKey: .timestamp)
+ try container.encode(eventId, forKey: .eventId)
+ try container.encode(sessionId, forKey: .sessionId)
+ try container.encode(locationLat, forKey: .locationLat)
+ try container.encode(locationLng, forKey: .locationLng)
+ try container.encode(appVersion, forKey: .appVersion)
+ try container.encode(versionName, forKey: .versionName)
+ try container.encode(platform, forKey: .platform)
+ try container.encode(osName, forKey: .osName)
+ try container.encode(osVersion, forKey: .osVersion)
+ try container.encode(deviceBrand, forKey: .deviceBrand)
+ try container.encode(deviceManufacturer, forKey: .deviceManufacturer)
+ try container.encode(deviceModel, forKey: .deviceModel)
+ try container.encode(carrier, forKey: .carrier)
+ try container.encode(country, forKey: .country)
+ try container.encode(region, forKey: .region)
+ try container.encode(city, forKey: .city)
+ try container.encode(dma, forKey: .dma)
+ try container.encode(idfa, forKey: .idfa)
+ try container.encode(idfv, forKey: .idfv)
+ try container.encode(adid, forKey: .adid)
+ try container.encode(language, forKey: .language)
+ try container.encode(library, forKey: .library)
+ try container.encode(ip, forKey: .ip)
+ try container.encodeIfPresent(plan, forKey: .plan)
+ try container.encodeIfPresent(ingestionMetadata, forKey: .ingestionMetadata)
+ try container.encode(revenue, forKey: .revenue)
+ try container.encode(price, forKey: .price)
+ try container.encode(quantity, forKey: .quantity)
+ try container.encode(productId, forKey: .productId)
+ try container.encode(revenueType, forKey: .revenueType)
+ try container.encode(partnerId, forKey: .partnerId)
+ }
+}
+
+extension BaseEvent {
+ func toString() -> String {
+ var returnString = ""
+ do {
+ let encoder = JSONEncoder()
+ let json = try encoder.encode(self)
+ if let printed = String(data: json, encoding: .utf8) {
+ returnString = printed
+ }
+ } catch {
+ returnString = error.localizedDescription
+ }
+ return returnString
+ }
}
diff --git a/Sources/Amplitude/Events/EventOptions.swift b/Sources/Amplitude/Events/EventOptions.swift
index 2e754a96..427be31a 100644
--- a/Sources/Amplitude/Events/EventOptions.swift
+++ b/Sources/Amplitude/Events/EventOptions.swift
@@ -10,9 +10,9 @@ import Foundation
public class EventOptions {
var userId: String?
var deviceId: String?
- var timestamp: Double?
- var eventId: Double?
- var sessionId: Double? = -1
+ var timestamp: Int64?
+ var eventId: Int64?
+ var sessionId: Int64? = -1
var insertId: String?
var locationLat: Double?
var locationLng: Double?
@@ -45,14 +45,14 @@ public class EventOptions {
var extra: [String: Any]?
var callback: EventCallBack?
var partnerId: String?
- private var attempts: Int
+ internal var attempts: Int
init(
userId: String? = nil,
deviceId: String? = nil,
- timestamp: Double? = nil,
- eventId: Double? = nil,
- sessionId: Double? = -1,
+ timestamp: Int64? = nil,
+ eventId: Int64? = nil,
+ sessionId: Int64? = -1,
insertId: String? = nil,
locationLat: Double? = nil,
locationLng: Double? = nil,
diff --git a/Sources/Amplitude/Mediator.swift b/Sources/Amplitude/Mediator.swift
index db22d378..9b2e23ce 100644
--- a/Sources/Amplitude/Mediator.swift
+++ b/Sources/Amplitude/Mediator.swift
@@ -23,10 +23,9 @@ internal class Mediator {
var result: BaseEvent? = event
plugins.forEach { plugin in
if let r = result {
- if plugin is DestinationPlugin {
- _ = plugin.execute(event: r)
+ if let p = plugin as? DestinationPlugin {
+ _ = p.process(event: r)
} else if let p = plugin as? EventPlugin {
- result = p.execute(event: r)
if let rr = result {
if let identifyEvent = rr as? IdentifyEvent {
result = p.identify(event: identifyEvent)
diff --git a/Sources/Amplitude/Plugins/AmplitudeDestinationPlugin.swift b/Sources/Amplitude/Plugins/AmplitudeDestinationPlugin.swift
index c427661b..3cda8acc 100644
--- a/Sources/Amplitude/Plugins/AmplitudeDestinationPlugin.swift
+++ b/Sources/Amplitude/Plugins/AmplitudeDestinationPlugin.swift
@@ -19,7 +19,6 @@ public class AmplitudeDestinationPlugin: DestinationPlugin {
logger?.error(message: "Event is invalid for missing information like userId and deviceId")
}
}
-
}
public func track(event: BaseEvent) -> BaseEvent? {
diff --git a/Sources/Amplitude/Plugins/ContextPlugin.swift b/Sources/Amplitude/Plugins/ContextPlugin.swift
index 5cc731ac..7e679303 100644
--- a/Sources/Amplitude/Plugins/ContextPlugin.swift
+++ b/Sources/Amplitude/Plugins/ContextPlugin.swift
@@ -76,7 +76,7 @@ class ContextPlugin: Plugin {
internal func mergeContext(event: BaseEvent, context: [String: Any]) {
if event.timestamp == nil {
- event.timestamp = NSDate().timeIntervalSince1970 * 1000
+ event.timestamp = Int64(NSDate().timeIntervalSince1970 * 1000)
}
if event.insertId == nil {
event.insertId = NSUUID().uuidString
diff --git a/Sources/Amplitude/Plugins/Mac/MacOSLifecycleMonitor.swift b/Sources/Amplitude/Plugins/Mac/MacOSLifecycleMonitor.swift
index 58a9349c..e5d0cc79 100644
--- a/Sources/Amplitude/Plugins/Mac/MacOSLifecycleMonitor.swift
+++ b/Sources/Amplitude/Plugins/Mac/MacOSLifecycleMonitor.swift
@@ -82,7 +82,7 @@
extension AmplitudeDestinationPlugin: MacOSLifecycle {
public func applicationDidBecomeActive() {
- let timestamp = NSDate().timeIntervalSince1970
+ let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000)
self.amplitude?.onEnterForeground(timestamp: timestamp)
}
diff --git a/Sources/Amplitude/Plugins/Vendors/AppUtil.swift b/Sources/Amplitude/Plugins/Vendors/AppUtil.swift
index ec7ac253..e6cc93cf 100644
--- a/Sources/Amplitude/Plugins/Vendors/AppUtil.swift
+++ b/Sources/Amplitude/Plugins/Vendors/AppUtil.swift
@@ -83,10 +83,12 @@ import Foundation
}
override var os_version: String {
- return String(format: "%ld.%ld.%ld",
- device.operatingSystemVersion.majorVersion,
- device.operatingSystemVersion.minorVersion,
- device.operatingSystemVersion.patchVersion)
+ return String(
+ format: "%ld.%ld.%ld",
+ device.operatingSystemVersion.majorVersion,
+ device.operatingSystemVersion.minorVersion,
+ device.operatingSystemVersion.patchVersion
+ )
}
override var requiredPlugin: Plugin {
@@ -109,12 +111,12 @@ import Foundation
return getDeviceModel(platform: platform)
}
- private func macAddress(bsd : String) -> String? {
+ private func macAddress(bsd: String) -> String? {
let MAC_ADDRESS_LENGTH = 6
let separator = ":"
- var length : size_t = 0
- var buffer : [CChar]
+ var length: size_t = 0
+ var buffer: [CChar]
let bsdIndex = Int32(if_nametoindex(bsd))
if bsdIndex == 0 {
@@ -124,16 +126,19 @@ import Foundation
var managementInfoBase = [CTL_NET, AF_ROUTE, 0, AF_LINK, NET_RT_IFLIST, bsdIndex]
if sysctl(&managementInfoBase, 6, nil, &length, nil, 0) < 0 {
- return nil;
+ return nil
}
- buffer = [CChar](unsafeUninitializedCapacity: length, initializingWith: {buffer, initializedCount in
- for x in 0.. Any? {
+ func read(key: StorageKey) async -> T? {
return nil
}
func reset() async {
}
+
+ func rollover() async {
+
+ }
+
+ func getEventsString(eventBlock: Any) async -> String? {
+ return nil
+ }
}
diff --git a/Sources/Amplitude/Storages/PersistentStorage.swift b/Sources/Amplitude/Storages/PersistentStorage.swift
index 5eb7e44f..e32b6d3b 100644
--- a/Sources/Amplitude/Storages/PersistentStorage.swift
+++ b/Sources/Amplitude/Storages/PersistentStorage.swift
@@ -1,5 +1,5 @@
//
-// File.swift
+// PersistentStorage.swift
//
//
// Created by Marvin Liu on 10/28/22.
@@ -11,22 +11,256 @@ actor PersistentStorage: Storage {
let storagePrefix: String
let userDefaults: UserDefaults?
let fileManager: FileManager?
+ private var outputStream: OutputFileStream?
+ internal weak var amplitude: Amplitude?
- init(storagePrefix: String = Constants.Storage.STORAGE_PREFIX) {
- self.storagePrefix = storagePrefix
- self.userDefaults = UserDefaults(suiteName: "com.amplitude.storage.\(storagePrefix)")
+ init(apiKey: String = "") {
+ self.storagePrefix = "\(PersistentStorage.DEFAULT_STORAGE_PREFIX)-\(apiKey)"
+ self.userDefaults = UserDefaults(suiteName: "\(PersistentStorage.AMP_STORAGE_PREFIX).\(storagePrefix)")
self.fileManager = FileManager.default
}
- func write(key: String, value: Any?) async {
+ func write(key: StorageKey, value: Any?) async throws {
+ switch key {
+ case .EVENTS:
+ if let event = value as? BaseEvent {
+ let eventStoreFile = getCurrentFile()
+ self.storeEvent(toFile: eventStoreFile, event: event)
+ }
+ default:
+ if isBasicType(value: value) {
+ userDefaults?.set(value, forKey: key.rawValue)
+ } else {
+ throw Exception.unsupportedType
+ }
+ }
+ }
+ func read(key: StorageKey) async -> T? {
+ var result: T?
+ switch key {
+ case .EVENTS:
+ result = getEventFiles() as? T
+ default:
+ result = userDefaults?.object(forKey: key.rawValue) as? T
+ }
+ return result
}
- func read(key: String) async -> Any? {
- return nil
+ func getEventsString(eventBlock: Any) async -> String? {
+ var content: String?
+ guard let eventBlock = eventBlock as? URL else { return content }
+ do {
+ content = try String(contentsOf: eventBlock, encoding: .utf8)
+ } catch {
+ amplitude?.logger?.error(message: error.localizedDescription)
+ }
+ return content
}
func reset() async {
+ let urls = getEventFiles(includeUnfinished: true)
+ let keys = userDefaults?.dictionaryRepresentation().keys
+ keys?.forEach { key in
+ userDefaults?.removeObject(forKey: key)
+ }
+ for url in urls {
+ try? fileManager!.removeItem(atPath: url.path)
+ }
+ }
+
+ func rollover() async {
+ let currentFile = getCurrentFile()
+ if fileManager?.fileExists(atPath: currentFile.path) == false {
+ return
+ }
+ if let attributes = try? fileManager?.attributesOfItem(atPath: currentFile.path),
+ let fileSize = attributes[FileAttributeKey.size] as? UInt64,
+ fileSize >= 0
+ {
+ finish(file: currentFile)
+ }
+ }
+
+ func isBasicType(value: Any?) -> Bool {
+ var result = false
+ if value == nil {
+ result = true
+ } else {
+ switch value {
+ case is NSNull, is Int, is Float, is Double, is Decimal, is NSNumber, is Bool, is String, is NSString:
+ result = true
+ default:
+ break
+ }
+ }
+ return result
+ }
+}
+
+extension PersistentStorage {
+ static let DEFAULT_STORAGE_PREFIX = "amplitude-swift"
+ static let AMP_STORAGE_PREFIX = "com.amplitude.storage"
+ static let MAX_FILE_SIZE = 975000 // 975KB
+ static let TEMP_FILE_EXTENSION = "tmp"
+
+ enum Exception: Error {
+ case unsupportedType
+ }
+}
+
+extension PersistentStorage {
+ private var eventsFileKey: String {
+ return "\(storagePrefix).\(StorageKey.EVENTS.rawValue).index"
+ }
+
+ private func getCurrentFile() -> URL {
+ var currentFileIndex = 0
+ let index: Int = userDefaults?.integer(forKey: eventsFileKey) ?? 0
+ userDefaults?.set(index, forKey: eventsFileKey)
+ currentFileIndex = index
+ return getEventsFile(index: currentFileIndex)
+ }
+
+ private func getEventsFile(index: Int) -> URL {
+ let dir = getEventsStorageDirectory()
+ let fileURL = dir.appendingPathComponent("\(index)").appendingPathExtension(
+ PersistentStorage.TEMP_FILE_EXTENSION
+ )
+ return fileURL
+ }
+
+ private func getEventFiles(includeUnfinished: Bool = false) -> [URL] {
+ var result = [URL]()
+
+ // finish out any file in progress
+ let index = userDefaults?.integer(forKey: eventsFileKey) ?? 0
+ finish(file: getEventsFile(index: index))
+
+ let allFiles = try? fileManager!.contentsOfDirectory(
+ at: getEventsStorageDirectory(),
+ includingPropertiesForKeys: [],
+ options: .skipsHiddenFiles
+ )
+ var files = allFiles
+ if includeUnfinished == false {
+ files = allFiles?.filter { (file) -> Bool in
+ return file.pathExtension != PersistentStorage.TEMP_FILE_EXTENSION
+ }
+ }
+ let sorted = files?.sorted { (left, right) -> Bool in
+ return left.lastPathComponent > right.lastPathComponent
+ }
+ if let s = sorted {
+ result = s
+ }
+ return result
+ }
+
+ private func getEventsStorageDirectory() -> URL {
+ // tvOS doesn't have access to document
+ // macOS /Documents dir might be synced with iCloud
+ #if os(tvOS) || os(macOS)
+ let searchPathDirectory = FileManager.SearchPathDirectory.cachesDirectory
+ #else
+ let searchPathDirectory = FileManager.SearchPathDirectory.documentDirectory
+ #endif
+
+ let urls = fileManager!.urls(for: searchPathDirectory, in: .userDomainMask)
+ let docUrl = urls[0]
+ let storageUrl = docUrl.appendingPathComponent("amplitude/\(eventsFileKey)/")
+ // try to create it, will fail if already exists.
+ // tvOS, watchOS regularly clear out data.
+ try? FileManager.default.createDirectory(at: storageUrl, withIntermediateDirectories: true, attributes: nil)
+ return storageUrl
+ }
+
+ private func storeEvent(toFile file: URL, event: BaseEvent) {
+ var storeFile = file
+
+ var newFile = false
+ if fileManager?.fileExists(atPath: storeFile.path) == false {
+ start(file: storeFile)
+ newFile = true
+ } else if outputStream == nil {
+ // this can happen if an instance was terminated before finishing a file.
+ open(file: storeFile)
+ }
+
+ // Verify file size isn't too large
+ if let attributes = try? fileManager?.attributesOfItem(atPath: storeFile.path),
+ let fileSize = attributes[FileAttributeKey.size] as? UInt64,
+ fileSize >= PersistentStorage.MAX_FILE_SIZE
+ {
+ finish(file: storeFile)
+ // Set the new file path
+ storeFile = getCurrentFile()
+ start(file: storeFile)
+ newFile = true
+ }
+
+ let jsonString = event.toString()
+ do {
+ if outputStream == nil {
+ amplitude?.logger?.error(message: "OutputStream is nil with file: \(storeFile)")
+ }
+ if newFile == false {
+ // prepare for the next entry
+ try outputStream?.write(",")
+ }
+ try outputStream?.write(jsonString)
+ } catch {
+ amplitude?.logger?.error(message: error.localizedDescription)
+ }
+ }
+
+ private func start(file: URL) {
+ let contents = "["
+ do {
+ outputStream = try OutputFileStream(fileURL: file)
+ try outputStream?.create()
+ try outputStream?.write(contents)
+ } catch {
+ amplitude?.logger?.error(message: error.localizedDescription)
+ }
+ }
+
+ private func open(file: URL) {
+ if outputStream == nil {
+ // this can happen if an instance was terminated before finishing a file.
+ do {
+ outputStream = try OutputFileStream(fileURL: file)
+ if let outputStream = outputStream {
+ try outputStream.open()
+ }
+ } catch {
+ amplitude?.logger?.error(message: error.localizedDescription)
+ }
+ }
+ }
+
+ private func finish(file: URL) {
+ guard let outputStream = self.outputStream else {
+ return
+ }
+
+ let fileEnding = "]"
+ do {
+ try outputStream.write(fileEnding)
+ } catch {
+ amplitude?.logger?.error(message: error.localizedDescription)
+ }
+ outputStream.close()
+ self.outputStream = nil
+
+ let fileWithoutTemp = file.deletingPathExtension()
+ do {
+ try fileManager?.moveItem(at: file, to: fileWithoutTemp)
+ } catch {
+ amplitude?.logger?.error(message: "Unable to rename file: \(file.path)")
+ }
+ let currentFileIndex: Int = (userDefaults?.integer(forKey: eventsFileKey) ?? 0) + 1
+ userDefaults?.set(currentFileIndex, forKey: eventsFileKey)
}
}
diff --git a/Sources/Amplitude/Timeline.swift b/Sources/Amplitude/Timeline.swift
index 7d957f93..1eb85ae3 100644
--- a/Sources/Amplitude/Timeline.swift
+++ b/Sources/Amplitude/Timeline.swift
@@ -21,7 +21,6 @@ public class Timeline {
let beforeResult = applyPlugin(pluginType: PluginType.before, event: event)
let enrichmentResult = applyPlugin(pluginType: PluginType.enrichment, event: beforeResult)
_ = applyPlugin(pluginType: PluginType.destination, event: enrichmentResult)
-
}
internal func applyPlugin(pluginType: PluginType, event: BaseEvent?) -> BaseEvent? {
@@ -30,7 +29,6 @@ public class Timeline {
result = mediator.execute(event: event!)
}
return result
-
}
internal func add(plugin: Plugin) {
diff --git a/Sources/Amplitude/Types.swift b/Sources/Amplitude/Types.swift
index 668d10be..b25244b0 100644
--- a/Sources/Amplitude/Types.swift
+++ b/Sources/Amplitude/Types.swift
@@ -5,14 +5,14 @@
// Created by Marvin Liu on 10/27/22.
//
-public struct Plan {
+public struct Plan: Codable {
var branch: String?
var source: String?
var version: String?
var versionId: String?
}
-public struct IngestionMetadata {
+public struct IngestionMetadata: Codable {
var sourceName: String?
var sourceVersion: String?
}
@@ -22,11 +22,21 @@ public protocol EventCallBack {
}
public protocol Storage {
- func write(key: String, value: Any?) async
- func read(key: String) async -> Any?
+ func write(key: StorageKey, value: Any?) async throws
+ func read(key: StorageKey) async -> T?
+ func getEventsString(eventBlock: Any) async -> String?
+ func rollover() async
func reset() async
}
+public enum StorageKey: String, CaseIterable {
+ case LAST_EVENT_ID = "last_event_id"
+ case PREVIOUS_SESSION_ID = "previous_session_id"
+ case LAST_EVENT_TIME = "last_event_time"
+ case OPT_OUT = "opt_out"
+ case EVENTS = "events"
+}
+
public protocol Logger {
associatedtype LogLevel: RawRepresentable
var logLevel: Int? { get set }
diff --git a/Sources/Amplitude/Utilities/Atomic.swift b/Sources/Amplitude/Utilities/Atomic.swift
new file mode 100644
index 00000000..4e19ba1a
--- /dev/null
+++ b/Sources/Amplitude/Utilities/Atomic.swift
@@ -0,0 +1,35 @@
+//
+// Atomic.swift
+//
+//
+// Created by Marvin Liu on 11/29/22.
+//
+
+import Foundation
+
+@propertyWrapper
+public struct Atomic {
+ var value: T
+ private let lock = NSLock()
+
+ public init(wrappedValue value: T) {
+ self.value = value
+ }
+
+ public var wrappedValue: T {
+ get { return load() }
+ set { store(newValue: newValue) }
+ }
+
+ func load() -> T {
+ lock.lock()
+ defer { lock.unlock() }
+ return value
+ }
+
+ mutating func store(newValue: T) {
+ lock.lock()
+ defer { lock.unlock() }
+ value = newValue
+ }
+}
diff --git a/Sources/Amplitude/Utilities/CodableExtension.swift b/Sources/Amplitude/Utilities/CodableExtension.swift
new file mode 100644
index 00000000..3d9f479f
--- /dev/null
+++ b/Sources/Amplitude/Utilities/CodableExtension.swift
@@ -0,0 +1,187 @@
+//
+// CodableExtension.swift
+//
+//
+// Created by Marvin Liu on 11/23/22.
+// This file extends the current Codable to support custom JSON, like [String: Any].
+
+import Foundation
+
+struct JSONCodingKeys: CodingKey {
+ var stringValue: String
+ var intValue: Int?
+
+ init?(stringValue: String) {
+ self.stringValue = stringValue
+ }
+
+ init?(intValue: Int) {
+ self.init(stringValue: "\(intValue)")
+ self.intValue = intValue
+ }
+}
+
+extension KeyedDecodingContainer {
+ func decode(_ type: [String: Any].Type, forKey key: K) throws -> [String: Any] {
+ let container = try self.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: key)
+ return try container.decode(type)
+ }
+
+ func decode(_ type: [[String: Any]].Type, forKey key: K) throws -> [[String: Any]] {
+ var container = try self.nestedUnkeyedContainer(forKey: key)
+ if let decodedData = try container.decode([Any].self) as? [[String: Any]] {
+ return decodedData
+ } else {
+ return []
+ }
+ }
+
+ func decodeIfPresent(_ type: [String: Any].Type, forKey key: K) throws -> [String: Any]? {
+ guard contains(key) else {
+ return nil
+ }
+ guard try decodeNil(forKey: key) == false else {
+ return nil
+ }
+ return try decode(type, forKey: key)
+ }
+
+ func decode(_ type: [Any].Type, forKey key: K) throws -> [Any] {
+ var container = try self.nestedUnkeyedContainer(forKey: key)
+ return try container.decode(type)
+ }
+
+ func decodeIfPresent(_ type: [Any].Type, forKey key: K) throws -> [Any]? {
+ guard contains(key) else {
+ return nil
+ }
+ guard try decodeNil(forKey: key) == false else {
+ return nil
+ }
+ return try decode(type, forKey: key)
+ }
+
+ func decode(_ type: [String: Any].Type) throws -> [String: Any] {
+ var dictionary = [String: Any]()
+ for key in allKeys {
+ if let boolValue = try? decode(Bool.self, forKey: key) {
+ dictionary[key.stringValue] = boolValue
+ } else if let stringValue = try? decode(String.self, forKey: key) {
+ dictionary[key.stringValue] = stringValue
+ } else if let intValue = try? decode(Int.self, forKey: key) {
+ dictionary[key.stringValue] = intValue
+ } else if let doubleValue = try? decode(Double.self, forKey: key) {
+ dictionary[key.stringValue] = doubleValue
+ } else if let nestedDictionary = try? decode(Dictionary.self, forKey: key) {
+ dictionary[key.stringValue] = nestedDictionary
+ } else if let nestedArray = try? decode(Array.self, forKey: key) {
+ dictionary[key.stringValue] = nestedArray
+ }
+ }
+ return dictionary
+ }
+}
+
+extension UnkeyedDecodingContainer {
+ mutating func decode(_ type: [Any].Type) throws -> [Any] {
+ var array: [Any] = []
+ while isAtEnd == false {
+ // See if the current value in the JSON array is `null` first
+ // and prevent infite recursion with nested arrays.
+ if try decodeNil() {
+ continue
+ } else if let value = try? decode(Bool.self) {
+ array.append(value)
+ } else if let value = try? decode(Double.self) {
+ array.append(value)
+ } else if let value = try? decode(String.self) {
+ array.append(value)
+ } else if let nestedDictionary = try? decode(Dictionary.self) {
+ array.append(nestedDictionary)
+ } else if let nestedArray = try? decode(Array.self) {
+ array.append(nestedArray)
+ }
+ }
+ return array
+ }
+
+ mutating func decode(_ type: [String: Any].Type) throws -> [String: Any] {
+ let nestedContainer = try self.nestedContainer(keyedBy: JSONCodingKeys.self)
+ return try nestedContainer.decode(type)
+ }
+}
+
+extension KeyedEncodingContainer {
+ mutating func encodeIfPresent(_ value: [String: Any]?, forKey key: KeyedEncodingContainer.Key) throws {
+ guard let safeValue = value, !safeValue.isEmpty else {
+ return
+ }
+ var container = self.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: key)
+ for item in safeValue {
+ if let val = item.value as? Int {
+ try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!)
+ } else if let val = item.value as? String {
+ try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!)
+ } else if let val = item.value as? Double {
+ try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!)
+ } else if let val = item.value as? Float {
+ try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!)
+ } else if let val = item.value as? Bool {
+ try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!)
+ } else if let val = item.value as? [Any] {
+ try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!)
+ } else if let val = item.value as? [String: Any] {
+ try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!)
+ }
+ }
+ }
+
+ mutating func encodeIfPresent(_ value: [Any]?, forKey key: KeyedEncodingContainer.Key) throws {
+ guard let safeValue = value else {
+ return
+ }
+ if let val = safeValue as? [Int] {
+ try self.encodeIfPresent(val, forKey: key)
+ } else if let val = safeValue as? [String] {
+ try self.encodeIfPresent(val, forKey: key)
+ } else if let val = safeValue as? [Double] {
+ try self.encodeIfPresent(val, forKey: key)
+ } else if let val = safeValue as? [Float] {
+ try self.encodeIfPresent(val, forKey: key)
+ } else if let val = safeValue as? [Bool] {
+ try self.encodeIfPresent(val, forKey: key)
+ } else if let val = value as? [[String: Any]] {
+ var container = self.nestedUnkeyedContainer(forKey: key)
+ try container.encode(contentsOf: val)
+ }
+ }
+}
+
+extension UnkeyedEncodingContainer {
+ mutating func encode(contentsOf sequence: [[String: Any]]) throws {
+ for dict in sequence {
+ try self.encodeIfPresent(dict)
+ }
+ }
+
+ mutating func encodeIfPresent(_ value: [String: Any]) throws {
+ var container = self.nestedContainer(keyedBy: JSONCodingKeys.self)
+ for item in value {
+ if let val = item.value as? Int {
+ try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!)
+ } else if let val = item.value as? String {
+ try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!)
+ } else if let val = item.value as? Double {
+ try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!)
+ } else if let val = item.value as? Float {
+ try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!)
+ } else if let val = item.value as? Bool {
+ try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!)
+ } else if let val = item.value as? [Any] {
+ try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!)
+ } else if let val = item.value as? [String: Any] {
+ try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!)
+ }
+ }
+ }
+}
diff --git a/Sources/Amplitude/Utilities/EventPipeline.swift b/Sources/Amplitude/Utilities/EventPipeline.swift
index 072c0632..c82d1075 100644
--- a/Sources/Amplitude/Utilities/EventPipeline.swift
+++ b/Sources/Amplitude/Utilities/EventPipeline.swift
@@ -8,22 +8,132 @@
import Foundation
class EventPipeline {
- var amplitude: Amplitude
- var httpClient: HttpClient = HttpClient()
+ let amplitude: Amplitude
+ var httpClient: HttpClient
+ var storage: Storage? { amplitude.storage }
+ @Atomic internal var eventCount: Int = 0
+ internal var flushTimer: QueueTimer?
+ private let uploadsQueue = DispatchQueue(label: "uploadsQueue.amplitude.com")
+
+ internal struct UploadTaskInfo {
+ let events: String
+ let task: URLSessionDataTask
+ // set/used via an extension in iOSLifecycleMonitor.swift
+ typealias CleanupClosure = () -> Void
+ var cleanup: CleanupClosure?
+ }
+ private var uploads = [UploadTaskInfo]()
init(amplitude: Amplitude) {
self.amplitude = amplitude
+ self.httpClient = HttpClient(configuration: amplitude.configuration)
+ self.flushTimer = QueueTimer(interval: getFlushInterval()) { [weak self] in
+ self?.flush()
+ }
}
- func put(event: BaseEvent) {
+ func put(event: BaseEvent, completion: (() -> Void)? = nil) {
+ guard let storage = self.storage else { return }
+ event.attempts += 1
+ Task {
+ do {
+ try await storage.write(key: StorageKey.EVENTS, value: event)
+ eventCount += 1
+ if eventCount >= getFlushCount() {
+ flush()
+ }
+ completion?()
+ } catch {
+ amplitude.logger?.error(message: "Error when storing event: \(error.localizedDescription)")
+ }
+ }
}
- func flush() {
+ func flush(completion: (() -> Void)? = nil) {
+ Task {
+ guard let storage = self.storage else { return }
+ await storage.rollover()
+ guard let eventFiles: [URL]? = await storage.read(key: StorageKey.EVENTS) else { return }
+ amplitude.logger?.log(message: "Start flushing \(eventCount) events")
+ eventCount = 0
+ for eventFile in eventFiles! {
+ guard let eventsString = await storage.getEventsString(eventBlock: eventFile) else {
+ continue
+ }
+ if eventsString.isEmpty {
+ continue
+ }
+ let uploadTask = httpClient.upload(events: eventsString) { [weak self] result in
+ // TODO: handle response and add retry logic
+ switch result {
+ case .success(let status):
+ self?.amplitude.logger?.log(message: "Upload event success: \(status)")
+ case .failure(let error):
+ switch error {
+ case HttpClient.Exception.httpError(let code, let data):
+ self?.amplitude.logger?.log(
+ message: "Upload event error \(code): \(String(decoding: data!, as: UTF8.self))"
+ )
+ default:
+ self?.amplitude.logger?.log(message: "\(error.localizedDescription)")
+ }
+ }
+ }
+ if let upload = uploadTask {
+ add(uploadTask: UploadTaskInfo(events: eventsString, task: upload))
+ }
+ }
+ completion?()
+ }
}
func start() {
+ flushTimer?.resume()
}
func stop() {
+ flushTimer?.suspend()
+ }
+
+ private func getFlushInterval() -> TimeInterval {
+ return TimeInterval.milliseconds(amplitude.configuration.flushIntervalMillis)
+ }
+
+ private func getFlushCount() -> Int {
+ let count = amplitude.configuration.flushQueueSize
+ return count != 0 ? count : 1
+ }
+}
+
+extension EventPipeline {
+ internal func cleanupUploads() {
+ uploadsQueue.sync {
+ let before = uploads.count
+ var newPending = uploads
+ newPending.removeAll { uploadInfo in
+ let shouldRemove = uploadInfo.task.state != .running
+ if shouldRemove, let cleanup = uploadInfo.cleanup {
+ cleanup()
+ }
+ return shouldRemove
+ }
+ uploads = newPending
+ let after = uploads.count
+ amplitude.logger?.log(message: "Cleaned up \(before - after) non-running uploads.")
+ }
+ }
+
+ internal var pendingUploads: Int {
+ var uploadsCount = 0
+ uploadsQueue.sync {
+ uploadsCount = uploads.count
+ }
+ return uploadsCount
+ }
+
+ internal func add(uploadTask: UploadTaskInfo) {
+ uploadsQueue.sync {
+ uploads.append(uploadTask)
+ }
}
}
diff --git a/Sources/Amplitude/Utilities/HttpClient.swift b/Sources/Amplitude/Utilities/HttpClient.swift
index 8b0490ec..40f99092 100644
--- a/Sources/Amplitude/Utilities/HttpClient.swift
+++ b/Sources/Amplitude/Utilities/HttpClient.swift
@@ -1,5 +1,5 @@
//
-// File.swift
+// HttpClient.swift
//
//
// Created by Marvin Liu on 10/28/22.
@@ -8,8 +8,90 @@
import Foundation
class HttpClient {
+ let configuration: Configuration
+ internal var session: URLSession
- func send(event: [BaseEvent]) -> HTTPURLResponse {
- return HTTPURLResponse()
+ init(configuration: Configuration) {
+ self.configuration = configuration
+ // shared instance has limitations but think we are not affected
+ // https://developer.apple.com/documentation/foundation/urlsession/1409000-shared
+ self.session = URLSession.shared
+ }
+
+ func upload(events: String, completion: @escaping (_ result: Result) -> Void) -> URLSessionDataTask? {
+ var sessionTask: URLSessionDataTask?
+ do {
+ let request = try getRequest()
+ let requestData = getRequestData(events: events)
+
+ sessionTask = session.uploadTask(with: request, from: requestData) { data, response, error in
+ if error != nil {
+ completion(.failure(error!))
+ } else if let httpResponse = response as? HTTPURLResponse {
+ switch httpResponse.statusCode {
+ case 1..<300:
+ completion(.success(true))
+ default:
+ completion(.failure(Exception.httpError(code: httpResponse.statusCode, data: data)))
+ }
+ }
+ }
+ sessionTask!.resume()
+ } catch {
+ completion(.failure(Exception.httpError(code: 500, data: nil)))
+ }
+ return sessionTask
+ }
+
+ func getUrl() -> String {
+ if let url = configuration.serverUrl, !url.isEmpty {
+ return url
+ }
+ if configuration.serverZone == ServerZone.EU {
+ return configuration.useBatch ? Constants.EU_BATCH_API_HOST : Constants.EU_DEFAULT_API_HOST
+ }
+ return configuration.useBatch ? Constants.BATCH_API_HOST : Constants.DEFAULT_API_HOST
+ }
+
+ func getRequest() throws -> URLRequest {
+ let url = getUrl()
+ guard let requestUrl = URL(string: url) else {
+ throw Exception.invalidUrl(url: url)
+ }
+ var request = URLRequest(url: requestUrl, timeoutInterval: 60)
+ request.httpMethod = "POST"
+ request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
+ request.addValue("application/json", forHTTPHeaderField: "Accept")
+ return request
+ }
+
+ func getRequestData(events: String) -> Data? {
+ let apiKey = configuration.apiKey
+ var requestPayload = """
+ {"api_key":"\(apiKey)","events":\(events)
+ """
+ if let minIdLength = configuration.minIdLength {
+ requestPayload += """
+ ,"options":{"min_id_length":\(minIdLength)}
+ """
+ }
+ requestPayload += "}"
+ return requestPayload.data(using: .utf8)
+ }
+}
+
+extension HttpClient {
+ enum HttpStatus: Int {
+ case SUCCESS = 200
+ case BAD_REQUEST = 400
+ case TIMEOUT = 408
+ case PAYLOAD_TOO_LARGE = 413
+ case TOO_MANY_REQUESTS = 429
+ case FAILED = 500
+ }
+
+ enum Exception: Error {
+ case invalidUrl(url: String)
+ case httpError(code: Int, data: Data?)
}
}
diff --git a/Sources/Amplitude/Utilities/OutputFileStream.swift b/Sources/Amplitude/Utilities/OutputFileStream.swift
new file mode 100644
index 00000000..5d4309de
--- /dev/null
+++ b/Sources/Amplitude/Utilities/OutputFileStream.swift
@@ -0,0 +1,81 @@
+//
+// OutputFileStream.swift
+//
+//
+// Created by Marvin Liu on 11/22/22.
+//
+// Originally from Segment: https://github.com/segmentio/analytics-swift, under MIT license.
+// Use C library file operations to avoid Swift API deprecation and related bugs.
+
+import Foundation
+
+#if os(Linux)
+ import Glibc
+#else
+ import Darwin.C
+#endif
+
+internal class OutputFileStream {
+ enum OutputStreamError: Error {
+ case invalidPath(String)
+ case unableToOpen(String)
+ case unableToWrite(String)
+ case unableToCreate(String)
+ }
+
+ var filePointer: UnsafeMutablePointer?
+ let fileURL: URL
+
+ init(fileURL: URL) throws {
+ self.fileURL = fileURL
+ let path = fileURL.path
+ guard path.isEmpty == false else { throw OutputStreamError.invalidPath(path) }
+ }
+
+ func create() throws {
+ let path = fileURL.path
+ if FileManager.default.fileExists(atPath: path) {
+ throw OutputStreamError.unableToCreate(path)
+ } else {
+ let created = FileManager.default.createFile(atPath: fileURL.path, contents: nil)
+ if created == false {
+ throw OutputStreamError.unableToCreate(path)
+ } else {
+ try open()
+ }
+ }
+ }
+
+ func open() throws {
+ if filePointer != nil { return }
+ let path = fileURL.path
+ path.withCString { file in
+ filePointer = fopen(file, "w")
+ }
+ guard filePointer != nil else { throw OutputStreamError.unableToOpen(path) }
+ }
+
+ func write(_ data: Data) throws {
+ guard let string = String(data: data, encoding: .utf8) else { return }
+ try write(string)
+ }
+
+ func write(_ string: String) throws {
+ guard string.isEmpty == false else { return }
+ _ = try string.utf8.withContiguousStorageIfAvailable { str in
+ if let baseAddr = str.baseAddress {
+ fwrite(baseAddr, 1, str.count, filePointer)
+ } else {
+ throw OutputStreamError.unableToWrite(fileURL.path)
+ }
+ if ferror(filePointer) != 0 {
+ throw OutputStreamError.unableToWrite(fileURL.path)
+ }
+ }
+ }
+
+ func close() {
+ fclose(filePointer)
+ filePointer = nil
+ }
+}
diff --git a/Sources/Amplitude/Utilities/QueueTimer.swift b/Sources/Amplitude/Utilities/QueueTimer.swift
new file mode 100644
index 00000000..83e29420
--- /dev/null
+++ b/Sources/Amplitude/Utilities/QueueTimer.swift
@@ -0,0 +1,85 @@
+//
+// QueueTimer.swift
+//
+//
+// Created by Marvin Liu on 11/29/22.
+//
+
+import Foundation
+
+internal class QueueTimer {
+ enum State {
+ case suspended
+ case resumed
+ }
+
+ let interval: TimeInterval
+ let timer: DispatchSourceTimer
+ let queue: DispatchQueue
+ let handler: () -> Void
+
+ @Atomic var state: State = .suspended
+
+ static var timers = [QueueTimer]()
+
+ static func schedule(interval: TimeInterval, queue: DispatchQueue = .main, handler: @escaping () -> Void) {
+ let timer = QueueTimer(interval: interval, queue: queue, handler: handler)
+ Self.timers.append(timer)
+ }
+
+ init(interval: TimeInterval, queue: DispatchQueue = .main, handler: @escaping () -> Void) {
+ self.interval = interval
+ self.queue = queue
+ self.handler = handler
+
+ timer = DispatchSource.makeTimerSource(flags: [], queue: queue)
+ timer.schedule(deadline: .now() + self.interval, repeating: self.interval)
+ timer.setEventHandler { [weak self] in
+ self?.handler()
+ }
+ resume()
+ }
+
+ deinit {
+ timer.setEventHandler {
+ // do nothing ...
+ }
+ // if timer is suspended, we must resume if we're going to cancel.
+ timer.cancel()
+ resume()
+ }
+
+ func suspend() {
+ if state == .suspended {
+ return
+ }
+ state = .suspended
+ timer.suspend()
+ }
+
+ func resume() {
+ if state == .resumed {
+ return
+ }
+ state = .resumed
+ timer.resume()
+ }
+}
+
+extension TimeInterval {
+ static func milliseconds(_ value: Int) -> TimeInterval {
+ return TimeInterval(value / 1000)
+ }
+
+ static func seconds(_ value: Int) -> TimeInterval {
+ return TimeInterval(value)
+ }
+
+ static func hours(_ value: Int) -> TimeInterval {
+ return TimeInterval(60 * value)
+ }
+
+ static func days(_ value: Int) -> TimeInterval {
+ return TimeInterval((60 * value) * 24)
+ }
+}
diff --git a/Tests/AmplitudeTests/AmplitudeTests.swift b/Tests/AmplitudeTests/AmplitudeTests.swift
index ecff4f8a..bac81517 100644
--- a/Tests/AmplitudeTests/AmplitudeTests.swift
+++ b/Tests/AmplitudeTests/AmplitudeTests.swift
@@ -26,11 +26,10 @@ final class AmplitudeTests: XCTestCase {
let lastEvent = outputReader.lastEvent
XCTAssertEqual(lastEvent?.library, "\(Constants.SDK_LIBRARY)/\(Constants.SDK_VERSION)")
XCTAssertEqual(lastEvent?.deviceManufacturer, "Apple")
- XCTAssertEqual(lastEvent?.deviceModel, "Simulator")
+ XCTAssertEqual(lastEvent?.deviceModel!.isEmpty, false)
XCTAssertEqual(lastEvent?.ip, "$remote")
XCTAssertNil(lastEvent?.country)
- XCTAssertNotNil(lastEvent?.platform)
- XCTAssertNotNil(lastEvent?.language)
+ XCTAssertEqual(lastEvent?.platform!.isEmpty, false)
+ XCTAssertEqual(lastEvent?.language!.isEmpty, false)
}
-
}
diff --git a/Tests/AmplitudeTests/Events/BaseEventTests.swift b/Tests/AmplitudeTests/Events/BaseEventTests.swift
new file mode 100644
index 00000000..146e8b56
--- /dev/null
+++ b/Tests/AmplitudeTests/Events/BaseEventTests.swift
@@ -0,0 +1,43 @@
+//
+// BaseEventTests.swift
+//
+//
+// Created by Marvin Liu on 11/23/22.
+//
+
+import XCTest
+
+@testable import Amplitude_Swift
+
+final class BaseEventTests: XCTestCase {
+ func testToString() async {
+ let baseEvent = BaseEvent(
+ eventType: "test",
+ eventProperties: [
+ "integer": 1,
+ "string": "stringValue",
+ "array": [1, 2, 3],
+ ]
+ )
+
+ let baseEventData = baseEvent.toString().data(using: .utf8)!
+ let baseEventDict =
+ try? JSONSerialization.jsonObject(with: baseEventData, options: .mutableContainers) as? [String: AnyObject]
+ XCTAssertEqual(
+ baseEventDict!["event_type"] as! String, // swiftlint:disable:this force_cast
+ "test"
+ )
+ XCTAssertEqual(
+ baseEventDict!["event_properties"]!["integer" as NSString] as! Int, // swiftlint:disable:this force_cast
+ 1
+ )
+ XCTAssertEqual(
+ baseEventDict!["event_properties"]!["string" as NSString] as! String, // swiftlint:disable:this force_cast
+ "stringValue"
+ )
+ XCTAssertEqual(
+ baseEventDict!["event_properties"]!["array" as NSString] as! Array, // swiftlint:disable:this force_cast
+ [1, 2, 3]
+ )
+ }
+}
diff --git a/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift b/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift
new file mode 100644
index 00000000..0ca58b54
--- /dev/null
+++ b/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift
@@ -0,0 +1,52 @@
+//
+// PersistentStorageTests.swift
+//
+//
+// Created by Marvin Liu on 11/21/22.
+//
+
+import XCTest
+
+@testable import Amplitude_Swift
+
+final class PersistentStorageTests: XCTestCase {
+ func testIsBasicType() async {
+ let persistentStorage = PersistentStorage()
+ var isValueBasicType = await persistentStorage.isBasicType(value: 111)
+ XCTAssertEqual(isValueBasicType, true)
+
+ isValueBasicType = await persistentStorage.isBasicType(value: 11.11)
+ XCTAssertEqual(isValueBasicType, true)
+
+ isValueBasicType = await persistentStorage.isBasicType(value: true)
+ XCTAssertEqual(isValueBasicType, true)
+
+ isValueBasicType = await persistentStorage.isBasicType(value: "test")
+ XCTAssertEqual(isValueBasicType, true)
+
+ isValueBasicType = await persistentStorage.isBasicType(value: NSString("test"))
+ XCTAssertEqual(isValueBasicType, true)
+
+ isValueBasicType = await persistentStorage.isBasicType(value: nil)
+ XCTAssertEqual(isValueBasicType, true)
+
+ isValueBasicType = await persistentStorage.isBasicType(value: Date())
+ XCTAssertEqual(isValueBasicType, false)
+ }
+
+ func testWrite() async {
+ let persistentStorage = PersistentStorage(apiKey: "xxx-api-key")
+ try? await persistentStorage.write(
+ key: StorageKey.EVENTS,
+ value: BaseEvent(eventType: "test1")
+ )
+ try? await persistentStorage.write(
+ key: StorageKey.EVENTS,
+ value: BaseEvent(eventType: "test2")
+ )
+ let eventFiles: [URL]? = await persistentStorage.read(key: StorageKey.EVENTS)
+ XCTAssertEqual(eventFiles?[0].absoluteString.contains("xxx-api-key.events.index"), true)
+ XCTAssertNotEqual(eventFiles?[0].pathExtension, PersistentStorage.TEMP_FILE_EXTENSION)
+ await persistentStorage.reset()
+ }
+}
diff --git a/Tests/AmplitudeTests/Supports/TestUtilities.swift b/Tests/AmplitudeTests/Supports/TestUtilities.swift
index 6cdfc85f..24f62c59 100644
--- a/Tests/AmplitudeTests/Supports/TestUtilities.swift
+++ b/Tests/AmplitudeTests/Supports/TestUtilities.swift
@@ -1,3 +1,5 @@
+import Foundation
+
@testable import Amplitude_Swift
class TestEnrichmentPlugin: Plugin {
@@ -44,3 +46,61 @@ class OutputReaderPlugin: Plugin {
return event
}
}
+
+actor FakeInMemoryStorage: Storage {
+ var keyValueStore = [String: Any?]()
+ var eventsStore = [URL: [BaseEvent]]()
+ var index = URL(string: "0")!
+
+ func write(key: StorageKey, value: Any?) async throws {
+ switch key {
+ case .EVENTS:
+ if let event = value as? BaseEvent {
+ var chunk = eventsStore[index, default: [BaseEvent]()]
+ chunk.append(event)
+ eventsStore[index] = chunk
+ }
+ default:
+ keyValueStore[key.rawValue] = value
+ }
+ }
+
+ func read(key: StorageKey) async -> T? {
+ var result: T?
+ switch key {
+ case .EVENTS:
+ result = Array(eventsStore.keys) as? T
+ default:
+ result = keyValueStore[key.rawValue] as? T
+ }
+ return result
+ }
+
+ func getEventsString(eventBlock: Any) async -> String? {
+ var content: String?
+ guard let eventBlock = eventBlock as? URL else { return content }
+ content = "["
+ content = content! + (eventsStore[eventBlock] ?? []).map { $0.toString() }.joined(separator: ", ")
+ content = content! + "]"
+ return content
+ }
+
+ func rollover() async {
+ }
+
+ func reset() async {
+ keyValueStore.removeAll()
+ eventsStore.removeAll()
+ }
+}
+
+class FakeHttpClient: HttpClient {
+ var isUploadCalled: Bool = false
+
+ override func upload(events: String, completion: @escaping (_ result: Result) -> Void)
+ -> URLSessionDataTask?
+ {
+ isUploadCalled = true
+ return nil
+ }
+}
diff --git a/Tests/AmplitudeTests/Timeline.swift b/Tests/AmplitudeTests/TimelineTests.swift
similarity index 97%
rename from Tests/AmplitudeTests/Timeline.swift
rename to Tests/AmplitudeTests/TimelineTests.swift
index 98ec2f84..53a1ba91 100644
--- a/Tests/AmplitudeTests/Timeline.swift
+++ b/Tests/AmplitudeTests/TimelineTests.swift
@@ -3,7 +3,7 @@ import XCTest
@testable import Amplitude_Swift
-final class TimelineTest: XCTestCase {
+final class TimelineTests: XCTestCase {
private var timeline: Timeline!
func testTimeline() {
diff --git a/Tests/AmplitudeTests/Utilities/EventPipelineTests.swift b/Tests/AmplitudeTests/Utilities/EventPipelineTests.swift
new file mode 100644
index 00000000..e0f99077
--- /dev/null
+++ b/Tests/AmplitudeTests/Utilities/EventPipelineTests.swift
@@ -0,0 +1,58 @@
+//
+// EventPipelineTests.swift
+//
+//
+// Created by Marvin Liu on 11/30/22.
+//
+
+import XCTest
+
+@testable import Amplitude_Swift
+
+final class EventPipelineTests: XCTestCase {
+ private var configuration: Configuration!
+ private var amplitude: Amplitude!
+ private var eventPipeline: EventPipeline!
+
+ override func setUp() {
+ super.setUp()
+ configuration = Configuration(
+ apiKey: "testApiKey",
+ flushIntervalMillis: 1000,
+ storageProvider: FakeInMemoryStorage()
+ )
+ amplitude = Amplitude(configuration: configuration)
+ eventPipeline = EventPipeline(amplitude: amplitude)
+ }
+
+ func testInit() {
+ XCTAssertEqual(eventPipeline.amplitude.configuration.apiKey, amplitude.configuration.apiKey)
+ }
+
+ func testPutEvent() {
+ let testEvent = BaseEvent(userId: "unit-test", deviceId: "unit-test-machine", eventType: "testEvent")
+
+ let asyncExpectation = expectation(description: "Async function")
+ eventPipeline.put(event: testEvent) {
+ asyncExpectation.fulfill()
+ XCTAssertEqual(self.eventPipeline.eventCount, 1)
+ }
+ XCTAssertEqual(testEvent.attempts, 1)
+ _ = XCTWaiter.wait(for: [asyncExpectation], timeout: 3)
+ }
+
+ func testFlush() async {
+ let testEvent = BaseEvent(userId: "unit-test", deviceId: "unit-test-machine", eventType: "testEvent")
+ try? await eventPipeline.storage?.write(key: StorageKey.EVENTS, value: testEvent)
+
+ let fakeHttpClient = FakeHttpClient(configuration: configuration)
+ eventPipeline.httpClient = fakeHttpClient as HttpClient
+
+ let asyncExpectation = expectation(description: "Async function")
+ eventPipeline.flush {
+ asyncExpectation.fulfill()
+ XCTAssertEqual(fakeHttpClient.isUploadCalled, true)
+ }
+ _ = XCTWaiter.wait(for: [asyncExpectation], timeout: 3)
+ }
+}
diff --git a/Tests/AmplitudeTests/Utilities/HttpClientTests.swift b/Tests/AmplitudeTests/Utilities/HttpClientTests.swift
new file mode 100644
index 00000000..030faca5
--- /dev/null
+++ b/Tests/AmplitudeTests/Utilities/HttpClientTests.swift
@@ -0,0 +1,63 @@
+//
+// HttpClientTests.swift
+//
+//
+// Created by Marvin Liu on 11/24/22.
+//
+
+import XCTest
+
+@testable import Amplitude_Swift
+
+final class HttpClientTests: XCTestCase {
+ private var configuration: Configuration!
+
+ override func setUp() {
+ super.setUp()
+ configuration = Configuration(apiKey: "testApiKey")
+ }
+
+ func testGetUrlWithDefault() {
+ let httpClient = HttpClient(configuration: configuration)
+ XCTAssertEqual(httpClient.getUrl(), Constants.DEFAULT_API_HOST)
+ }
+
+ func testGetUrlWithCustomUrl() {
+ let customUrl = "https//localhost.test"
+ configuration.serverUrl = customUrl
+ let httpClient = HttpClient(configuration: configuration)
+ XCTAssertEqual(httpClient.getUrl(), customUrl)
+ }
+
+ func testGetRequestWithInvalidUrl() {
+ let invalidUrl = "local host"
+ configuration.serverUrl = invalidUrl
+ let httpClient = HttpClient(configuration: configuration)
+
+ XCTAssertThrowsError(try httpClient.getRequest()) { error in
+ guard case HttpClient.Exception.invalidUrl(let url) = error else {
+ return XCTFail("not getting invalidUrl error")
+ }
+ XCTAssertEqual(url, invalidUrl)
+ }
+ }
+
+ func testUploadWithInvalidApiKey() {
+ // TODO: currently this test is sending request to real Amplitude host, update to mock for better stability
+ let httpClient = HttpClient(configuration: configuration)
+ let asyncExpectation = expectation(description: "Async function")
+ let event1 = BaseEvent(userId: "unit-test user", deviceId: "unit-test device", eventType: "unit-test event")
+ _ = httpClient.upload(events: "[\(event1.toString())]") { result in
+ guard case .failure(let error) = result else {
+ return XCTFail("not getting upload failure")
+ }
+ guard case HttpClient.Exception.httpError(let code, let data) = error else {
+ return XCTFail("not getting httpError error")
+ }
+ XCTAssertEqual(code, 400)
+ XCTAssertTrue(String(decoding: data!, as: UTF8.self).contains("Invalid API key: testApiKey"))
+ asyncExpectation.fulfill()
+ }
+ _ = XCTWaiter.wait(for: [asyncExpectation], timeout: 5)
+ }
+}