added iOS source code
[wl-app.git] / iOS / Pods / MatomoTracker / MatomoTracker / MatomoTracker.swift
1 import Foundation
2
3 /// The Matomo Tracker is a Swift framework to send analytics to the Matomo server.
4 ///
5 /// ## Basic Usage
6 /// * Use the track methods to track your views, events and more.
7 final public class MatomoTracker: NSObject {
8     
9     /// Defines if the user opted out of tracking. When set to true, every event
10     /// will be discarded immediately. This property is persisted between app launches.
11     @objc public var isOptedOut: Bool {
12         get {
13             return matomoUserDefaults.optOut
14         }
15         set {
16             matomoUserDefaults.optOut = newValue
17         }
18     }
19     
20     /// Will be used to associate all future events with a given userID. This property
21     /// is persisted between app launches.
22     @objc public var visitorId: String? {
23         get {
24             return matomoUserDefaults.visitorUserId
25         }
26         set {
27             matomoUserDefaults.visitorUserId = newValue
28             visitor = Visitor.current(in: matomoUserDefaults)
29         }
30     }
31     
32     internal var matomoUserDefaults: MatomoUserDefaults
33     private let dispatcher: Dispatcher
34     private var queue: Queue
35     internal let siteId: String
36
37     internal var dimensions: [CustomDimension] = []
38     
39     internal var customVariables: [CustomVariable] = []
40     
41     /// This logger is used to perform logging of all sorts of Matomo related information.
42     /// Per default it is a `DefaultLogger` with a `minLevel` of `LogLevel.warning`. You can
43     /// set your own Logger with a custom `minLevel` or a complete custom logging mechanism.
44     @objc public var logger: Logger = DefaultLogger(minLevel: .warning)
45     
46     /// The `contentBase` is used to build the url of an Event, if the Event hasn't got a url set.
47     /// This autogenerated url will then have the format <contentBase>/<actions>.
48     /// Per default the `contentBase` is http://<Application Bundle Name>.
49     /// Set the `contentBase` to nil, if you don't want to auto generate a url.
50     @objc public var contentBase: URL?
51     
52     internal static var _sharedInstance: MatomoTracker?
53     
54     /// Create and Configure a new Tracker
55     ///
56     /// - Parameters:
57     ///   - siteId: The unique site id generated by the server when a new site was created.
58     ///   - queue: The queue to use to store all analytics until it is dispatched to the server.
59     ///   - dispatcher: The dispatcher to use to transmit all analytics to the server.
60     required public init(siteId: String, queue: Queue, dispatcher: Dispatcher) {
61         self.siteId = siteId
62         self.queue = queue
63         self.dispatcher = dispatcher
64         self.contentBase = URL(string: "http://\(Application.makeCurrentApplication().bundleIdentifier ?? "unknown")")
65         self.matomoUserDefaults = MatomoUserDefaults(suiteName: "\(siteId)\(dispatcher.baseURL.absoluteString)")
66         self.visitor = Visitor.current(in: matomoUserDefaults)
67         self.session = Session.current(in: matomoUserDefaults)
68         super.init()
69         startNewSession()
70         startDispatchTimer()
71     }
72     
73     /// Create and Configure a new Tracker
74     ///
75     /// A volatile memory queue will be used to store the analytics data. All not transmitted data will be lost when the application gets terminated.
76     /// The URLSessionDispatcher will be used to transmit the data to the server.
77     ///
78     /// - Parameters:
79     ///   - siteId: The unique site id generated by the server when a new site was created.
80     ///   - baseURL: The url of the Matomo server. This url has to end in `piwik.php`.
81     ///   - userAgent: An optional parameter for custom user agent.
82     @objc convenience public init(siteId: String, baseURL: URL, userAgent: String? = nil) {
83         assert(baseURL.absoluteString.hasSuffix("piwik.php"), "The baseURL is expected to end in piwik.php")
84         
85         let queue = MemoryQueue()
86         let dispatcher = URLSessionDispatcher(baseURL: baseURL, userAgent: userAgent)
87         self.init(siteId: siteId, queue: queue, dispatcher: dispatcher)
88     }
89     
90     internal func queue(event: Event) {
91         guard Thread.isMainThread else {
92             DispatchQueue.main.sync {
93                 self.queue(event: event)
94             }
95             return
96         }
97         guard !isOptedOut else { return }
98         logger.verbose("Queued event: \(event)")
99         queue.enqueue(event: event)
100         nextEventStartsANewSession = false
101     }
102     
103     // MARK: dispatching
104     
105     private let numberOfEventsDispatchedAtOnce = 20
106     private(set) var isDispatching = false
107     
108     
109     /// Manually start the dispatching process. You might want to call this method in AppDelegates `applicationDidEnterBackground` to transmit all data
110     /// whenever the user leaves the application.
111     @objc public func dispatch() {
112         guard !isDispatching else {
113             logger.verbose("MatomoTracker is already dispatching.")
114             return
115         }
116         guard queue.eventCount > 0 else {
117             logger.info("No need to dispatch. Dispatch queue is empty.")
118             startDispatchTimer()
119             return
120         }
121         logger.info("Start dispatching events")
122         isDispatching = true
123         dispatchBatch()
124     }
125     
126     private func dispatchBatch() {
127         guard Thread.isMainThread else {
128             DispatchQueue.main.sync {
129                 self.dispatchBatch()
130             }
131             return
132         }
133         queue.first(limit: numberOfEventsDispatchedAtOnce) { events in
134             guard events.count > 0 else {
135                 // there are no more events queued, finish dispatching
136                 self.isDispatching = false
137                 self.startDispatchTimer()
138                 self.logger.info("Finished dispatching events")
139                 return
140             }
141             self.dispatcher.send(events: events, success: {
142                 DispatchQueue.main.async {
143                     self.queue.remove(events: events, completion: {
144                         self.logger.info("Dispatched batch of \(events.count) events.")
145                         DispatchQueue.main.async {
146                             self.dispatchBatch()
147                         }
148                     })
149                 }
150             }, failure: { error in
151                 self.isDispatching = false
152                 self.startDispatchTimer()
153                 self.logger.warning("Failed dispatching events with error \(error)")
154             })
155         }
156     }
157     
158     // MARK: dispatch timer
159     
160     @objc public var dispatchInterval: TimeInterval = 30.0 {
161         didSet {
162             startDispatchTimer()
163         }
164     }
165     private var dispatchTimer: Timer?
166     
167     private func startDispatchTimer() {
168         guard Thread.isMainThread else {
169             DispatchQueue.main.sync {
170                 self.startDispatchTimer()
171             }
172             return
173         }
174         guard dispatchInterval > 0  else { return } // Discussion: Do we want the possibility to dispatch synchronous? That than would be dispatchInterval = 0
175         if let dispatchTimer = dispatchTimer {
176             dispatchTimer.invalidate()
177             self.dispatchTimer = nil
178         }
179         self.dispatchTimer = Timer.scheduledTimer(timeInterval: dispatchInterval, target: self, selector: #selector(dispatch), userInfo: nil, repeats: false)
180     }
181     
182     internal var visitor: Visitor
183     internal var session: Session
184     internal var nextEventStartsANewSession = true
185
186     internal var campaignName: String? = nil
187     internal var campaignKeyword: String? = nil
188     
189     /// Adds the name and keyword for the current campaign.
190     /// This is usually very helpfull if you use deeplinks into your app.
191     ///
192     /// More information on campaigns: [https://matomo.org/docs/tracking-campaigns/](https://matomo.org/docs/tracking-campaigns/)
193     ///
194     /// - Parameters:
195     ///   - name: The name of the campaign.
196     ///   - keyword: The keyword of the campaign.
197     @objc public func trackCampaign(name: String?, keyword: String?) {
198         campaignName = name
199         campaignKeyword = keyword
200     }
201     
202     /// There are several ways to track content impressions and interactions manually, semi-automatically and automatically. Please be aware that content impressions will be tracked using bulk tracking which will always send a POST request, even if  GET is configured which is the default. For more details have a look at the in-depth guide to Content Tracking.
203     /// More information on content: [https://matomo.org/docs/content-tracking/](https://matomo.org/docs/content-tracking/)
204     ///
205     /// - Parameters:
206     ///   - name: The name of the content. For instance 'Ad Foo Bar'
207     ///   - piece: The actual content piece. For instance the path to an image, video, audio, any text
208     ///   - target: The target of the content. For instance the URL of a landing page
209     ///   - interaction: The name of the interaction with the content. For instance a 'click'
210     @objc public func trackContentImpression(name: String, piece: String?, target: String?) {
211         track(Event(tracker: self, action: [], contentName: name, contentPiece: piece, contentTarget: target))
212     }
213     @objc public func trackContentInteraction(name: String, interaction: String, piece: String?, target: String?) {
214         track(Event(tracker: self, action: [], contentName: name, contentInteraction: interaction, contentPiece: piece, contentTarget: target))
215     }
216 }
217
218 extension MatomoTracker {
219     /// Starts a new Session
220     ///
221     /// Use this function to manually start a new Session. A new Session will be automatically created only on app start.
222     /// You can use the AppDelegates `applicationWillEnterForeground` to start a new visit whenever the app enters foreground.
223     public func startNewSession() {
224         matomoUserDefaults.previousVisit = matomoUserDefaults.currentVisit
225         matomoUserDefaults.currentVisit = Date()
226         matomoUserDefaults.totalNumberOfVisits += 1
227         self.session = Session.current(in: matomoUserDefaults)
228     }
229 }
230
231 extension MatomoTracker {
232     
233     /// Tracks a custom Event
234     ///
235     /// - Parameter event: The event that should be tracked.
236     public func track(_ event: Event) {
237         queue(event: event)
238         
239         if (event.campaignName == campaignName && event.campaignKeyword == campaignKeyword) {
240             campaignName = nil
241             campaignKeyword = nil
242         }
243     }
244     
245     /// Tracks a screenview.
246     ///
247     /// This method can be used to track hierarchical screen names, e.g. screen/settings/register. Use this to create a hierarchical and logical grouping of screen views in the Matomo web interface.
248     ///
249     /// - Parameter view: An array of hierarchical screen names.
250     /// - Parameter url: The optional url of the page that was viewed.
251     /// - Parameter dimensions: An optional array of dimensions, that will be set only in the scope of this view.
252     public func track(view: [String], url: URL? = nil, dimensions: [CustomDimension] = []) {
253         let event = Event(tracker: self, action: view, url: url, dimensions: dimensions)
254         queue(event: event)
255     }
256     
257     /// Tracks an event as described here: https://matomo.org/docs/event-tracking/
258     ///
259     /// - Parameters:
260     ///   - category: The Category of the Event
261     ///   - action: The Action of the Event
262     ///   - name: The optional name of the Event
263     ///   - value: The optional value of the Event
264     ///   - dimensions: An optional array of dimensions, that will be set only in the scope of this event.
265     ///   - url: The optional url of the page that was viewed.
266     public func track(eventWithCategory category: String, action: String, name: String? = nil, value: Float? = nil, dimensions: [CustomDimension] = [], url: URL? = nil) {
267         let event = Event(tracker: self, action: [], url: url, eventCategory: category, eventAction: action, eventName: name, eventValue: value, dimensions: dimensions)
268         queue(event: event)
269     }
270 }
271
272 extension MatomoTracker {
273     
274     /// Tracks a search result page as described here: https://matomo.org/docs/site-search/
275     ///
276     /// - Parameters:
277     ///   - query: The string the user was searching for
278     ///   - category: An optional category which the user was searching in
279     ///   - resultCount: The number of results that were displayed for that search
280     ///   - dimensions: An optional array of dimensions, that will be set only in the scope of this event.
281     ///   - url: The optional url of the page that was viewed.
282     public func trackSearch(query: String, category: String?, resultCount: Int?, dimensions: [CustomDimension] = [], url: URL? = nil) {
283         let event = Event(tracker: self, action: [], url: url, searchQuery: query, searchCategory: category, searchResultsCount: resultCount, dimensions: dimensions)
284         queue(event: event)
285     }
286 }
287
288 extension MatomoTracker {
289     /// Set a permanent custom dimension.
290     ///
291     /// Use this method to set a dimension that will be send with every event. This is best for Custom Dimensions in scope "Visit". A typical example could be any device information or the version of the app the visitor is using.
292     ///
293     /// For more information on custom dimensions visit https://matomo.org/docs/custom-dimensions/
294     ///
295     /// - Parameter value: The value you want to set for this dimension.
296     /// - Parameter index: The index of the dimension. A dimension with this index must be setup in the Matomo backend.
297     @available(*, deprecated, message: "use setDimension: instead")
298     public func set(value: String, forIndex index: Int) {
299         let dimension = CustomDimension(index: index, value: value)
300         remove(dimensionAtIndex: dimension.index)
301         dimensions.append(dimension)
302     }
303     
304     /// Set a permanent custom dimension.
305     ///
306     /// Use this method to set a dimension that will be send with every event. This is best for Custom Dimensions in scope "Visit". A typical example could be any device information or the version of the app the visitor is using.
307     ///
308     /// For more information on custom dimensions visit https://matomo.org/docs/custom-dimensions/
309     ///
310     /// - Parameter dimension: The Dimension to set
311     public func set(dimension: CustomDimension) {
312         remove(dimensionAtIndex: dimension.index)
313         dimensions.append(dimension)
314     }
315     
316     /// Set a permanent custom dimension by value and index.
317     ///
318     /// This is a convenience alternative to set(dimension:) and calls the exact same functionality. Also, it is accessible from Objective-C.
319     ///
320     /// - Parameter value: The value for the new Custom Dimension
321     /// - Parameter forIndex: The index of the new Custom Dimension
322     @objc public func setDimension(_ value: String, forIndex index: Int) {
323         set(dimension: CustomDimension( index: index, value: value ));
324     }
325     
326     /// Removes a previously set custom dimension.
327     ///
328     /// Use this method to remove a dimension that was set using the `set(value: String, forDimension index: Int)` method.
329     ///
330     /// - Parameter index: The index of the dimension.
331     @objc public func remove(dimensionAtIndex index: Int) {
332         dimensions = dimensions.filter({ dimension in
333             dimension.index != index
334         })
335     }
336 }
337
338
339 extension MatomoTracker {
340
341     /// Set a permanent new Custom Variable.
342     ///
343     /// - Parameter dimension: The Custom Variable to set
344     public func set(customVariable: CustomVariable) {
345         removeCustomVariable(withIndex: customVariable.index)
346         customVariables.append(customVariable)
347     }
348
349     /// Set a permanent new Custom Variable.
350     ///
351     /// - Parameter name: The index of the new Custom Variable
352     /// - Parameter name: The name of the new Custom Variable
353     /// - Parameter value: The value of the new Custom Variable
354     @objc public func setCustomVariable(withIndex index: UInt, name: String, value: String) {
355         set(customVariable: CustomVariable(index: index, name: name, value: value))
356     }
357     
358     /// Remove a previously set Custom Variable.
359     ///
360     /// - Parameter index: The index of the Custom Variable.
361     @objc public func removeCustomVariable(withIndex index: UInt) {
362         customVariables = customVariables.filter { $0.index != index }
363     }
364 }
365
366 // Objective-c compatibility extension
367 extension MatomoTracker {
368     @objc public func track(view: [String], url: URL? = nil) {
369         track(view: view, url: url, dimensions: [])
370     }
371     
372     @objc public func track(eventWithCategory category: String, action: String, name: String? = nil, number: NSNumber? = nil, url: URL? = nil) {
373         let value = number == nil ? nil : number!.floatValue
374         track(eventWithCategory: category, action: action, name: name, value: value, url: url)
375     }
376     
377     @available(*, deprecated, message: "use trackEventWithCategory:action:name:number:url instead")
378     @objc public func track(eventWithCategory category: String, action: String, name: String? = nil, number: NSNumber? = nil) {
379         track(eventWithCategory: category, action: action, name: name, number: number, url: nil)
380     }
381     
382     @objc public func trackSearch(query: String, category: String?, resultCount: Int, url: URL? = nil) {
383         trackSearch(query: query, category: category, resultCount: resultCount, url: url)
384     }}
385
386 extension MatomoTracker {
387     public func copyFromOldSharedInstance() {
388         matomoUserDefaults.copy(from: UserDefaults.standard)
389     }
390 }