Refactoring II.
- Filter by date range - SyncUpdate tasks run fully asynchronous in background - Move tableView manipulations into FilterPipelineDelegate - Move SyncUpdate notification into SyncUpdateDelegate - Fix: sync cache before persisting a recording - Restructuring GroupedDomainDataSource - Performance: db logs queries use rowids instead of timestamps - Add 'now' button to DatePickerAlert
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
enum DomainFilter {
|
||||
static private var data: [String: FilterOptions] = {
|
||||
AppDB?.loadFilters() ?? [:]
|
||||
}()
|
||||
static private var data = AppDB?.loadFilters() ?? [:]
|
||||
|
||||
/// Get filter with given `domain` name
|
||||
@inline(__always) static subscript(_ domain: String) -> FilterOptions? {
|
||||
@@ -12,10 +10,10 @@ enum DomainFilter {
|
||||
|
||||
/// Update local memory object by loading values from persistent db.
|
||||
/// - Note: Will trigger `NotifyDNSFilterChanged` notification.
|
||||
static func reload() {
|
||||
data = AppDB?.loadFilters() ?? [:]
|
||||
NotifyDNSFilterChanged.post()
|
||||
}
|
||||
// static func reload() {
|
||||
// data = AppDB?.loadFilters() ?? [:]
|
||||
// NotifyDNSFilterChanged.post()
|
||||
// }
|
||||
|
||||
/// Get list of domains (sorted by name) which do contain the given filter
|
||||
static func list(where matching: FilterOptions) -> [String] {
|
||||
|
||||
@@ -1,54 +1,52 @@
|
||||
import UIKit
|
||||
|
||||
protocol GroupedDomainDataSourceDelegate: UITableViewController {
|
||||
/// Currently only called when a row is moved and the `tableView` is frontmost.
|
||||
func groupedDomainDataSource(needsUpdate row: Int)
|
||||
}
|
||||
|
||||
// ##########################
|
||||
// #
|
||||
// # MARK: DataSource
|
||||
// #
|
||||
// ##########################
|
||||
|
||||
class GroupedDomainDataSource {
|
||||
class GroupedDomainDataSource: FilterPipelineDelegate, SyncUpdateDelegate {
|
||||
|
||||
private var tsLatest: Timestamp = 0
|
||||
|
||||
private let parent: String?
|
||||
let pipeline: FilterPipeline<GroupedDomain>
|
||||
private lazy var search = SearchBarManager(on: pipeline.delegate!.tableView)
|
||||
let parent: String?
|
||||
private let pipeline = FilterPipeline<GroupedDomain>()
|
||||
private lazy var search = SearchBarManager(on: delegate!.tableView)
|
||||
private var currentOrder: DateFilterOrderBy = .Date
|
||||
private var orderAsc = false
|
||||
|
||||
init(withDelegate tvc: FilterPipelineDelegate, parent p: String?) {
|
||||
parent = p
|
||||
pipeline = .init(withDelegate: tvc)
|
||||
pipeline.setDataSource { [unowned self] in self.dataSourceCallback() }
|
||||
/// Will init `sync.allowPullToRefresh()` on `tableView.refreshControl` as well.
|
||||
weak var delegate: GroupedDomainDataSourceDelegate? {
|
||||
willSet { if #available(iOS 10.0, *), newValue !== delegate {
|
||||
sync.allowPullToRefresh(onTVC: newValue, forObserver: self)
|
||||
}}}
|
||||
|
||||
/// - Note: Will call `tableview.reloadData()`
|
||||
init(withParent: String?) {
|
||||
parent = withParent
|
||||
pipeline.delegate = self
|
||||
resetSortingOrder(force: true)
|
||||
if #available(iOS 10.0, *) {
|
||||
tvc.tableView.refreshControl = UIRefreshControl(call: #selector(reloadFromSource), on: self)
|
||||
}
|
||||
NotifyLogHistoryReset.observe(call: #selector(reloadFromSource), on: self)
|
||||
|
||||
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
|
||||
NotifySortOrderChanged.observe(call: #selector(didChangeSortOrder), on: self)
|
||||
NotifySyncInsert.observe(call: #selector(syncInsert), on: self)
|
||||
NotifySyncRemove.observe(call: #selector(syncRemove), on: self)
|
||||
|
||||
sync.addObserver(self) // calls syncUpdate(reset:)
|
||||
}
|
||||
|
||||
/// Callback fired only when pipeline resets data source
|
||||
private func dataSourceCallback() -> [GroupedDomain] {
|
||||
guard let db = AppDB else { return [] }
|
||||
let earliest = sync.tsEarliest
|
||||
tsLatest = earliest
|
||||
var log = db.dnsLogsGrouped(since: earliest, parentDomain: parent) ?? []
|
||||
for (i, val) in log.enumerated() {
|
||||
log[i].options = DomainFilter[val.domain]
|
||||
tsLatest = max(tsLatest, val.lastModified)
|
||||
}
|
||||
return log
|
||||
/// Callback fired when user changes date filter settings. (`NotifySortOrderChanged` notification)
|
||||
@objc private func didChangeSortOrder(_ notification: Notification) {
|
||||
resetSortingOrder()
|
||||
}
|
||||
|
||||
/// Read user defaults and apply new sorting order. Either by setting a new or reversing the current.
|
||||
/// - Parameter force: If `true` set new sorting even if the type does not differ.
|
||||
private func resetSortingOrder(force: Bool = false) {
|
||||
let orderDidChange = (orderAsc =? Pref.DateFilter.OrderAsc)
|
||||
if currentOrder =? Pref.DateFilter.OrderBy || force {
|
||||
let orderDidChange = (orderAsc <-? Pref.DateFilter.OrderAsc)
|
||||
if currentOrder <-? Pref.DateFilter.OrderBy || force {
|
||||
switch currentOrder {
|
||||
case .Date:
|
||||
pipeline.setSorting { [unowned self] in
|
||||
@@ -68,64 +66,49 @@ class GroupedDomainDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause recurring background updates to force reload `dataSource`.
|
||||
/// Callback fired on user action `pull-to-refresh`, or another background task triggered `NotifyLogHistoryReset`.
|
||||
/// - Parameter sender: May be either `UIRefreshControl` or `Notification`
|
||||
/// (optional: pass single domain as the notification object).
|
||||
@objc func reloadFromSource(sender: Any? = nil) {
|
||||
weak var refreshControl = sender as? UIRefreshControl
|
||||
let notification = sender as? Notification
|
||||
sync.pause()
|
||||
if let affectedDomain = notification?.object as? String {
|
||||
partiallyReloadFromSource(affectedDomain)
|
||||
sync.continue()
|
||||
} else {
|
||||
pipeline.reload(fromSource: true, whenDone: {
|
||||
sync.syncNow() // sync outstanding entries in cache
|
||||
sync.continue()
|
||||
refreshControl?.endRefreshing()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback fired when user edits list of `blocked` or `ignored` domains in settings. (`NotifyDNSFilterChanged` notification)
|
||||
@objc private func didChangeDomainFilter(_ notification: Notification) {
|
||||
guard let domain = notification.object as? String else {
|
||||
reloadFromSource()
|
||||
return
|
||||
preconditionFailure("Domain independent filter reset not implemented") // `syncUpdate(reset:)` async!
|
||||
}
|
||||
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == domain }) {
|
||||
var y = obj
|
||||
y.options = DomainFilter[domain]
|
||||
pipeline.update(y, at: i)
|
||||
if let x = pipeline.dataSourceGet(where: { $0.domain == domain }) {
|
||||
var obj = x.object
|
||||
obj.options = DomainFilter[domain]
|
||||
pipeline.update(obj, at: x.index)
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback fired when user changes date filter settings. (`NotifySortOrderChanged` notification)
|
||||
@objc private func didChangeSortOrder(_ notification: Notification) {
|
||||
resetSortingOrder()
|
||||
}
|
||||
|
||||
|
||||
// MARK: Table View Data Source
|
||||
|
||||
@inline(__always) var numberOfRows: Int { get { pipeline.displayObjectCount() } }
|
||||
|
||||
@inline(__always) subscript(_ row: Int) -> GroupedDomain { pipeline.displayObject(at: row) }
|
||||
}
|
||||
|
||||
|
||||
// ################################
|
||||
// #
|
||||
// # MARK: - Partial Update
|
||||
// #
|
||||
// ################################
|
||||
|
||||
extension GroupedDomainDataSource {
|
||||
|
||||
func syncUpdate(_: SyncUpdate, reset rows: SQLiteRowRange) {
|
||||
var logs = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent) ?? []
|
||||
for (i, val) in logs.enumerated() {
|
||||
logs[i].options = DomainFilter[val.domain]
|
||||
}
|
||||
pipeline.reset(dataSource: logs)
|
||||
}
|
||||
|
||||
// MARK: partial updates
|
||||
|
||||
/// Callback fired when background sync added new entries to the list. (`NotifySyncInsert` notification)
|
||||
@objc private func syncInsert(_ notification: Notification) {
|
||||
sync.pause()
|
||||
defer { sync.continue() }
|
||||
let range = notification.object as! SQLiteRowRange
|
||||
guard let latest = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent) else {
|
||||
func syncUpdate(_: SyncUpdate, insert rows: SQLiteRowRange) {
|
||||
guard let latest = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent) else {
|
||||
assertionFailure("NotifySyncInsert fired with empty range")
|
||||
return
|
||||
}
|
||||
pipeline.pauseCellAnimations(if: latest.count > 14)
|
||||
cellAnimationsGroup(if: latest.count > 14)
|
||||
for x in latest {
|
||||
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) {
|
||||
pipeline.update(obj + x, at: i)
|
||||
@@ -134,21 +117,16 @@ class GroupedDomainDataSource {
|
||||
y.options = DomainFilter[x.domain]
|
||||
pipeline.addNew(y)
|
||||
}
|
||||
tsLatest = max(tsLatest, x.lastModified)
|
||||
}
|
||||
pipeline.continueCellAnimations(reloadTable: true)
|
||||
cellAnimationsCommit()
|
||||
}
|
||||
|
||||
/// Callback fired when background sync removed old entries from the list. (`NotifySyncRemove` notification)
|
||||
@objc private func syncRemove(_ notification: Notification) {
|
||||
sync.pause()
|
||||
defer { sync.continue() }
|
||||
let range = notification.object as! SQLiteRowRange
|
||||
guard let outdated = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent),
|
||||
func syncUpdate(_: SyncUpdate, remove rows: SQLiteRowRange) {
|
||||
guard let outdated = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent),
|
||||
outdated.count > 0 else {
|
||||
return
|
||||
}
|
||||
pipeline.pauseCellAnimations(if: outdated.count > 14)
|
||||
cellAnimationsGroup(if: outdated.count > 14)
|
||||
var listOfDeletes: [Int] = []
|
||||
for x in outdated {
|
||||
guard let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) else {
|
||||
@@ -162,34 +140,10 @@ class GroupedDomainDataSource {
|
||||
}
|
||||
}
|
||||
pipeline.remove(indices: listOfDeletes.sorted())
|
||||
pipeline.continueCellAnimations(reloadTable: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ################################
|
||||
// #
|
||||
// # MARK: - Delete History
|
||||
// #
|
||||
// ################################
|
||||
|
||||
extension GroupedDomainDataSource {
|
||||
|
||||
/// Callback fired when user performs row edit -> delete action
|
||||
func deleteHistory(domain: String, since ts: Timestamp) {
|
||||
let flag = (parent != nil)
|
||||
DispatchQueue.global().async {
|
||||
guard let db = AppDB, db.dnsLogsDelete(domain, strict: flag, since: ts) > 0 else {
|
||||
return // nothing has changed
|
||||
}
|
||||
db.vacuum()
|
||||
NotifyLogHistoryReset.postAsyncMain(domain) // calls partiallyReloadFromSource(:)
|
||||
}
|
||||
cellAnimationsCommit()
|
||||
}
|
||||
|
||||
/// Reload a single data source entry. Callback fired by `reloadFromSource()`
|
||||
/// Only useful if `affectedFQDN` currently exists in `dataSource`. Can either update or remove entry.
|
||||
private func partiallyReloadFromSource(_ affectedFQDN: String) {
|
||||
func syncUpdate(_ sender: SyncUpdate, partialRemove affectedFQDN: String) {
|
||||
let affectedParent = affectedFQDN.extractDomain()
|
||||
guard parent == nil || parent == affectedParent else {
|
||||
return // does not affect current table
|
||||
@@ -199,8 +153,8 @@ extension GroupedDomainDataSource {
|
||||
// can only happen if delete sheet is open while background sync removed the element
|
||||
return
|
||||
}
|
||||
if var updated = AppDB?.dnsLogsGrouped(since: sync.tsEarliest, upto: tsLatest,
|
||||
matchingDomain: affected, parentDomain: parent)?.first {
|
||||
if var updated = AppDB?.dnsLogsGrouped(range: sender.rows, matchingDomain: affected,
|
||||
parentDomain: parent)?.first {
|
||||
assert(old.object.domain == updated.domain)
|
||||
updated.options = DomainFilter[updated.domain]
|
||||
pipeline.update(updated, at: old.index)
|
||||
@@ -211,6 +165,57 @@ extension GroupedDomainDataSource {
|
||||
}
|
||||
|
||||
|
||||
// #################################
|
||||
// #
|
||||
// # MARK: - Cell Animations
|
||||
// #
|
||||
// #################################
|
||||
|
||||
extension GroupedDomainDataSource {
|
||||
/// Sets `pipeline.delegate = nil` to disable individual cell animations (update, insert, delete & move).
|
||||
private func cellAnimationsGroup(if condition: Bool = true) {
|
||||
if condition { pipeline.delegate = nil }
|
||||
if pipeline.delegate != nil {
|
||||
onMain { if !$0.isFrontmost { self.pipeline.delegate = nil } }
|
||||
}
|
||||
}
|
||||
/// No-Op if cell animations are enabled already.
|
||||
/// Else, set `pipeline.delegate = self` and perform `reloadData()`.
|
||||
private func cellAnimationsCommit() {
|
||||
if pipeline.delegate == nil {
|
||||
pipeline.delegate = self
|
||||
onMain { $0.reloadData() }
|
||||
}
|
||||
}
|
||||
/// Perform table view manipulations on main thread.
|
||||
/// Set `delegate = nil` to disable `tableView` animations.
|
||||
private func onMain(_ closure: (UITableView) -> Void) {
|
||||
if Thread.isMainThread {
|
||||
if let tv = delegate?.tableView { closure(tv) }
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
if let tv = delegate?.tableView { closure(tv) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Collect animations and post them in a single animations block.
|
||||
// This will require enormous work to translate then into a final set.
|
||||
func filterPipelineDidReset() { onMain { $0.reloadData() } }
|
||||
func filterPipeline(delete rows: [Int]) { onMain { $0.safeDeleteRows(rows) } }
|
||||
func filterPipeline(insert row: Int) { onMain { $0.safeInsertRow(row, with: .left) } }
|
||||
func filterPipeline(update row: Int) { onMain { $0.safeReloadRow(row) } }
|
||||
func filterPipeline(move oldRow: Int, to newRow: Int) {
|
||||
onMain {
|
||||
$0.safeMoveRow(oldRow, to: newRow)
|
||||
if $0.isFrontmost { // onMain ensures delegate is set
|
||||
delegate!.groupedDomainDataSource(needsUpdate: newRow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ################################
|
||||
// #
|
||||
// # MARK: - Search
|
||||
@@ -221,9 +226,9 @@ extension GroupedDomainDataSource {
|
||||
func toggleSearch() {
|
||||
if search.active { search.hide() }
|
||||
else {
|
||||
// Pause animations. Otherwise the `scrollToTop` animation is broken.
|
||||
// Begin animations group. Otherwise the `scrollToTop` animation is broken.
|
||||
// This is due to `addFilter` calling `reloadData()` before `search.show()` can animate it.
|
||||
pipeline.pauseCellAnimations()
|
||||
cellAnimationsGroup()
|
||||
var searchTerm = ""
|
||||
pipeline.addFilter("search") {
|
||||
$0.domain.lowercased().contains(searchTerm)
|
||||
@@ -234,7 +239,7 @@ extension GroupedDomainDataSource {
|
||||
searchTerm = $0.lowercased()
|
||||
self.pipeline.reloadFilter(withId: "search")
|
||||
})
|
||||
pipeline.continueCellAnimations()
|
||||
cellAnimationsCommit()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,8 +251,8 @@ extension GroupedDomainDataSource {
|
||||
// #
|
||||
// ##########################
|
||||
|
||||
protocol GroupedDomainEditRow : EditableRows, FilterPipelineDelegate {
|
||||
var source: GroupedDomainDataSource { get set }
|
||||
protocol GroupedDomainEditRow : UIViewController, EditableRows {
|
||||
var source: GroupedDomainDataSource { get }
|
||||
}
|
||||
|
||||
extension GroupedDomainEditRow {
|
||||
@@ -274,8 +279,10 @@ extension GroupedDomainEditRow {
|
||||
case .ignore: showFilterSheet(entry, .ignored)
|
||||
case .block: showFilterSheet(entry, .blocked)
|
||||
case .delete:
|
||||
AlertDeleteLogs(entry.domain, latest: entry.lastModified) {
|
||||
self.source.deleteHistory(domain: entry.domain, since: $0)
|
||||
let name = entry.domain
|
||||
let flag = (source.parent != nil)
|
||||
AlertDeleteLogs(name, latest: entry.lastModified) {
|
||||
TheGreatDestroyer.deleteLogs(domain: name, since: $0, strict: flag)
|
||||
}.presentIn(self)
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -15,8 +15,9 @@ enum RecordingsDB {
|
||||
|
||||
/// Copy log entries from generic `heap` table to recording specific `recLog` table
|
||||
static func persist(_ r: Recording) {
|
||||
sync.syncNow() // persist changes in cache before copying recording details
|
||||
AppDB?.recordingLogsPersist(r)
|
||||
sync.syncNow { // persist changes in cache before copying recording details
|
||||
AppDB?.recordingLogsPersist(r)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get list of domains that occured during the recording
|
||||
|
||||
@@ -1,26 +1,63 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class SyncUpdate {
|
||||
private var lastSync: TimeInterval = 0
|
||||
private var timer: Timer!
|
||||
private var paused: Int = 1 // first start() will decrement
|
||||
private(set) var tsEarliest: Timestamp
|
||||
|
||||
private var filterType: DateFilterKind
|
||||
private var range: SQLiteRowRange? // written in reloadRangeFromDB()
|
||||
|
||||
/// 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) {
|
||||
tsEarliest = Pref.DateFilter.lastXMinTimestamp() ?? 0
|
||||
(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 {
|
||||
self.set(newEarliest: Pref.DateFilter.lastXMinTimestamp() ?? 0)
|
||||
// Not necessary, but improve execution order (delete then insert).
|
||||
if self.tsEarliest <= filter.earliest {
|
||||
self.update(newEarliest: filter.earliest)
|
||||
self.update(newLatest: filter.latest)
|
||||
} else {
|
||||
self.update(newLatest: filter.latest)
|
||||
self.update(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 }
|
||||
|
||||
@@ -37,38 +74,188 @@ class SyncUpdate {
|
||||
/// 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.
|
||||
func syncNow() {
|
||||
/// - Parameter block: **Always** called on a background thread!
|
||||
func syncNow(whenDone block: (() -> Void)? = nil) {
|
||||
let now = Date().timeIntervalSince1970
|
||||
guard (now - lastSync) > 1 else { return } // rate limiting
|
||||
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.pause() // reduce concurrent load
|
||||
|
||||
if let inserted = AppDB?.dnsLogsPersist() { // move cache -> heap
|
||||
NotifySyncInsert.postAsyncMain(inserted)
|
||||
}
|
||||
if let lastXFilter = Pref.DateFilter.lastXMinTimestamp() {
|
||||
self.set(newEarliest: lastXFilter)
|
||||
}
|
||||
// TODO: periodic hard delete old logs (will reset rowids!)
|
||||
|
||||
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 inserted = AppDB?.dnsLogsPersist() { // move cache -> heap
|
||||
if filterType == .ABRange {
|
||||
// ... even if we filter a few later
|
||||
publishInsert(front: false, tsEarliest, tsLatest + 1, scope: inserted)
|
||||
} else {
|
||||
safeSetRange(end: inserted)
|
||||
notifyObservers { $0.syncUpdate(self, insert: inserted) }
|
||||
}
|
||||
}
|
||||
if filterType == .LastXMin {
|
||||
update(newEarliest: Timestamp.past(minutes: Pref.DateFilter.LastXMin))
|
||||
}
|
||||
// TODO: periodic hard delete old logs (will reset rowids!)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
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 = AppDB?.dnsLogsRowRange(between: tsEarliest, and: tsLatest + 1)
|
||||
}
|
||||
|
||||
/// Helper to always set range in case there was none before. Otherwise only update `start`.
|
||||
private func safeSetRange(start r: SQLiteRowRange) {
|
||||
range == nil ? (range = r) : (range!.start = r.start)
|
||||
}
|
||||
|
||||
/// Helper to always set range in case there was none before. Otherwise only update `end`.
|
||||
private func safeSetRange(end r: SQLiteRowRange) {
|
||||
range == nil ? (range = r) : (range!.end = r.end)
|
||||
}
|
||||
|
||||
/// Update internal `tsEarliest`, then post `NotifySyncInsert` or `NotifySyncRemove` notification with row ids.
|
||||
/// - Warning: Always call from a background thread!
|
||||
private func set(newEarliest: Timestamp) {
|
||||
let current = tsEarliest
|
||||
tsEarliest = newEarliest
|
||||
if current < newEarliest {
|
||||
if let excess = AppDB?.dnsLogsRowRange(between: current, and: newEarliest) {
|
||||
NotifySyncRemove.postAsyncMain(excess)
|
||||
private func update(newEarliest: Timestamp) {
|
||||
if let (old, new) = tsEarliest <-/ newEarliest {
|
||||
if new < old {
|
||||
publishInsert(front: true, new, (tsLatest == -1 ? old : min(old, tsLatest + 1)), scope: (0, range?.start ?? 0))
|
||||
} else if range != nil {
|
||||
publishRemove(front: true, old, (tsLatest == -1 ? new : min(new, tsLatest + 1)), scope: range!)
|
||||
}
|
||||
} else if current > newEarliest {
|
||||
if let missing = AppDB?.dnsLogsRowRange(between: newEarliest, and: current) {
|
||||
NotifySyncInsert.postAsyncMain(missing)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update internal `tsLatest`, then post `NotifySyncInsert` or `NotifySyncRemove` notification with row ids.
|
||||
/// - Warning: Always call from a background thread!
|
||||
private func update(newLatest: Timestamp) {
|
||||
if let (old, new) = tsLatest <-/ newLatest {
|
||||
// +1: include upper end because `dnsLogsRowRange` selects `ts < X`
|
||||
if (old < new || new == -1), old != -1 {
|
||||
publishInsert(front: false, max(old + 1, tsEarliest), new + 1, scope: (range?.end ?? 0, 0))
|
||||
} else if range != nil {
|
||||
// FIXME: removing latest entries will invalidate "last changed" label
|
||||
publishRemove(front: false, max(new + 1, tsEarliest), old + 1, scope: range!)
|
||||
}
|
||||
} // else: nothing changed
|
||||
}
|
||||
}
|
||||
|
||||
/// - Warning: Always call from a background thread!
|
||||
private func publishInsert(front: Bool, _ ts1: Timestamp, _ ts2: Timestamp, scope: SQLiteRowRange) {
|
||||
if let r = AppDB?.dnsLogsRowRange(between: ts1, and: ts2, within: scope) {
|
||||
front ? safeSetRange(start: r) : safeSetRange(end: r)
|
||||
notifyObservers { $0.syncUpdate(self, insert: r) }
|
||||
}
|
||||
}
|
||||
|
||||
/// - Warning: `range` must not be `nil`!
|
||||
/// - Warning: Always call from a background thread!
|
||||
private func publishRemove(front: Bool, _ ts1: Timestamp, _ ts2: Timestamp, scope: SQLiteRowRange) {
|
||||
assert(range != nil)
|
||||
if let r = AppDB?.dnsLogsRowRange(between: ts1, and: ts2, within: scope) {
|
||||
front ? (range!.start = r.end + 1) : (range!.end = r.start - 1)
|
||||
if range!.start > range!.end { range = nil }
|
||||
notifyObservers { $0.syncUpdate(self, remove: r) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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?
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
/// `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)
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user