Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add offline mode #111

Merged
merged 10 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Amplitude-Swift.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@
8EDECEC5F98F9974DF3E576F /* ObjCIdentify.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EDEC5A50E197C9C5067C19E /* ObjCIdentify.swift */; };
8EDECF81C2B1B38D472FD7EF /* ObjCConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EDECEC5AAE15FD05E76359A /* ObjCConfiguration.swift */; };
8EDECFCCF4219767F26210D6 /* Sessions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EDEC2B8B38E04CDB51F0E83 /* Sessions.swift */; };
B6CCC6CD2B6B14510004B203 /* ObjCNetworkConnectivityCheckerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CCC6CC2B6B14510004B203 /* ObjCNetworkConnectivityCheckerPlugin.swift */; };
B6EDB4D02B643C8400454B90 /* NetworkConnectivityCheckerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6EDB4CF2B643C8400454B90 /* NetworkConnectivityCheckerPlugin.swift */; };
B6F338A32B685793006179E2 /* NetworkConnectivityCheckerPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F338A22B685793006179E2 /* NetworkConnectivityCheckerPluginTests.swift */; };
BA0359CA2A51585D007C383B /* legacy_v3.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = BA0359C92A51585D007C383B /* legacy_v3.sqlite */; };
BA0639F62A4DD491000F1CEE /* LegacyDatabaseStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA0639F52A4DD491000F1CEE /* LegacyDatabaseStorage.swift */; };
BA1EC0F42A9F2FC700C2D547 /* DefaultTrackingOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA1EC0F32A9F2FC700C2D547 /* DefaultTrackingOptionsTests.swift */; };
Expand Down Expand Up @@ -169,7 +172,10 @@
8EDECE07F682FAAE47F77B24 /* ObjCEventOptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjCEventOptions.swift; sourceTree = "<group>"; };
8EDECEC5AAE15FD05E76359A /* ObjCConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjCConfiguration.swift; sourceTree = "<group>"; };
8EDECF8CF745F7339B65D6DB /* ObjCStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjCStorage.swift; sourceTree = "<group>"; };
B6CCC6CC2B6B14510004B203 /* ObjCNetworkConnectivityCheckerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjCNetworkConnectivityCheckerPlugin.swift; sourceTree = "<group>"; };
B6DF481F2B5B45BE00B3E6AA /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
B6EDB4CF2B643C8400454B90 /* NetworkConnectivityCheckerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkConnectivityCheckerPlugin.swift; sourceTree = "<group>"; };
B6F338A22B685793006179E2 /* NetworkConnectivityCheckerPluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkConnectivityCheckerPluginTests.swift; sourceTree = "<group>"; };
BA0359C92A51585D007C383B /* legacy_v3.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = legacy_v3.sqlite; sourceTree = "<group>"; };
BA0639F52A4DD491000F1CEE /* LegacyDatabaseStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyDatabaseStorage.swift; sourceTree = "<group>"; };
BA1EC0F32A9F2FC700C2D547 /* DefaultTrackingOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultTrackingOptionsTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -276,6 +282,7 @@
8EDECAFD8271434E8DC7BA78 /* ObjC */ = {
isa = PBXGroup;
children = (
B6CCC6CC2B6B14510004B203 /* ObjCNetworkConnectivityCheckerPlugin.swift */,
8EDECEC5AAE15FD05E76359A /* ObjCConfiguration.swift */,
8EDECB1FA2AFF022A19104EE /* ObjCPlan.swift */,
8EDEC500EBDA8B813056E2DB /* ObjCIngestionMetadata.swift */,
Expand Down Expand Up @@ -308,6 +315,14 @@
path = Migration;
sourceTree = "<group>";
};
B6F3389F2B6854A8006179E2 /* Plugins */ = {
isa = PBXGroup;
children = (
B6F338A22B685793006179E2 /* NetworkConnectivityCheckerPluginTests.swift */,
);
path = Plugins;
sourceTree = "<group>";
};
OBJ_13 /* Events */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -338,6 +353,7 @@
8EDEC448C42C8C0A464FAA15 /* BasePlugins.swift */,
8EDECD39BAA97DD4320C0AA5 /* AnalyticsConnectorPlugin.swift */,
8EDEC48916EFEF6D5B3EEF9A /* AnalyticsConnectorIdentityPlugin.swift */,
B6EDB4CF2B643C8400454B90 /* NetworkConnectivityCheckerPlugin.swift */,
);
path = Plugins;
sourceTree = "<group>";
Expand Down Expand Up @@ -421,6 +437,7 @@
OBJ_53 /* Tests */ = {
isa = PBXGroup;
children = (
B6F3389F2B6854A8006179E2 /* Plugins */,
OBJ_54 /* AmplitudeTests.swift */,
OBJ_55 /* ConfigurationTests.swift */,
OBJ_56 /* ConsoleLoggerTests.swift */,
Expand Down Expand Up @@ -645,6 +662,7 @@
OBJ_153 /* TimelineTests.swift in Sources */,
OBJ_154 /* TypesTests.swift in Sources */,
OBJ_155 /* EventPipelineTests.swift in Sources */,
B6F338A32B685793006179E2 /* NetworkConnectivityCheckerPluginTests.swift in Sources */,
OBJ_156 /* HttpClientTests.swift in Sources */,
D01043612B6C5A8500F8173C /* SandboxHelperTests.swift in Sources */,
OBJ_157 /* PersistentStorageResponseHandlerTests.swift in Sources */,
Expand Down Expand Up @@ -685,6 +703,7 @@
OBJ_108 /* IOSLifecycleMonitor.swift in Sources */,
OBJ_109 /* WatchOSLifecycleMonitor.swift in Sources */,
OBJ_110 /* State.swift in Sources */,
B6CCC6CD2B6B14510004B203 /* ObjCNetworkConnectivityCheckerPlugin.swift in Sources */,
OBJ_111 /* InMemoryStorage.swift in Sources */,
OBJ_112 /* PersistentStorage.swift in Sources */,
BA9BEA4B299FB43B00BC0F7C /* IdentifyInterceptor.swift in Sources */,
Expand Down Expand Up @@ -727,6 +746,7 @@
8EDECA4DAFA67CD4785D0161 /* ObjCDefaultTrackingOptions.swift in Sources */,
8EDEC43520B2DCF584F1035D /* ObjCScreenViewedEvent.swift in Sources */,
8EDECC1FC97DDF0BEFAA96E7 /* ObjCDeepLinkOpenedEvent.swift in Sources */,
B6EDB4D02B643C8400454B90 /* NetworkConnectivityCheckerPlugin.swift in Sources */,
8EDEC5F7208B1C327C8703D7 /* ObjCStorage.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
3 changes: 3 additions & 0 deletions Sources/Amplitude/Amplitude.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public class Amplitude {
state.userId = userId
}

if self.configuration.offline != NetworkConnectivityCheckerPlugin.Disabled {
_ = add(plugin: NetworkConnectivityCheckerPlugin())
}
// required plugin for specific platform, only has lifecyclePlugin now
if let requiredPlugin = VendorSystem.current.requiredPlugin {
_ = add(plugin: requiredPlugin)
Expand Down
5 changes: 4 additions & 1 deletion Sources/Amplitude/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public class Configuration {
public var identifyBatchIntervalMillis: Int
public internal(set) var migrateLegacyData: Bool
public var defaultTracking: DefaultTrackingOptions
public var offline: Bool?

public init(
apiKey: String,
Expand Down Expand Up @@ -60,7 +61,8 @@ public class Configuration {
// `trackingSessionEvents` has been replaced by `defaultTracking.sessions`
defaultTracking: DefaultTrackingOptions = DefaultTrackingOptions(),
identifyBatchIntervalMillis: Int = Constants.Configuration.IDENTIFY_BATCH_INTERVAL_MILLIS,
migrateLegacyData: Bool = true
migrateLegacyData: Bool = true,
offline: Bool? = false
) {
let normalizedInstanceName = instanceName == "" ? Constants.Configuration.DEFAULT_INSTANCE : instanceName

Expand Down Expand Up @@ -93,6 +95,7 @@ public class Configuration {
self.migrateLegacyData = migrateLegacyData
// Logging is OFF by default
self.loggerProvider.logLevel = logLevel.rawValue
self.offline = offline
}

func isValid() -> Bool {
Expand Down
6 changes: 5 additions & 1 deletion Sources/Amplitude/Mediator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ internal class Mediator {

internal func remove(plugin: Plugin) {
plugins.removeAll { (storedPlugin) -> Bool in
return storedPlugin === plugin
if storedPlugin === plugin {
storedPlugin.teardown()
return true
}
return false
}
}

Expand Down
10 changes: 10 additions & 0 deletions Sources/Amplitude/ObjC/ObjCConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -272,4 +272,14 @@ public class ObjCConfiguration: NSObject {
configuration.migrateLegacyData = value
}
}

@objc
public var offline: NSNumber? {
get {
return configuration.offline as NSNumber?
}
set(value) {
configuration.offline = value?.boolValue
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

@objc(AMPNetworkConnectivityCheckerPlugin)
public class ObjCNetworkConnectivityCheckerPlugin: NSObject {
@objc public static let Disabled: NSNumber? = nil
}
4 changes: 4 additions & 0 deletions Sources/Amplitude/Plugins/BasePlugins.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ open class BasePlugin {
open func execute(event: BaseEvent) -> BaseEvent? {
return event
}

public func teardown(){
// Clean up any resources from setup if necessary
}
}

open class BeforePlugin: BasePlugin, Plugin {
Expand Down
74 changes: 74 additions & 0 deletions Sources/Amplitude/Plugins/NetworkConnectivityCheckerPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// NetworkConnectivityCheckerPlugin.swift
// Amplitude-Swift
//
// Created by Xinyi.Ye on 1/26/24.
//

import Foundation
import Network
import Combine

// Define a custom struct to represent network path status
public struct NetworkPath {
public var status: NWPath.Status

public init(status: NWPath.Status) {
self.status = status
}
}

// Protocol for creating network paths
protocol PathCreationProtocol {
var networkPathPublisher: AnyPublisher<NetworkPath, Never>? { get }
func start()
}

// Implementation of PathCreationProtocol using NWPathMonitor
final class PathCreation: PathCreationProtocol {
public var networkPathPublisher: AnyPublisher<NetworkPath, Never>?
private let subject = PassthroughSubject<NWPath, Never>()
private let monitor = NWPathMonitor()

func start() {
monitor.pathUpdateHandler = subject.send
networkPathPublisher = subject
.map { NetworkPath(status: $0.status) }
.eraseToAnyPublisher()
monitor.start(queue: .main)
}
}

open class NetworkConnectivityCheckerPlugin: BeforePlugin {
public static let Disabled: Bool? = nil
var pathCreation: PathCreationProtocol
private var pathUpdateCancellable: AnyCancellable?

init(pathCreation: PathCreationProtocol = PathCreation()) {
self.pathCreation = pathCreation
super.init()
}

open override func setup(amplitude: Amplitude) {
super.setup(amplitude: amplitude)
amplitude.logger?.debug(message: "Installing NetworkConnectivityCheckerPlugin, offline feature should be supported.")

pathCreation.start()
pathUpdateCancellable = pathCreation.networkPathPublisher?
.sink(receiveValue: { [weak self] networkPath in
let isOffline = !(networkPath.status == .satisfied)
if self?.amplitude?.configuration.offline == isOffline {
return
}
self?.amplitude?.logger?.debug(message: "Network connectivity changed to \(isOffline ? "offline" : "online").")
self?.amplitude?.configuration.offline = isOffline
if !isOffline {
amplitude.flush()
}
})
}

open override func teardown() {
pathUpdateCancellable?.cancel()
}
}
5 changes: 5 additions & 0 deletions Sources/Amplitude/Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ public protocol Plugin: AnyObject {
var type: PluginType { get }
func setup(amplitude: Amplitude)
func execute(event: BaseEvent) -> BaseEvent?
func teardown()
}

public protocol EventPlugin: Plugin {
Expand All @@ -116,6 +117,10 @@ extension Plugin {

public func setup(amplitude: Amplitude) {
}

public func teardown(){
// Clean up any resources from setup if necessary
}
}

public protocol ResponseHandler {
Expand Down
5 changes: 5 additions & 0 deletions Sources/Amplitude/Utilities/EventPipeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ public class EventPipeline {
}

func flush(completion: (() -> Void)? = nil) {
if self.amplitude.configuration.offline == true {
self.amplitude.logger?.debug(message: "Skipping flush while offline.")
return
}

amplitude.logger?.log(message: "Start flushing \(eventCount) events")
eventCount = 0
guard let storage = self.storage else { return }
Expand Down
4 changes: 4 additions & 0 deletions Tests/AmplitudeTests/AmplitudeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,10 @@ final class AmplitudeTests: XCTestCase {
])
}

func testInit_Offline() {
XCTAssertEqual(Amplitude(configuration: configuration).configuration.offline, false)
}

func getDictionary(_ props: [String: Any?]) -> NSDictionary {
return NSDictionary(dictionary: props as [AnyHashable: Any])
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// NetworkConnectivityCheckerPluginTests.swift
// Amplitude-SwiftTests
//
// Created by Xinyi.Ye on 1/29/24.
//

import XCTest

@testable import AmplitudeSwift

final class NetworkConnectivityCheckerPluginTests: XCTestCase {
private var mockPathCreation: MockPathCreation!
private var plugin: NetworkConnectivityCheckerPlugin!
private var amplitude: Amplitude!

override func setUp() {
super.setUp()
mockPathCreation = MockPathCreation()
amplitude = Amplitude(configuration: Configuration(apiKey: "test-api-key"))
plugin = NetworkConnectivityCheckerPlugin(pathCreation: mockPathCreation)
plugin.setup(amplitude: amplitude)
}

func testNetworkBecomesOnline() {
mockPathCreation.simulateNetworkChange(status: .satisfied)
XCTAssertEqual(amplitude.configuration.offline, false)
}

func testNetworkBecomesOffline() {
mockPathCreation.simulateNetworkChange(status: .unsatisfied)
XCTAssertEqual(amplitude.configuration.offline, true)
}
}
17 changes: 17 additions & 0 deletions Tests/AmplitudeTests/Supports/TestUtilities.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Foundation
import XCTest
import Network
import Combine

@testable import AmplitudeSwift

Expand Down Expand Up @@ -260,3 +262,18 @@ class FakePersistentStorageAppSandboxEnabled: PersistentStorage {
return true
}
}

final class MockPathCreation: PathCreationProtocol {
var networkPathPublisher: AnyPublisher<NetworkPath, Never>?
private let subject = PassthroughSubject<NetworkPath, Never>()

func start() {
networkPathPublisher = subject.eraseToAnyPublisher()
}

// Method to simulate network change in tests
func simulateNetworkChange(status: NWPath.Status) {
let networkPath = NetworkPath(status: status)
subject.send(networkPath)
}
}
14 changes: 14 additions & 0 deletions Tests/AmplitudeTests/Utilities/EventPipelineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,18 @@ final class EventPipelineTests: XCTestCase {
XCTAssertEqual(uploadedEvents?.count, 1)
XCTAssertEqual(uploadedEvents![0].eventType, "testEvent")
}

func testFlushWhenOffline() {
let testEvent = BaseEvent(userId: "unit-test", deviceId: "unit-test-machine", eventType: "testEvent")
try? pipeline.storage?.write(key: StorageKey.EVENTS, value: testEvent)

XCTAssertEqual(httpClient.uploadCount, 0)
XCTAssertEqual(pipeline.amplitude.configuration.offline, false)

pipeline.amplitude.configuration.offline = true
pipeline.flush()

XCTAssertEqual(pipeline.amplitude.configuration.offline, true)
XCTAssertEqual(httpClient.uploadCount, 0, "There should be no uploads when offline")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ final class IdentifyInterceptorTests: XCTestCase {
private var interceptor: TestIdentifyInterceptor!
private var configuration: Configuration!
private var pipeline: EventPipeline!
private var mockPathCreation: MockPathCreation!

override func setUp() {
super.setUp()
Expand All @@ -21,9 +22,12 @@ final class IdentifyInterceptorTests: XCTestCase {
apiKey: "testApiKey",
storageProvider: storage,
identifyStorageProvider: identifyStorage,
identifyBatchIntervalMillis: identifyBatchIntervalMillis
identifyBatchIntervalMillis: identifyBatchIntervalMillis,
offline: NetworkConnectivityCheckerPlugin.Disabled
)
let amplitude = Amplitude(configuration: configuration)
mockPathCreation = MockPathCreation()
amplitude.add(plugin: NetworkConnectivityCheckerPlugin(pathCreation: mockPathCreation))
httpClient = FakeHttpClient(configuration: configuration)
pipeline = EventPipeline(amplitude: amplitude)
pipeline.httpClient = httpClient
Expand Down
Loading