Skip to content

Commit

Permalink
feat: add offline mode (#111)
Browse files Browse the repository at this point in the history
  • Loading branch information
Mercy811 authored Feb 8, 2024
1 parent 6e8c1b4 commit 37b337d
Show file tree
Hide file tree
Showing 15 changed files with 210 additions and 3 deletions.
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

0 comments on commit 37b337d

Please sign in to comment.