diff --git a/AppCheck.xcodeproj/project.pbxproj b/AppCheck.xcodeproj/project.pbxproj index 1aef16d..96716dd 100644 --- a/AppCheck.xcodeproj/project.pbxproj +++ b/AppCheck.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 541AC5DD2399498A00A769D7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DB2399498A00A769D7 /* Main.storyboard */; }; 541AC5DF2399498B00A769D7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DE2399498B00A769D7 /* Assets.xcassets */; }; 541AC5E22399498B00A769D7 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5E02399498B00A769D7 /* LaunchScreen.storyboard */; }; + 541FC9872497D81C00962623 /* TheGreatDestroyer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541FC9862497D81C00962623 /* TheGreatDestroyer.swift */; }; 542E2A982404973F001462DC /* TBCMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A972404973F001462DC /* TBCMain.swift */; }; 542E2A9A24051556001462DC /* TVCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A9924051556001462DC /* TVCSettings.swift */; }; 543CDB2023EEE61900B7F323 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543CDB1F23EEE61900B7F323 /* PacketTunnelProvider.swift */; }; @@ -175,6 +176,7 @@ 541AC5DE2399498B00A769D7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 541AC5E12399498B00A769D7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 541AC5E32399498B00A769D7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 541FC9862497D81C00962623 /* TheGreatDestroyer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TheGreatDestroyer.swift; sourceTree = ""; }; 542E2A972404973F001462DC /* TBCMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBCMain.swift; sourceTree = ""; }; 542E2A9924051556001462DC /* TVCSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCSettings.swift; sourceTree = ""; }; 543CDB1D23EEE61900B7F323 /* GlassVPN.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GlassVPN.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -434,6 +436,7 @@ 54D8B97D2471B88900EB2414 /* DBCommon.swift */, 54D8B9822471BD8100EB2414 /* DBAppOnly.swift */, 54B345AC241BBB00004C53CC /* DBExtensions.swift */, + 541FC9862497D81C00962623 /* TheGreatDestroyer.swift */, ); path = DB; sourceTree = ""; @@ -848,6 +851,7 @@ 542E2A982404973F001462DC /* TBCMain.swift in Sources */, 54448A3224899A4000771C96 /* SearchBarManager.swift in Sources */, 5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */, + 541FC9872497D81C00962623 /* TheGreatDestroyer.swift in Sources */, 5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */, 545DDDD124436983003B6544 /* QuickUI.swift in Sources */, 541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */, diff --git a/main/Common Classes/DatePickerAlert.swift b/main/Common Classes/DatePickerAlert.swift index 2f5055e..3bc505a 100644 --- a/main/Common Classes/DatePickerAlert.swift +++ b/main/Common Classes/DatePickerAlert.swift @@ -2,6 +2,10 @@ import UIKit class DatePickerAlert: UIViewController { + override var keyCommands: [UIKeyCommand]? { + [UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(didTapCancel))] + } + private var callback: (Date) -> Void private let picker: UIDatePicker = { let x = UIDatePicker() @@ -23,14 +27,17 @@ class DatePickerAlert: UIViewController { } internal override func loadView() { - let cancel = QuickUI.button("Cancel", target: self, action: #selector(didTapCancel)) + let cancel = QuickUI.button("Discard", target: self, action: #selector(didTapCancel)) let save = QuickUI.button("Save", target: self, action: #selector(didTapSave)) + let now = QuickUI.button("Now", target: self, action: #selector(didTapNow)) save.titleLabel?.font = save.titleLabel?.font.bold() + now.titleLabel?.font = now.titleLabel?.font.bold() + now.setTitleColor(.sysFg, for: .normal) //cancel.setTitleColor(.systemRed, for: .normal) - let buttons = UIStackView(arrangedSubviews: [cancel, save]) + let buttons = UIStackView(arrangedSubviews: [cancel, now, save]) buttons.axis = .horizontal - buttons.distribution = .fillEqually + buttons.distribution = .equalSpacing let bg = UIView(frame: picker.frame) bg.frame.size.height += buttons.frame.height + 15 @@ -45,7 +52,7 @@ class DatePickerAlert: UIViewController { picker.anchor([.leading, .trailing, .top], to: bg) picker.bottomAnchor =&= buttons.topAnchor - buttons.anchor([.leading, .trailing], to: bg) + buttons.anchor([.leading, .trailing], to: bg, margin: 25) buttons.bottomAnchor =&= bg.bottomAnchor - 15 bg.anchor([.leading, .trailing, .bottom], to: clearBg) @@ -53,6 +60,10 @@ class DatePickerAlert: UIViewController { view.isHidden = true // otherwise picker will flash on present } + @objc private func didTapNow() { + picker.date = Date() + } + @objc private func didTapSave() { dismiss(animated: true) { self.callback(self.picker.date) diff --git a/main/Common Classes/FilterPipeline.swift b/main/Common Classes/FilterPipeline.swift index ff1b2ff..7a1631e 100644 --- a/main/Common Classes/FilterPipeline.swift +++ b/main/Common Classes/FilterPipeline.swift @@ -1,34 +1,36 @@ import UIKit -protocol FilterPipelineDelegate: UITableViewController { - /// Currently only called when a row is moved and the `tableView` is frontmost. - func rowNeedsUpdate(_ row: Int) +protocol FilterPipelineDelegate: AnyObject { + /// Call `reloadData()` on main thread. + /// - Warning: This function may be called from a background thread. + func filterPipelineDidReset() + + /// Call `safeDeleteRows()` on main thread. + /// - Warning: This function may be called from a background thread. + func filterPipeline(delete rows: [Int]) + + /// Call `safeInsertRow()` on main thread. + /// - Warning: This function may be called from a background thread. + func filterPipeline(insert row: Int) + + /// Call `safeReloadRow()` on main thread. + /// - Warning: This function may be called from a background thread. + func filterPipeline(update row: Int) + + /// Call `safeMoveRow()` on main thread. + /// - Warning: This function may be called from a background thread. + func filterPipeline(move oldRow: Int, to newRow: Int) } -// MARK: FilterPipeline +// MARK: - FilterPipeline class FilterPipeline { - typealias DataSourceQuery = () -> [T] - - private var sourceQuery: DataSourceQuery! + private(set) fileprivate var dataSource: [T] = [] private var pipeline: [PipelineFilter] = [] private var display: PipelineSorting! - private(set) weak var delegate: FilterPipelineDelegate? - - private var cellAnimations: Bool = true - - required init(withDelegate: FilterPipelineDelegate) { - delegate = withDelegate - } - - /// Set a new `dataSource` query and immediately apply all filters and sorting. - /// - Note: You must call `reload(fromSource:whenDone:)` manually! - /// - Note: Always use `[unowned self]` - func setDataSource(query: @escaping DataSourceQuery) { - sourceQuery = query - } + weak var delegate: FilterPipelineDelegate? /// - Returns: Number of elements in `projection` @inline(__always) func displayObjectCount() -> Int { display.projection.count } @@ -49,20 +51,12 @@ class FilterPipeline { return (i, dataSource[i]) } - /// Re-query data source and re-built filter and display sorting order. - /// - Note: Will call `reloadData()` before `whenDone` closure is executed. But only if `cellAnimations` are enabled. - /// - Parameter fromSource: If `false` only re-built filter and sort order - func reload(fromSource: Bool, whenDone: @escaping () -> Void) { - DispatchQueue.global().async { - if fromSource { - self.dataSource = self.sourceQuery() - } - self.resetFilters() - DispatchQueue.main.sync { - self.reloadTableCells() - whenDone() - } - } + /// Set new data source and re-built filter and display sorting order. + /// - Note: Will call `filterPipelineDidReset()` + func reset(dataSource: [T]) { + self.dataSource = dataSource + self.resetFilters() + delegate?.filterPipelineDidReset() } /// Returns the index set of either the last filter layer, or `dataSource` if no filter is set. @@ -80,12 +74,14 @@ class FilterPipeline { /// Add new filter layer. Each layer is applied upon the previous layer. Therefore, each filter /// can only restrict the display further. A filter cannot introduce previously removed elements. - /// - Note: Will call `reloadData()` if `cellAnimations` are enabled. + /// - Warning: Use `[unowned self]` to prevent retain cycles! + /// - Note: Will call `filterPipelineDidReset()` /// - Parameters: /// - identifier: Use this id to find the filter again. For reload and remove operations. /// - otherId: If `nil` or non-existent the new filter will be appended at the end. /// - predicate: Return `true` if you want to keep the element. func addFilter(_ identifier: String, before otherId: String? = nil, _ predicate: @escaping PipelineFilter.Predicate) { + guard indexOfFilter(identifier) == nil else { return } let newFilter = PipelineFilter(identifier, predicate) if let other = otherId, let i = indexOfFilter(other) { pipeline.insert(newFilter, at: i) @@ -95,11 +91,11 @@ class FilterPipeline { pipeline.append(newFilter) display?.apply(moreRestrictive: newFilter.selection) } - reloadTableCells() + delegate?.filterPipelineDidReset() } /// Find and remove filter with given identifier. Will automatically update remaining filters and display sorting. - /// - Note: Will call `reloadData()` if `cellAnimations` are enabled. + /// - Note: Will call `filterPipelineDidReset()` func removeFilter(withId ident: String) { guard let i = indexOfFilter(ident) else { return } pipeline.remove(at: i) @@ -109,32 +105,34 @@ class FilterPipeline { } else { resetFilters(startingAt: i) } - reloadTableCells() + delegate?.filterPipelineDidReset() } /// Start filter evaluation on all entries from previous filter. - /// - Note: Will call `reloadData()` if `cellAnimations` are enabled. + /// - Note: Will call `filterPipelineDidReset()` func reloadFilter(withId ident: String) { guard let i = indexOfFilter(ident) else { return } resetFilters(startingAt: i) - reloadTableCells() + delegate?.filterPipelineDidReset() } /// Sets the sort and display order. You should set the `delegate` to automatically update your `tableView`. - /// - Note: Will call `reloadData()` if `cellAnimations` are enabled. + /// - Warning: Use `[unowned self]` to prevent retain cycles! + /// - Note: Will call `filterPipelineDidReset()` /// - Parameter predicate: Return `true` if first element should be sorted before second element. func setSorting(_ predicate: @escaping PipelineSorting.Predicate) { display = .init(predicate, pipe: self) - reloadTableCells() + delegate?.filterPipelineDidReset() } /// Will reverse the current display order without resorting. This is faster than setting a new sorting `predicate`. /// However, the `predicate` must be dynamic and support a sort order flag. + /// - Note: Will call `filterPipelineDidReset()` /// - Warning: Make sure `predicate` does reflect the change or it will lead to data inconsistency! func reverseSorting() { // TODO: use semaphore to prevent concurrent edits display?.reverseOrder() - reloadTableCells() + delegate?.filterPipelineDidReset() } /// Re-built filter and display sorting order. @@ -173,26 +171,8 @@ class FilterPipeline { // MARK: data updates - /// Disable individual cell updates (update, move, insert & remove actions) - func pauseCellAnimations(if condition: Bool = true) { - cellAnimations = !condition && delegate?.tableView.isFrontmost ?? false - } - - /// Allow individual cell updates (update, move, insert & remove actions) if tableView `isFrontmost` - /// - Parameter reloadTable: If `true` and cell animations are disabled, perform `tableView.reloadData()` - func continueCellAnimations(reloadTable: Bool = true) { - if !cellAnimations { - cellAnimations = true - if reloadTable { delegate?.tableView.reloadData() } - } - } - - /// Reload table but only if `cellAnimations` is enabled. - func reloadTableCells() { - if cellAnimations { delegate?.tableView.reloadData() } - } - /// Add new element to the original `dataSource` and immediately apply filter and sorting. + /// - Note: Will call `filterPipeline(insert:)` if not filtered. /// - Complexity: O((*m*+1) log *n*), where *m* is the number of filters and *n* the number of elements in each filter. func addNew(_ obj: T) { let index = dataSource.count @@ -202,10 +182,11 @@ class FilterPipeline { } // survived all filters let displayIndex = display.insertNew(index) - if cellAnimations { delegate?.tableView.safeInsertRow(displayIndex, with: .left) } + delegate?.filterPipeline(insert: displayIndex) } /// Update element at `index` in the original `dataSource` and immediately re-apply filter and sorting. + /// - Note: Will call `filterPipeline(delete:)`, `(insert:)`, `(update:)`, or `(move:)` /// - Parameters: /// - obj: Element to be added. Will overwrite previous `dataSource` object. /// - index: Index in the original `dataSource` @@ -219,27 +200,23 @@ class FilterPipeline { let oldPos = display.deleteOld(index) dataSource[index] = obj guard status.display else { - if cellAnimations, oldPos != -1 { delegate?.tableView.safeDeleteRows([oldPos]) } + if oldPos != -1 { delegate?.filterPipeline(delete: [oldPos]) } return } let newPos = display.insertNew(index, previousIndex: oldPos) - if cellAnimations { - if oldPos == -1 { - delegate?.tableView.safeInsertRow(newPos, with: .left) + if oldPos == -1 { + delegate?.filterPipeline(insert: newPos) + } else { + if oldPos == newPos { + delegate?.filterPipeline(update: oldPos) } else { - if oldPos == newPos { - delegate?.tableView.safeReloadRow(oldPos) - } else { - delegate?.tableView.safeMoveRow(oldPos, to: newPos) - if delegate?.tableView.isFrontmost ?? false { - delegate?.rowNeedsUpdate(newPos) - } - } + delegate?.filterPipeline(move: oldPos, to: newPos) } } } /// Remove elements from the original `dataSource`, from all filters, and from display sorting. + /// - Note: Will call `filterPipeline(delete:)` if `sorted` array is not empty. /// - Parameter sorted: Indices in the original `dataSource` /// - Complexity: O(*t*(*m*+*n*) + *m* log *n*), where *t* is the number of filters, /// *m* the number of elements in each filter / projection, and *n* the length of `sorted` indices. @@ -252,14 +229,16 @@ class FilterPipeline { filter.shiftRemove(indices: sorted) } let indices = display.shiftRemove(indices: sorted) - if cellAnimations { delegate?.tableView.safeDeleteRows(indices) } + delegate?.filterPipeline(delete: indices) } } // MARK: - Filter -class PipelineFilter { +class PipelineFilter: CustomStringConvertible { + var description: String { "\(Self.self)(id: \(id))" } + typealias Predicate = (T) -> Bool let id: String diff --git a/main/DB/DBAppOnly.swift b/main/DB/DBAppOnly.swift index 62c63d5..85aea61 100644 --- a/main/DB/DBAppOnly.swift +++ b/main/DB/DBAppOnly.swift @@ -56,14 +56,31 @@ extension SQLiteDatabase { } } -struct WhereClauseBuilder: CustomStringConvertible { +class WhereClauseBuilder: CustomStringConvertible { var description: String = "" private let prefix: String private(set) var bindings: [DBBinding] = [] + init(prefix p: String = "WHERE") { prefix = "\(p) " } - mutating func and(_ clause: String, _ bind: DBBinding ...) { + + /// Append new clause by either prepending `WHERE` prefix or placing `AND` between clauses. + @discardableResult func and(_ clause: String, _ bind: DBBinding ...) -> Self { description.append((description=="" ? prefix : " AND ") + clause) bindings.append(contentsOf: bind) + return self + } + /// Restrict to `rowid >= {range}.start AND rowid <= {range}.end`. + /// Omitted if range is `nil` or individually if a value is `0`. + @discardableResult func and(in range: SQLiteRowRange) -> Self { + if range.start != 0 { and("rowid >= ?", BindInt64(range.start)) } + if range.end != 0 { and("rowid <= ?", BindInt64(range.end)) } + return self + } + /// Restrict to `ts >= {min} AND ts < {max}`. Omit one or the other if value is `0`. + @discardableResult func and(min: Timestamp = 0, max: Timestamp = 0) -> Self { + if min != 0 { and("ts >= ?", BindInt64(min)) } + if max != 0 { and("ts < ?", BindInt64(max)) } + return self } } @@ -119,8 +136,7 @@ extension SQLiteDatabase { /// - Parameter strict: If `true`, use `fqdn` instead of `domain` column /// - Returns: Number of changes aka. Number of rows deleted @discardableResult func dnsLogsDelete(_ domain: String, strict: Bool, since ts: Timestamp = 0) -> Int32 { - var Where = WhereClauseBuilder() - if ts != 0 { Where.and("ts >= ?", BindInt64(ts)) } + let Where = WhereClauseBuilder().and(min: ts) Where.and("\(strict ? "fqdn" : "domain") = ?", BindText(domain)) // (fqdn = ? OR fqdn LIKE '%.' || ?) return (try? run(sql: "DELETE FROM heap \(Where);", bind: Where.bindings) { stmt -> Int32 in try ifStep(stmt, SQLITE_DONE) @@ -130,6 +146,7 @@ extension SQLiteDatabase { // MARK: read + /// `SELECT min(ts) FROM heap` func dnsLogsMinDate() -> Timestamp? { try? run(sql:"SELECT min(ts) FROM heap") { try ifStep($0, SQLITE_ROW) @@ -138,10 +155,14 @@ extension SQLiteDatabase { } /// Select min and max row id with given condition `ts >= ? AND ts < ?` + /// - Parameters: + /// - ts1: Restrict min `rowid` to `ts >= ?`. Pass `0` to omit restriction. + /// - ts2: Restrict max `rowid` to `ts < ?`. Pass `0` to omit restriction. + /// - range: If set, only look at the specified range. Default: `(0,0)` /// - Returns: `nil` in case no rows are matching the condition - func dnsLogsRowRange(between ts: Timestamp, and ts2: Timestamp) -> SQLiteRowRange? { - try? run(sql:"SELECT min(rowid), max(rowid) FROM heap WHERE ts >= ? AND ts < ?", - bind: [BindInt64(ts), BindInt64(ts2)]) { + func dnsLogsRowRange(between ts1: Timestamp, and ts2: Timestamp, within range: SQLiteRowRange = (0,0)) -> SQLiteRowRange? { + let Where = WhereClauseBuilder().and(in: range).and(min: ts1, max: ts2) + return try? run(sql:"SELECT min(rowid), max(rowid) FROM heap \(Where);", bind: Where.bindings) { try ifStep($0, SQLITE_ROW) let max = sqlite3_column_int64($0, 1) return (max == 0) ? nil : (sqlite3_column_int64($0, 0), max) @@ -151,19 +172,15 @@ extension SQLiteDatabase { /// Group DNS logs by domain, count occurences and number of blocked requests. /// - Parameters: /// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end ` - /// - ts: Restrict result set `ts >= ?` + /// - ts1: Restrict result set `ts >= ?` /// - ts2: Restrict result set `ts < ?` /// - matchingDomain: Restrict `(fqdn|domain) = ?`. Which column is used is determined by `parentDomain`. /// - parentDomain: If `nil` returns `domain` column. Else returns `fqdn` column with restriction on `domain == parentDomain`. /// - Returns: List of grouped domains with no particular sorting order. - func dnsLogsGrouped(range: SQLiteRowRange? = nil, since ts: Timestamp = 0, upto ts2: Timestamp = 0, + func dnsLogsGrouped(range: SQLiteRowRange = (0,0), since ts1: Timestamp = 0, upto ts2: Timestamp = 0, matchingDomain: String? = nil, parentDomain: String? = nil) -> [GroupedDomain]? { - var Where = WhereClauseBuilder() - if let from = range?.start { Where.and("rowid >= ?", BindInt64(from)) } - if let to = range?.end { Where.and("rowid <= ?", BindInt64(to)) } - if ts != 0 { Where.and("ts >= ?", BindInt64(ts)) } - if ts2 != 0 { Where.and("ts < ?", BindInt64(ts2)) } + let Where = WhereClauseBuilder().and(in: range).and(min: ts1, max: ts2) let col: String // fqdn or domain if let parent = parentDomain { // is subdomain col = "fqdn" @@ -188,16 +205,9 @@ extension SQLiteDatabase { /// - Parameters: /// - fqdn: Exact match for domain name `fqdn = ?` /// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end ` - /// - ts: Restrict result set `ts >= ?` - /// - ts2: Restrict result set `ts < ?` /// - Returns: List sorted by reverse timestamp order (newest first) - func timesForDomain(_ fqdn: String, range: SQLiteRowRange? = nil, since ts: Timestamp = 0, upto ts2: Timestamp = 0) -> [GroupedTsOccurrence]? { - var Where = WhereClauseBuilder() - if let from = range?.start { Where.and("rowid >= ?", BindInt64(from)) } - if let to = range?.end { Where.and("rowid <= ?", BindInt64(to)) } - if ts != 0 { Where.and("ts >= ?", BindInt64(ts)) } - if ts2 != 0 { Where.and("ts < ?", BindInt64(ts2)) } - Where.and("fqdn = ?", BindText(fqdn)) + func timesForDomain(_ fqdn: String, range: SQLiteRowRange = (0,0)) -> [GroupedTsOccurrence]? { + let Where = WhereClauseBuilder().and(in: range).and("fqdn = ?", BindText(fqdn)) return try? run(sql: "SELECT ts, COUNT(ts), COUNT(opt) FROM heap \(Where) GROUP BY ts ORDER BY ts DESC;", bind: Where.bindings) { allRows($0) { (sqlite3_column_int64($0, 0), sqlite3_column_int($0, 1), sqlite3_column_int($0, 2)) diff --git a/main/DB/DBCore.swift b/main/DB/DBCore.swift index 47f5c69..a4ad215 100644 --- a/main/DB/DBCore.swift +++ b/main/DB/DBCore.swift @@ -13,6 +13,7 @@ enum SQLiteError: Error { /// `try? SQLiteDatabase.open()` var AppDB: SQLiteDatabase? { get { try? SQLiteDatabase.open() } } typealias SQLiteRowID = sqlite3_int64 +/// `0` indicates an unbound edge. typealias SQLiteRowRange = (start: SQLiteRowID, end: SQLiteRowID) // MARK: - SQLiteDatabase diff --git a/main/DB/TheGreatDestroyer.swift b/main/DB/TheGreatDestroyer.swift new file mode 100644 index 0000000..60bb59a --- /dev/null +++ b/main/DB/TheGreatDestroyer.swift @@ -0,0 +1,29 @@ +import Foundation + +struct TheGreatDestroyer { + + /// Callback fired when user performs row edit -> delete action + static func deleteLogs(domain: String, since ts: Timestamp, strict flag: Bool) { + sync.pause() + DispatchQueue.global().async { + defer { sync.continue() } + guard let db = AppDB, db.dnsLogsDelete(domain, strict: flag, since: ts) > 0 else { + return // nothing has changed + } + db.vacuum() + sync.needsReloadDB(domain: domain) + } + } + + /// Fired when user taps on Settings -> Delete All Logs + static func deleteAllLogs() { + sync.pause() + DispatchQueue.global().async { + defer { sync.continue() } + do { + try AppDB?.dnsLogsDeleteAll() + sync.needsReloadDB() + } catch {} + } + } +} diff --git a/main/Data Source/DomainFilter.swift b/main/Data Source/DomainFilter.swift index dd37e1f..18260fd 100644 --- a/main/Data Source/DomainFilter.swift +++ b/main/Data Source/DomainFilter.swift @@ -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] { diff --git a/main/Data Source/GroupedDomainDataSource.swift b/main/Data Source/GroupedDomainDataSource.swift index 61fc7c2..de93310 100644 --- a/main/Data Source/GroupedDomainDataSource.swift +++ b/main/Data Source/GroupedDomainDataSource.swift @@ -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 - private lazy var search = SearchBarManager(on: pipeline.delegate!.tableView) + let parent: String? + private let pipeline = FilterPipeline() + 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 diff --git a/main/Data Source/RecordingsDB.swift b/main/Data Source/RecordingsDB.swift index 9d43f7c..7f04a08 100644 --- a/main/Data Source/RecordingsDB.swift +++ b/main/Data Source/RecordingsDB.swift @@ -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 diff --git a/main/Data Source/SyncUpdate.swift b/main/Data Source/SyncUpdate.swift index 28813f7..d2d0bf7 100644 --- a/main/Data Source/SyncUpdate.swift +++ b/main/Data Source/SyncUpdate.swift @@ -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() + } + } } } diff --git a/main/Extensions/Generic.swift b/main/Extensions/Generic.swift index 1ef7146..3bbf75a 100644 --- a/main/Extensions/Generic.swift +++ b/main/Extensions/Generic.swift @@ -27,15 +27,34 @@ extension UIEdgeInsets { } } -infix operator =? : ComparisonPrecedence +precedencegroup CompareAssignPrecedence { + assignment: true + associativity: left + higherThan: ComparisonPrecedence +} + +infix operator <-? : CompareAssignPrecedence +infix operator <-/ : CompareAssignPrecedence extension Equatable { - /// Assign a new value to `lhs` if the `newValue` differs from the previous value. Return whether the new value was set. + /// Assign a new value to `lhs` if `newValue` differs from the previous value. Return `false` if they are equal. /// - Returns: `true` if `lhs` was overwritten with another value - static func =?(lhs: inout Self, newValue: Self) -> Bool { + static func <-?(lhs: inout Self, newValue: Self) -> Bool { if lhs != newValue { lhs = newValue return true } return false } + + /// Assign a new value to `lhs` if `newValue` differs from the previous value. + /// Return tuple with both values. Or `nil` if they are equal. + /// - Returns: `nil` if `previousValue == newValue` + static func <-/(lhs: inout Self, newValue: Self) -> (previousValue: Self, newValue: Self)? { + let previousValue = lhs + if previousValue != newValue { + lhs = newValue + return (previousValue, newValue) + } + return nil + } } diff --git a/main/Extensions/Notifications.swift b/main/Extensions/Notifications.swift index f09721b..98751f7 100644 --- a/main/Extensions/Notifications.swift +++ b/main/Extensions/Notifications.swift @@ -1,12 +1,9 @@ import Foundation let NotifyVPNStateChanged = NSNotification.Name("GlassVPNStateChanged") // VPNState! -let NotifyDNSFilterChanged = NSNotification.Name("PSIDNSFilterSettingsChanged") // domain: String? +let NotifyDNSFilterChanged = NSNotification.Name("PSIDNSFilterSettingsChanged") // domain: String! let NotifyDateFilterChanged = NSNotification.Name("PSIDateFilterSettingsChanged") // nil! let NotifySortOrderChanged = NSNotification.Name("PSIDateFilterSortOrderChanged") // nil! -let NotifyLogHistoryReset = NSNotification.Name("PSILogHistoryReset") // domain: String? -let NotifySyncInsert = NSNotification.Name("PSISyncInsert") // SQLiteRowRange! -let NotifySyncRemove = NSNotification.Name("PSISyncRemove") // SQLiteRowRange! let NotifyRecordingChanged = NSNotification.Name("PSIRecordingChanged") // (Recording, deleted: Bool)! diff --git a/main/Extensions/SharedState.swift b/main/Extensions/SharedState.swift index 265bad1..6813123 100644 --- a/main/Extensions/SharedState.swift +++ b/main/Extensions/SharedState.swift @@ -7,13 +7,15 @@ public enum VPNState : Int { case on = 1, inbetween, off } -struct Pref { +enum Pref { static func Int(_ key: String) -> Int { UserDefaults.standard.integer(forKey: key) } static func Int(_ val: Int, _ key: String) { UserDefaults.standard.set(val, forKey: key) } static func Bool(_ key: String) -> Bool { UserDefaults.standard.bool(forKey: key) } static func Bool(_ val: Bool, _ key: String) { UserDefaults.standard.set(val, forKey: key) } + static func `Any`(_ key: String) -> Any? { UserDefaults.standard.object(forKey: key) } + static func `Any`(_ val: Any?, _ key: String) { UserDefaults.standard.set(val, forKey: key) } - struct DidShowTutorial { + enum DidShowTutorial { static var Welcome: Bool { get { Pref.Bool("didShowTutorialAppWelcome") } set { Pref.Bool(newValue, "didShowTutorialAppWelcome") } @@ -23,7 +25,7 @@ struct Pref { set { Pref.Bool(newValue, "didShowTutorialRecordings") } } } - struct DateFilter { + enum DateFilter { static var Kind: DateFilterKind { get { DateFilterKind(rawValue: Pref.Int("dateFilterType"))! } set { Pref.Int(newValue.rawValue, "dateFilterType") } @@ -33,6 +35,16 @@ struct Pref { get { Pref.Int("dateFilterLastXMin") } set { Pref.Int(newValue, "dateFilterLastXMin") } } + /// Default: `nil` (disabled) + static var RangeA: Timestamp? { + get { Pref.Any("dateFilterRangeA") as? Timestamp } + set { Pref.Any(newValue, "dateFilterRangeA") } + } + /// Default: `nil` (disabled) + static var RangeB: Timestamp? { + get { Pref.Any("dateFilterRangeB") as? Timestamp } + set { Pref.Any(newValue, "dateFilterRangeB") } + } /// default: `.Date` static var OrderBy: DateFilterOrderBy { get { DateFilterOrderBy(rawValue: Pref.Int("dateFilterOderType"))! } @@ -44,11 +56,17 @@ struct Pref { set { Pref.Bool(newValue, "dateFilterOderAsc") } } - /// Return selected timestamp filter or `nil` if filtering is disabled. - /// - Returns: `Timestamp.now() - LastXMin * 60` - static func lastXMinTimestamp() -> Timestamp? { - if Kind != .LastXMin { return nil } - return Timestamp.past(minutes: Pref.DateFilter.LastXMin) + /// - Returns: Timestamp restriction depending on current selected date filter. + /// - `Off` : `(0, -1)` + /// - `LastXMin` : `(now-LastXMin, -1)` + /// - `ABRange` : `(RangeA, RangeB)` + static func restrictions() -> (type: DateFilterKind, earliest: Timestamp, latest: Timestamp) { + let type = Kind + switch type { + case .Off: return (type, 0, -1) + case .LastXMin: return (type, Timestamp.past(minutes: Pref.DateFilter.LastXMin), -1) + case .ABRange: return (type, Pref.DateFilter.RangeA ?? 0, Pref.DateFilter.RangeB ?? -1) + } } } } diff --git a/main/Extensions/String.swift b/main/Extensions/String.swift index dd075a0..b3e53ef 100644 --- a/main/Extensions/String.swift +++ b/main/Extensions/String.swift @@ -36,7 +36,7 @@ extension String { } } -var listOfSLDs: [String : [String : Bool]] = { +private var listOfSLDs: [String : [String : Bool]] = { let path = Bundle.main.url(forResource: "third-level", withExtension: "txt") let content = try! String(contentsOf: path!) var res: [String : [String : Bool]] = [:] diff --git a/main/Requests/TVCDomains.swift b/main/Requests/TVCDomains.swift index d9bc9ff..5a6a5b3 100644 --- a/main/Requests/TVCDomains.swift +++ b/main/Requests/TVCDomains.swift @@ -1,8 +1,8 @@ import UIKit -class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDelegate { +class TVCDomains: UITableViewController, UISearchBarDelegate, GroupedDomainDataSourceDelegate { - lazy var source = GroupedDomainDataSource(withDelegate: self, parent: nil) + lazy var source = GroupedDomainDataSource(withParent: nil) @IBOutlet private var filterButton: UIBarButtonItem! @IBOutlet private var filterButtonDetail: UIBarButtonItem! @@ -11,14 +11,7 @@ class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDele super.viewDidLoad() NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self) didChangeDateFilter() - } - - private var didLoadAlready = false - override func viewDidAppear(_ animated: Bool) { - if !didLoadAlready { - didLoadAlready = true - source.reloadFromSource() - } + source.delegate = self // init lazy var, ready for tableView data source } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { @@ -76,7 +69,7 @@ class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDele return cell } - func rowNeedsUpdate(_ row: Int) { + func groupedDomainDataSource(needsUpdate row: Int) { let entry = source[row] let cell = tableView.cellForRow(at: IndexPath(row: row)) cell?.detailTextLabel?.text = entry.detailCellText diff --git a/main/Requests/TVCHostDetails.swift b/main/Requests/TVCHostDetails.swift index 07fec53..47639ba 100644 --- a/main/Requests/TVCHostDetails.swift +++ b/main/Requests/TVCHostDetails.swift @@ -1,62 +1,16 @@ import UIKit -class TVCHostDetails: UITableViewController { +class TVCHostDetails: UITableViewController, SyncUpdateDelegate { public var fullDomain: String! private var dataSource: [GroupedTsOccurrence] = [] override func viewDidLoad() { - super.viewDidLoad() navigationItem.prompt = fullDomain + super.viewDidLoad() + sync.addObserver(self) // calls `syncUpdate(reset:)` if #available(iOS 10.0, *) { - tableView.refreshControl = UIRefreshControl(call: #selector(reloadDataSource), on: self) - } - NotifyLogHistoryReset.observe(call: #selector(reloadDataSource), on: self) - NotifySyncInsert.observe(call: #selector(syncInsert), on: self) - NotifySyncRemove.observe(call: #selector(syncRemove), on: self) - reloadDataSource() - } - - @objc func reloadDataSource(sender: Any? = nil) { - let refreshControl = sender as? UIRefreshControl - let notification = sender as? Notification - if let affectedDomain = notification?.object as? String { - guard fullDomain.isSubdomain(of: affectedDomain) else { return } - } - DispatchQueue.global().async { [weak self] in - self?.dataSource = AppDB?.timesForDomain(self?.fullDomain ?? "", since: sync.tsEarliest) ?? [] - DispatchQueue.main.sync { - self?.tableView.reloadData() - sync.syncNow() // sync outstanding entries in cache - refreshControl?.endRefreshing() - } - } - } - - @objc private func syncInsert(_ notification: Notification) { - let range = notification.object as! SQLiteRowRange - if let latest = AppDB?.timesForDomain(fullDomain, range: range), latest.count > 0 { - dataSource.insert(contentsOf: latest, at: 0) - if tableView.isFrontmost { - let indices = (0.. 0 else { + return + } + // TODO: if filter will be ever editable at this point, we cannot insert at 0 + dataSource.insert(contentsOf: latest, at: 0) + DispatchQueue.main.sync { + if tableView.isFrontmost { + let indices = (0..= earliest }), (i+1) < dataSource.count { + let indices = ((i+1).. 0 { + let indices = (dataSource.startIndex.. ts { + self.tsRangeA = ts // lower end of minute + self.buttonRangeStart.setTitle(DateFormat.minutes(ts), for: .normal) + } + if !flag || ts > self.tsRangeB { + self.tsRangeB = ts + 59 // upper end of minute + self.buttonRangeEnd.setTitle(DateFormat.minutes(ts + 59), for: .normal) + } }) } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - if gestureRecognizer.view == touch.view { - let newXMin = durationSlider.tag - let filterType: DateFilterKind - let orderType: DateFilterOrderBy - - switch filterBy.selectedSegmentIndex { - case 0: filterType = (newXMin > 0) ? .LastXMin : .Off - case 1: filterType = .ABRange - default: preconditionFailure() - } - switch orderbyType.selectedSegmentIndex { - case 0: orderType = .Date - case 1: orderType = .Name - case 2: orderType = .Count - default: preconditionFailure() - } - sync.pause() - let orderAsc = (orderbyAsc.selectedSegmentIndex == 0) - if Pref.DateFilter.OrderBy != orderType || Pref.DateFilter.OrderAsc != orderAsc { - Pref.DateFilter.OrderBy = orderType - Pref.DateFilter.OrderAsc = orderAsc - NotifySortOrderChanged.post() - } - if Pref.DateFilter.Kind != filterType || Pref.DateFilter.LastXMin != newXMin { - Pref.DateFilter.Kind = filterType - Pref.DateFilter.LastXMin = newXMin - NotifyDateFilterChanged.post() - } - sync.continue() + if gestureRecognizer.view === touch.view { + saveSettings() dismiss(animated: true) } return false } + + private func saveSettings() { + let newXMin = durationSlider.tag + let filterType: DateFilterKind + let orderType: DateFilterOrderBy + + switch filterBy.selectedSegmentIndex { + case 0: filterType = (newXMin > 0) ? .LastXMin : .Off + case 1: filterType = .ABRange + default: preconditionFailure() + } + switch orderbyType.selectedSegmentIndex { + case 0: orderType = .Date + case 1: orderType = .Name + case 2: orderType = .Count + default: preconditionFailure() + } + let a = Pref.DateFilter.OrderBy <-? orderType + let b = Pref.DateFilter.OrderAsc <-? (orderbyAsc.selectedSegmentIndex == 0) + if a || b { + NotifySortOrderChanged.post() + } + let c = Pref.DateFilter.Kind <-? filterType + let d = Pref.DateFilter.LastXMin <-? newXMin + let e = Pref.DateFilter.RangeA <-? (filterType == .ABRange ? tsRangeA : nil) + let f = Pref.DateFilter.RangeB <-? (filterType == .ABRange ? tsRangeB : nil) + if c || d || e || f { + NotifyDateFilterChanged.post() + } + } } diff --git a/main/Settings/TVCFilter.swift b/main/Settings/TVCFilter.swift index 511cd29..cf7a1de 100644 --- a/main/Settings/TVCFilter.swift +++ b/main/Settings/TVCFilter.swift @@ -2,23 +2,16 @@ import UIKit class TVCFilter: UITableViewController, EditActionsRemove { var currentFilter: FilterOptions = .none // set by segue - private var dataSource: [String] = [] + private lazy var dataSource = DomainFilter.list(where: currentFilter) override func viewDidLoad() { super.viewDidLoad() NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self) - reloadDataSource() - } - - func reloadDataSource() { - dataSource = DomainFilter.list(where: currentFilter) - tableView.reloadData() } @objc func didChangeDomainFilter(_ notification: Notification) { guard let domain = notification.object as? String else { - reloadDataSource() - return + preconditionFailure("Domain independent filter reset not implemented") } if DomainFilter[domain]?.contains(currentFilter) ?? false { let i = dataSource.binTreeIndex(of: domain, compare: (<))! diff --git a/main/Settings/TVCSettings.swift b/main/Settings/TVCSettings.swift index 04b8306..1f3f625 100644 --- a/main/Settings/TVCSettings.swift +++ b/main/Settings/TVCSettings.swift @@ -42,10 +42,7 @@ class TVCSettings: UITableViewController { "You are about to delete all results that have been logged in the past. " + "Your preferences for blocked and ignored domains are preserved.\n" + "Continue?", buttonText: "Delete", buttonStyle: .destructive) { _ in - DispatchQueue.global().async { - try? AppDB?.dnsLogsDeleteAll() - NotifyLogHistoryReset.postAsyncMain() - } + TheGreatDestroyer.deleteAllLogs() }.presentIn(self) }