import UIKit class SyncUpdate { private var lastSync: TimeInterval = 0 private var timer: Timer! private var paused: Int = 1 // first start() will decrement private var filterType: DateFilterKind private var range: SQLiteRowRange? // written in reloadRangeFromDB() /// `tsEarliest ?? 0` private var tsMin: Timestamp { tsEarliest ?? 0 } /// `(tsLatest + 1) ?? 0` private var tsMax: Timestamp { (tsLatest ?? -1) + 1 } /// Returns invalid range `(-1,-1)` if collection contains no rows var rows: SQLiteRowRange { get { range ?? (-1,-1) } } private(set) var tsEarliest: Timestamp? // as set per user, not actual earliest private(set) var tsLatest: Timestamp? // as set per user, not actual latest init(periodic interval: TimeInterval) { (filterType, tsEarliest, tsLatest) = Pref.DateFilter.restrictions() reloadRangeFromDB() NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self) timer = Timer.repeating(interval, call: #selector(periodicUpdate), on: self) syncNow() // because timer will only fire after interval } /// Callback fired every `7` seconds. @objc private func periodicUpdate() { if paused == 0 { syncNow() } } /// Callback fired when user changes `DateFilter` on root tableView controller @objc private func didChangeDateFilter() { self.pause() let filter = Pref.DateFilter.restrictions() filterType = filter.type DispatchQueue.global().async { // Not necessary, but improve execution order (delete then insert). if self.tsMin <= (filter.earliest ?? 0) { self.set(newEarliest: filter.earliest) self.set(newLatest: filter.latest) } else { self.set(newLatest: filter.latest) self.set(newEarliest: filter.earliest) } self.continue() } } /// - Warning: Always call from a background thread! func needsReloadDB(domain: String? = nil) { assert(!Thread.isMainThread) reloadRangeFromDB() if let dom = domain { notifyObservers { $0.syncUpdate(self, partialRemove: dom) } } else { notifyObservers { $0.syncUpdate(self, reset: rows) } } } // MARK: - Sync Now /// This will immediately resume timer updates, ignoring previous `pause()` requests. func start() { paused = 0 } /// All calls must be balanced with `continue()` calls. /// Can be nested within other `pause-continue` pairs. /// - Warning: An execution branch that results in unbalanced pairs will completely disable updates! func pause() { paused += 1 } /// Must be balanced with a `pause()` call. A `continue()` without a `pause()` is a `nop`. /// - Note: Internally the sync timer keeps running. The `pause` will simply ignore execution during that time. func `continue`() { if paused > 0 { paused -= 1 } } /// Persist logs from cache and notify all observers. (`NotifySyncInsert`) /// Determine rows of outdated entries that should be removed and notify observers as well. (`NotifySyncRemove`) /// - Note: This method is rate limited. Sync will be performed at most once per second. /// - Note: This method returns immediatelly. Syncing is done in a background thread. /// - Parameter block: **Always** called on a background thread! func syncNow(whenDone block: (() -> Void)? = nil) { let now = Date().timeIntervalSince1970 guard (now - lastSync) > 1 else { // rate limiting if let b = block { DispatchQueue.global().async { b() } } return } lastSync = now self.pause() // reduce concurrent load DispatchQueue.global().async { self.internalSync() block?() self.continue() } } /// Called by `syncNow()`. Split to a separate func to reduce `self.` cluttering private func internalSync() { assert(!Thread.isMainThread) // Always persist logs ... if let newest = AppDB?.dnsLogsPersist() { // move cache -> heap if filterType == .ABRange { // ... even if we filter a few later if let r = rows(tsMin, tsMax, scope: newest) { notify(insert: r, .Latest) } } else { notify(insert: newest, .Latest) } } if filterType == .LastXMin { set(newEarliest: Timestamp.past(minutes: Pref.DateFilter.LastXMin)) } // TODO: periodic hard delete old logs (will reset rowids!) } // MARK: - Internal private func rows(_ ts1: Timestamp, _ ts2: Timestamp, scope: SQLiteRowRange = (0,0)) -> SQLiteRowRange? { AppDB?.dnsLogsRowRange(between: ts1, and: ts2, within: scope) } private func reloadRangeFromDB() { // `nil` is not SQLiteRowRange(0,0) aka. full collection. // `nil` means invalid range. e.g. ts restriction too high or empty db. range = rows(tsMin, tsMax) } /// Update internal `tsEarliest`, then post `NotifySyncInsert` or `NotifySyncRemove` notification with row ids. /// - Warning: Always call from a background thread! private func set(newEarliest: Timestamp?) { func from(_ t: Timestamp?) -> Timestamp { t ?? 0 } func to(_ t: Timestamp) -> Timestamp { tsLatest == nil ? t : min(t, tsMax) } if let (old, new) = tsEarliest <-/ newEarliest { if old != nil, (new == nil || new! < old!) { if let r = rows(from(new), to(old!), scope: (0, range?.start ?? 0)) { notify(insert: r, .Earliest) } } else if range != nil { if let r = rows(from(old), to(new!), scope: range!) { notify(remove: r, .Earliest) } } } } /// Update internal `tsLatest`, then post `NotifySyncInsert` or `NotifySyncRemove` notification with row ids. /// - Warning: Always call from a background thread! private func set(newLatest: Timestamp?) { func from(_ t: Timestamp) -> Timestamp { max(t + 1, tsMin) } func to(_ t: Timestamp?) -> Timestamp { t == nil ? 0 : t! + 1 } // +1: include upper end because `dnsLogsRowRange` selects `ts < X` if let (old, new) = tsLatest <-/ newLatest { if old != nil, (new == nil || old! < new!) { if let r = rows(from(old!), to(new), scope: (range?.end ?? 0, 0)) { notify(insert: r, .Latest) } } else if range != nil { if let r = rows(from(new!), to(old), scope: range!) { notify(remove: r, .Latest) } } } } /// - Warning: Always call from a background thread! private func notify(insert r: SQLiteRowRange, _ end: SyncUpdateEnd) { if range == nil { range = r } else { switch end { case .Earliest: range!.start = r.start case .Latest: range!.end = r.end } } notifyObservers { $0.syncUpdate(self, insert: r, affects: end) } } /// - Warning: `range` must not be `nil`! /// - Warning: Always call from a background thread! private func notify(remove r: SQLiteRowRange, _ end: SyncUpdateEnd) { switch end { case .Earliest: range!.start = r.end + 1 case .Latest: range!.end = r.start - 1 } if range!.start > range!.end { range = nil } notifyObservers { $0.syncUpdate(self, remove: r, affects: end) } } // MARK: - Observer List private var observers: [WeakObserver] = [] /// Add `delegate` to observer list and immediatelly call `syncUpdate(reset:)` (on background thread). func addObserver(_ delegate: SyncUpdateDelegate) { observers.removeAll { $0.target == nil } observers.append(.init(target: delegate)) DispatchQueue.global().async { delegate.syncUpdate(self, reset: self.rows) } } /// - Warning: Always call from a background thread! private func notifyObservers(_ block: (SyncUpdateDelegate) -> Void) { assert(!Thread.isMainThread) self.pause() for o in observers where o.target != nil { block(o.target!) } self.continue() } } /// Wrapper class for `SyncUpdateDelegate` that supports weak references private struct WeakObserver { weak var target: SyncUpdateDelegate? weak var pullToRefresh: UIRefreshControl? } enum SyncUpdateEnd { case Earliest, Latest } protocol SyncUpdateDelegate : AnyObject { /// `SyncUpdate` has unpredictable changes. Reload your `dataSource`. /// - Warning: This function will **always** be called from a background thread. func syncUpdate(_ sender: SyncUpdate, reset rows: SQLiteRowRange) /// `SyncUpdate` added new `rows` to database. Sync changes to your `dataSource`. /// - Warning: This function will **always** be called from a background thread. func syncUpdate(_ sender: SyncUpdate, insert rows: SQLiteRowRange, affects: SyncUpdateEnd) /// `SyncUpdate` outdated some `rows` in database. Sync changes to your `dataSource`. /// - Warning: This function will **always** be called from a background thread. func syncUpdate(_ sender: SyncUpdate, remove rows: SQLiteRowRange, affects: SyncUpdateEnd) /// Background process did delete some entries in database that match `affectedDomain`. /// Update or remove entries from your `dataSource`. /// - Warning: This function will **always** be called from a background thread. func syncUpdate(_ sender: SyncUpdate, partialRemove affectedDomain: String) } // MARK: - Pull-To-Refresh @available(iOS 10.0, *) extension SyncUpdate { /// Add Pull-To-Refresh control to `tableViewController`. On action notify `observer.syncUpdate(reset:)` /// - Warning: Must be called after `addObserver()` such that `observer` exists in list of observers. func allowPullToRefresh(onTVC tableViewController: UITableViewController?, forObserver: SyncUpdateDelegate) { guard let i = observers.firstIndex(where: { $0.target === forObserver }) else { assertionFailure("You must add the observer before enabling Pull-To-Refresh!") return } // remove previous observers[i].pullToRefresh?.removeTarget(self, action: #selector(pullToRefresh), for: .valueChanged) observers[i].pullToRefresh = nil if let tvc = tableViewController { let rc = UIRefreshControl() rc.addTarget(self, action: #selector(pullToRefresh), for: .valueChanged) tvc.tableView.refreshControl = rc observers[i].pullToRefresh = rc } } /// Pull-To-Refresh callback method. Find observer with corresponding `RefreshControl` and notify `syncUpdate(reset:)` @objc private func pullToRefresh(sender: UIRefreshControl) { guard let x = observers.first(where: { $0.pullToRefresh === sender }) else { assertionFailure("Should never happen. RefreshControl removed from table view while keeping it active somewhere else.") return } syncNow { x.target?.syncUpdate(self, reset: self.rows) DispatchQueue.main.sync { sender.endRefreshing() } } } }