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:
@@ -16,6 +16,7 @@
|
|||||||
541AC5DD2399498A00A769D7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DB2399498A00A769D7 /* Main.storyboard */; };
|
541AC5DD2399498A00A769D7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DB2399498A00A769D7 /* Main.storyboard */; };
|
||||||
541AC5DF2399498B00A769D7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DE2399498B00A769D7 /* Assets.xcassets */; };
|
541AC5DF2399498B00A769D7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DE2399498B00A769D7 /* Assets.xcassets */; };
|
||||||
541AC5E22399498B00A769D7 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5E02399498B00A769D7 /* LaunchScreen.storyboard */; };
|
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 */; };
|
542E2A982404973F001462DC /* TBCMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A972404973F001462DC /* TBCMain.swift */; };
|
||||||
542E2A9A24051556001462DC /* TVCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A9924051556001462DC /* TVCSettings.swift */; };
|
542E2A9A24051556001462DC /* TVCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A9924051556001462DC /* TVCSettings.swift */; };
|
||||||
543CDB2023EEE61900B7F323 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543CDB1F23EEE61900B7F323 /* PacketTunnelProvider.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 = "<group>"; };
|
541AC5DE2399498B00A769D7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
541AC5E12399498B00A769D7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
541AC5E12399498B00A769D7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
541AC5E32399498B00A769D7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
541AC5E32399498B00A769D7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
541FC9862497D81C00962623 /* TheGreatDestroyer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TheGreatDestroyer.swift; sourceTree = "<group>"; };
|
||||||
542E2A972404973F001462DC /* TBCMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBCMain.swift; sourceTree = "<group>"; };
|
542E2A972404973F001462DC /* TBCMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBCMain.swift; sourceTree = "<group>"; };
|
||||||
542E2A9924051556001462DC /* TVCSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCSettings.swift; sourceTree = "<group>"; };
|
542E2A9924051556001462DC /* TVCSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCSettings.swift; sourceTree = "<group>"; };
|
||||||
543CDB1D23EEE61900B7F323 /* GlassVPN.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GlassVPN.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 */,
|
54D8B97D2471B88900EB2414 /* DBCommon.swift */,
|
||||||
54D8B9822471BD8100EB2414 /* DBAppOnly.swift */,
|
54D8B9822471BD8100EB2414 /* DBAppOnly.swift */,
|
||||||
54B345AC241BBB00004C53CC /* DBExtensions.swift */,
|
54B345AC241BBB00004C53CC /* DBExtensions.swift */,
|
||||||
|
541FC9862497D81C00962623 /* TheGreatDestroyer.swift */,
|
||||||
);
|
);
|
||||||
path = DB;
|
path = DB;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -848,6 +851,7 @@
|
|||||||
542E2A982404973F001462DC /* TBCMain.swift in Sources */,
|
542E2A982404973F001462DC /* TBCMain.swift in Sources */,
|
||||||
54448A3224899A4000771C96 /* SearchBarManager.swift in Sources */,
|
54448A3224899A4000771C96 /* SearchBarManager.swift in Sources */,
|
||||||
5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */,
|
5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */,
|
||||||
|
541FC9872497D81C00962623 /* TheGreatDestroyer.swift in Sources */,
|
||||||
5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */,
|
5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */,
|
||||||
545DDDD124436983003B6544 /* QuickUI.swift in Sources */,
|
545DDDD124436983003B6544 /* QuickUI.swift in Sources */,
|
||||||
541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */,
|
541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */,
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import UIKit
|
|||||||
|
|
||||||
class DatePickerAlert: UIViewController {
|
class DatePickerAlert: UIViewController {
|
||||||
|
|
||||||
|
override var keyCommands: [UIKeyCommand]? {
|
||||||
|
[UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(didTapCancel))]
|
||||||
|
}
|
||||||
|
|
||||||
private var callback: (Date) -> Void
|
private var callback: (Date) -> Void
|
||||||
private let picker: UIDatePicker = {
|
private let picker: UIDatePicker = {
|
||||||
let x = UIDatePicker()
|
let x = UIDatePicker()
|
||||||
@@ -23,14 +27,17 @@ class DatePickerAlert: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal override func loadView() {
|
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 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()
|
save.titleLabel?.font = save.titleLabel?.font.bold()
|
||||||
|
now.titleLabel?.font = now.titleLabel?.font.bold()
|
||||||
|
now.setTitleColor(.sysFg, for: .normal)
|
||||||
//cancel.setTitleColor(.systemRed, for: .normal)
|
//cancel.setTitleColor(.systemRed, for: .normal)
|
||||||
|
|
||||||
let buttons = UIStackView(arrangedSubviews: [cancel, save])
|
let buttons = UIStackView(arrangedSubviews: [cancel, now, save])
|
||||||
buttons.axis = .horizontal
|
buttons.axis = .horizontal
|
||||||
buttons.distribution = .fillEqually
|
buttons.distribution = .equalSpacing
|
||||||
|
|
||||||
let bg = UIView(frame: picker.frame)
|
let bg = UIView(frame: picker.frame)
|
||||||
bg.frame.size.height += buttons.frame.height + 15
|
bg.frame.size.height += buttons.frame.height + 15
|
||||||
@@ -45,7 +52,7 @@ class DatePickerAlert: UIViewController {
|
|||||||
|
|
||||||
picker.anchor([.leading, .trailing, .top], to: bg)
|
picker.anchor([.leading, .trailing, .top], to: bg)
|
||||||
picker.bottomAnchor =&= buttons.topAnchor
|
picker.bottomAnchor =&= buttons.topAnchor
|
||||||
buttons.anchor([.leading, .trailing], to: bg)
|
buttons.anchor([.leading, .trailing], to: bg, margin: 25)
|
||||||
buttons.bottomAnchor =&= bg.bottomAnchor - 15
|
buttons.bottomAnchor =&= bg.bottomAnchor - 15
|
||||||
bg.anchor([.leading, .trailing, .bottom], to: clearBg)
|
bg.anchor([.leading, .trailing, .bottom], to: clearBg)
|
||||||
|
|
||||||
@@ -53,6 +60,10 @@ class DatePickerAlert: UIViewController {
|
|||||||
view.isHidden = true // otherwise picker will flash on present
|
view.isHidden = true // otherwise picker will flash on present
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func didTapNow() {
|
||||||
|
picker.date = Date()
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func didTapSave() {
|
@objc private func didTapSave() {
|
||||||
dismiss(animated: true) {
|
dismiss(animated: true) {
|
||||||
self.callback(self.picker.date)
|
self.callback(self.picker.date)
|
||||||
|
|||||||
@@ -1,34 +1,36 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
protocol FilterPipelineDelegate: UITableViewController {
|
protocol FilterPipelineDelegate: AnyObject {
|
||||||
/// Currently only called when a row is moved and the `tableView` is frontmost.
|
/// Call `reloadData()` on main thread.
|
||||||
func rowNeedsUpdate(_ row: Int)
|
/// - 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<T> {
|
class FilterPipeline<T> {
|
||||||
typealias DataSourceQuery = () -> [T]
|
|
||||||
|
|
||||||
private var sourceQuery: DataSourceQuery!
|
|
||||||
private(set) fileprivate var dataSource: [T] = []
|
private(set) fileprivate var dataSource: [T] = []
|
||||||
|
|
||||||
private var pipeline: [PipelineFilter<T>] = []
|
private var pipeline: [PipelineFilter<T>] = []
|
||||||
private var display: PipelineSorting<T>!
|
private var display: PipelineSorting<T>!
|
||||||
private(set) weak var delegate: FilterPipelineDelegate?
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// - Returns: Number of elements in `projection`
|
/// - Returns: Number of elements in `projection`
|
||||||
@inline(__always) func displayObjectCount() -> Int { display.projection.count }
|
@inline(__always) func displayObjectCount() -> Int { display.projection.count }
|
||||||
@@ -49,20 +51,12 @@ class FilterPipeline<T> {
|
|||||||
return (i, dataSource[i])
|
return (i, dataSource[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-query data source and re-built filter and display sorting order.
|
/// Set new 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.
|
/// - Note: Will call `filterPipelineDidReset()`
|
||||||
/// - Parameter fromSource: If `false` only re-built filter and sort order
|
func reset(dataSource: [T]) {
|
||||||
func reload(fromSource: Bool, whenDone: @escaping () -> Void) {
|
self.dataSource = dataSource
|
||||||
DispatchQueue.global().async {
|
self.resetFilters()
|
||||||
if fromSource {
|
delegate?.filterPipelineDidReset()
|
||||||
self.dataSource = self.sourceQuery()
|
|
||||||
}
|
|
||||||
self.resetFilters()
|
|
||||||
DispatchQueue.main.sync {
|
|
||||||
self.reloadTableCells()
|
|
||||||
whenDone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the index set of either the last filter layer, or `dataSource` if no filter is set.
|
/// Returns the index set of either the last filter layer, or `dataSource` if no filter is set.
|
||||||
@@ -80,12 +74,14 @@ class FilterPipeline<T> {
|
|||||||
|
|
||||||
/// Add new filter layer. Each layer is applied upon the previous layer. Therefore, each filter
|
/// 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.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - identifier: Use this id to find the filter again. For reload and remove operations.
|
/// - 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.
|
/// - 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.
|
/// - predicate: Return `true` if you want to keep the element.
|
||||||
func addFilter(_ identifier: String, before otherId: String? = nil, _ predicate: @escaping PipelineFilter<T>.Predicate) {
|
func addFilter(_ identifier: String, before otherId: String? = nil, _ predicate: @escaping PipelineFilter<T>.Predicate) {
|
||||||
|
guard indexOfFilter(identifier) == nil else { return }
|
||||||
let newFilter = PipelineFilter(identifier, predicate)
|
let newFilter = PipelineFilter(identifier, predicate)
|
||||||
if let other = otherId, let i = indexOfFilter(other) {
|
if let other = otherId, let i = indexOfFilter(other) {
|
||||||
pipeline.insert(newFilter, at: i)
|
pipeline.insert(newFilter, at: i)
|
||||||
@@ -95,11 +91,11 @@ class FilterPipeline<T> {
|
|||||||
pipeline.append(newFilter)
|
pipeline.append(newFilter)
|
||||||
display?.apply(moreRestrictive: newFilter.selection)
|
display?.apply(moreRestrictive: newFilter.selection)
|
||||||
}
|
}
|
||||||
reloadTableCells()
|
delegate?.filterPipelineDidReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find and remove filter with given identifier. Will automatically update remaining filters and display sorting.
|
/// 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) {
|
func removeFilter(withId ident: String) {
|
||||||
guard let i = indexOfFilter(ident) else { return }
|
guard let i = indexOfFilter(ident) else { return }
|
||||||
pipeline.remove(at: i)
|
pipeline.remove(at: i)
|
||||||
@@ -109,32 +105,34 @@ class FilterPipeline<T> {
|
|||||||
} else {
|
} else {
|
||||||
resetFilters(startingAt: i)
|
resetFilters(startingAt: i)
|
||||||
}
|
}
|
||||||
reloadTableCells()
|
delegate?.filterPipelineDidReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start filter evaluation on all entries from previous filter.
|
/// 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) {
|
func reloadFilter(withId ident: String) {
|
||||||
guard let i = indexOfFilter(ident) else { return }
|
guard let i = indexOfFilter(ident) else { return }
|
||||||
resetFilters(startingAt: i)
|
resetFilters(startingAt: i)
|
||||||
reloadTableCells()
|
delegate?.filterPipelineDidReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the sort and display order. You should set the `delegate` to automatically update your `tableView`.
|
/// 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.
|
/// - Parameter predicate: Return `true` if first element should be sorted before second element.
|
||||||
func setSorting(_ predicate: @escaping PipelineSorting<T>.Predicate) {
|
func setSorting(_ predicate: @escaping PipelineSorting<T>.Predicate) {
|
||||||
display = .init(predicate, pipe: self)
|
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`.
|
/// 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.
|
/// 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!
|
/// - Warning: Make sure `predicate` does reflect the change or it will lead to data inconsistency!
|
||||||
func reverseSorting() {
|
func reverseSorting() {
|
||||||
// TODO: use semaphore to prevent concurrent edits
|
// TODO: use semaphore to prevent concurrent edits
|
||||||
display?.reverseOrder()
|
display?.reverseOrder()
|
||||||
reloadTableCells()
|
delegate?.filterPipelineDidReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-built filter and display sorting order.
|
/// Re-built filter and display sorting order.
|
||||||
@@ -173,26 +171,8 @@ class FilterPipeline<T> {
|
|||||||
|
|
||||||
// MARK: data updates
|
// 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.
|
/// 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.
|
/// - 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) {
|
func addNew(_ obj: T) {
|
||||||
let index = dataSource.count
|
let index = dataSource.count
|
||||||
@@ -202,10 +182,11 @@ class FilterPipeline<T> {
|
|||||||
}
|
}
|
||||||
// survived all filters
|
// survived all filters
|
||||||
let displayIndex = display.insertNew(index)
|
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.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - obj: Element to be added. Will overwrite previous `dataSource` object.
|
/// - obj: Element to be added. Will overwrite previous `dataSource` object.
|
||||||
/// - index: Index in the original `dataSource`
|
/// - index: Index in the original `dataSource`
|
||||||
@@ -219,27 +200,23 @@ class FilterPipeline<T> {
|
|||||||
let oldPos = display.deleteOld(index)
|
let oldPos = display.deleteOld(index)
|
||||||
dataSource[index] = obj
|
dataSource[index] = obj
|
||||||
guard status.display else {
|
guard status.display else {
|
||||||
if cellAnimations, oldPos != -1 { delegate?.tableView.safeDeleteRows([oldPos]) }
|
if oldPos != -1 { delegate?.filterPipeline(delete: [oldPos]) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let newPos = display.insertNew(index, previousIndex: oldPos)
|
let newPos = display.insertNew(index, previousIndex: oldPos)
|
||||||
if cellAnimations {
|
if oldPos == -1 {
|
||||||
if oldPos == -1 {
|
delegate?.filterPipeline(insert: newPos)
|
||||||
delegate?.tableView.safeInsertRow(newPos, with: .left)
|
} else {
|
||||||
|
if oldPos == newPos {
|
||||||
|
delegate?.filterPipeline(update: oldPos)
|
||||||
} else {
|
} else {
|
||||||
if oldPos == newPos {
|
delegate?.filterPipeline(move: oldPos, to: newPos)
|
||||||
delegate?.tableView.safeReloadRow(oldPos)
|
|
||||||
} else {
|
|
||||||
delegate?.tableView.safeMoveRow(oldPos, to: newPos)
|
|
||||||
if delegate?.tableView.isFrontmost ?? false {
|
|
||||||
delegate?.rowNeedsUpdate(newPos)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove elements from the original `dataSource`, from all filters, and from display sorting.
|
/// 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`
|
/// - Parameter sorted: Indices in the original `dataSource`
|
||||||
/// - Complexity: O(*t*(*m*+*n*) + *m* log *n*), where *t* is the number of filters,
|
/// - 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.
|
/// *m* the number of elements in each filter / projection, and *n* the length of `sorted` indices.
|
||||||
@@ -252,14 +229,16 @@ class FilterPipeline<T> {
|
|||||||
filter.shiftRemove(indices: sorted)
|
filter.shiftRemove(indices: sorted)
|
||||||
}
|
}
|
||||||
let indices = display.shiftRemove(indices: sorted)
|
let indices = display.shiftRemove(indices: sorted)
|
||||||
if cellAnimations { delegate?.tableView.safeDeleteRows(indices) }
|
delegate?.filterPipeline(delete: indices)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Filter
|
// MARK: - Filter
|
||||||
|
|
||||||
class PipelineFilter<T> {
|
class PipelineFilter<T>: CustomStringConvertible {
|
||||||
|
var description: String { "\(Self.self)(id: \(id))" }
|
||||||
|
|
||||||
typealias Predicate = (T) -> Bool
|
typealias Predicate = (T) -> Bool
|
||||||
|
|
||||||
let id: String
|
let id: String
|
||||||
|
|||||||
@@ -56,14 +56,31 @@ extension SQLiteDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WhereClauseBuilder: CustomStringConvertible {
|
class WhereClauseBuilder: CustomStringConvertible {
|
||||||
var description: String = ""
|
var description: String = ""
|
||||||
private let prefix: String
|
private let prefix: String
|
||||||
private(set) var bindings: [DBBinding] = []
|
private(set) var bindings: [DBBinding] = []
|
||||||
|
|
||||||
init(prefix p: String = "WHERE") { prefix = "\(p) " }
|
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)
|
description.append((description=="" ? prefix : " AND ") + clause)
|
||||||
bindings.append(contentsOf: bind)
|
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
|
/// - Parameter strict: If `true`, use `fqdn` instead of `domain` column
|
||||||
/// - Returns: Number of changes aka. Number of rows deleted
|
/// - Returns: Number of changes aka. Number of rows deleted
|
||||||
@discardableResult func dnsLogsDelete(_ domain: String, strict: Bool, since ts: Timestamp = 0) -> Int32 {
|
@discardableResult func dnsLogsDelete(_ domain: String, strict: Bool, since ts: Timestamp = 0) -> Int32 {
|
||||||
var Where = WhereClauseBuilder()
|
let Where = WhereClauseBuilder().and(min: ts)
|
||||||
if ts != 0 { Where.and("ts >= ?", BindInt64(ts)) }
|
|
||||||
Where.and("\(strict ? "fqdn" : "domain") = ?", BindText(domain)) // (fqdn = ? OR fqdn LIKE '%.' || ?)
|
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
|
return (try? run(sql: "DELETE FROM heap \(Where);", bind: Where.bindings) { stmt -> Int32 in
|
||||||
try ifStep(stmt, SQLITE_DONE)
|
try ifStep(stmt, SQLITE_DONE)
|
||||||
@@ -130,6 +146,7 @@ extension SQLiteDatabase {
|
|||||||
|
|
||||||
// MARK: read
|
// MARK: read
|
||||||
|
|
||||||
|
/// `SELECT min(ts) FROM heap`
|
||||||
func dnsLogsMinDate() -> Timestamp? {
|
func dnsLogsMinDate() -> Timestamp? {
|
||||||
try? run(sql:"SELECT min(ts) FROM heap") {
|
try? run(sql:"SELECT min(ts) FROM heap") {
|
||||||
try ifStep($0, SQLITE_ROW)
|
try ifStep($0, SQLITE_ROW)
|
||||||
@@ -138,10 +155,14 @@ extension SQLiteDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Select min and max row id with given condition `ts >= ? AND ts < ?`
|
/// 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
|
/// - Returns: `nil` in case no rows are matching the condition
|
||||||
func dnsLogsRowRange(between ts: Timestamp, and ts2: Timestamp) -> SQLiteRowRange? {
|
func dnsLogsRowRange(between ts1: Timestamp, and ts2: Timestamp, within range: SQLiteRowRange = (0,0)) -> SQLiteRowRange? {
|
||||||
try? run(sql:"SELECT min(rowid), max(rowid) FROM heap WHERE ts >= ? AND ts < ?",
|
let Where = WhereClauseBuilder().and(in: range).and(min: ts1, max: ts2)
|
||||||
bind: [BindInt64(ts), BindInt64(ts2)]) {
|
return try? run(sql:"SELECT min(rowid), max(rowid) FROM heap \(Where);", bind: Where.bindings) {
|
||||||
try ifStep($0, SQLITE_ROW)
|
try ifStep($0, SQLITE_ROW)
|
||||||
let max = sqlite3_column_int64($0, 1)
|
let max = sqlite3_column_int64($0, 1)
|
||||||
return (max == 0) ? nil : (sqlite3_column_int64($0, 0), max)
|
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.
|
/// Group DNS logs by domain, count occurences and number of blocked requests.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end `
|
/// - 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 < ?`
|
/// - ts2: Restrict result set `ts < ?`
|
||||||
/// - matchingDomain: Restrict `(fqdn|domain) = ?`. Which column is used is determined by `parentDomain`.
|
/// - 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`.
|
/// - 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.
|
/// - 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]?
|
matchingDomain: String? = nil, parentDomain: String? = nil) -> [GroupedDomain]?
|
||||||
{
|
{
|
||||||
var Where = WhereClauseBuilder()
|
let Where = WhereClauseBuilder().and(in: range).and(min: ts1, max: ts2)
|
||||||
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 col: String // fqdn or domain
|
let col: String // fqdn or domain
|
||||||
if let parent = parentDomain { // is subdomain
|
if let parent = parentDomain { // is subdomain
|
||||||
col = "fqdn"
|
col = "fqdn"
|
||||||
@@ -188,16 +205,9 @@ extension SQLiteDatabase {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - fqdn: Exact match for domain name `fqdn = ?`
|
/// - fqdn: Exact match for domain name `fqdn = ?`
|
||||||
/// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end `
|
/// - 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)
|
/// - 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]? {
|
func timesForDomain(_ fqdn: String, range: SQLiteRowRange = (0,0)) -> [GroupedTsOccurrence]? {
|
||||||
var Where = WhereClauseBuilder()
|
let Where = WhereClauseBuilder().and(in: range).and("fqdn = ?", BindText(fqdn))
|
||||||
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))
|
|
||||||
return try? run(sql: "SELECT ts, COUNT(ts), COUNT(opt) FROM heap \(Where) GROUP BY ts ORDER BY ts DESC;", bind: Where.bindings) {
|
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) {
|
allRows($0) {
|
||||||
(sqlite3_column_int64($0, 0), sqlite3_column_int($0, 1), sqlite3_column_int($0, 2))
|
(sqlite3_column_int64($0, 0), sqlite3_column_int($0, 1), sqlite3_column_int($0, 2))
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ enum SQLiteError: Error {
|
|||||||
/// `try? SQLiteDatabase.open()`
|
/// `try? SQLiteDatabase.open()`
|
||||||
var AppDB: SQLiteDatabase? { get { try? SQLiteDatabase.open() } }
|
var AppDB: SQLiteDatabase? { get { try? SQLiteDatabase.open() } }
|
||||||
typealias SQLiteRowID = sqlite3_int64
|
typealias SQLiteRowID = sqlite3_int64
|
||||||
|
/// `0` indicates an unbound edge.
|
||||||
typealias SQLiteRowRange = (start: SQLiteRowID, end: SQLiteRowID)
|
typealias SQLiteRowRange = (start: SQLiteRowID, end: SQLiteRowID)
|
||||||
|
|
||||||
// MARK: - SQLiteDatabase
|
// MARK: - SQLiteDatabase
|
||||||
|
|||||||
29
main/DB/TheGreatDestroyer.swift
Normal file
29
main/DB/TheGreatDestroyer.swift
Normal file
@@ -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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum DomainFilter {
|
enum DomainFilter {
|
||||||
static private var data: [String: FilterOptions] = {
|
static private var data = AppDB?.loadFilters() ?? [:]
|
||||||
AppDB?.loadFilters() ?? [:]
|
|
||||||
}()
|
|
||||||
|
|
||||||
/// Get filter with given `domain` name
|
/// Get filter with given `domain` name
|
||||||
@inline(__always) static subscript(_ domain: String) -> FilterOptions? {
|
@inline(__always) static subscript(_ domain: String) -> FilterOptions? {
|
||||||
@@ -12,10 +10,10 @@ enum DomainFilter {
|
|||||||
|
|
||||||
/// Update local memory object by loading values from persistent db.
|
/// Update local memory object by loading values from persistent db.
|
||||||
/// - Note: Will trigger `NotifyDNSFilterChanged` notification.
|
/// - Note: Will trigger `NotifyDNSFilterChanged` notification.
|
||||||
static func reload() {
|
// static func reload() {
|
||||||
data = AppDB?.loadFilters() ?? [:]
|
// data = AppDB?.loadFilters() ?? [:]
|
||||||
NotifyDNSFilterChanged.post()
|
// NotifyDNSFilterChanged.post()
|
||||||
}
|
// }
|
||||||
|
|
||||||
/// Get list of domains (sorted by name) which do contain the given filter
|
/// Get list of domains (sorted by name) which do contain the given filter
|
||||||
static func list(where matching: FilterOptions) -> [String] {
|
static func list(where matching: FilterOptions) -> [String] {
|
||||||
|
|||||||
@@ -1,54 +1,52 @@
|
|||||||
import UIKit
|
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
|
// # MARK: DataSource
|
||||||
// #
|
// #
|
||||||
// ##########################
|
// ##########################
|
||||||
|
|
||||||
class GroupedDomainDataSource {
|
class GroupedDomainDataSource: FilterPipelineDelegate, SyncUpdateDelegate {
|
||||||
|
|
||||||
private var tsLatest: Timestamp = 0
|
let parent: String?
|
||||||
|
private let pipeline = FilterPipeline<GroupedDomain>()
|
||||||
private let parent: String?
|
private lazy var search = SearchBarManager(on: delegate!.tableView)
|
||||||
let pipeline: FilterPipeline<GroupedDomain>
|
|
||||||
private lazy var search = SearchBarManager(on: pipeline.delegate!.tableView)
|
|
||||||
private var currentOrder: DateFilterOrderBy = .Date
|
private var currentOrder: DateFilterOrderBy = .Date
|
||||||
private var orderAsc = false
|
private var orderAsc = false
|
||||||
|
|
||||||
init(withDelegate tvc: FilterPipelineDelegate, parent p: String?) {
|
/// Will init `sync.allowPullToRefresh()` on `tableView.refreshControl` as well.
|
||||||
parent = p
|
weak var delegate: GroupedDomainDataSourceDelegate? {
|
||||||
pipeline = .init(withDelegate: tvc)
|
willSet { if #available(iOS 10.0, *), newValue !== delegate {
|
||||||
pipeline.setDataSource { [unowned self] in self.dataSourceCallback() }
|
sync.allowPullToRefresh(onTVC: newValue, forObserver: self)
|
||||||
|
}}}
|
||||||
|
|
||||||
|
/// - Note: Will call `tableview.reloadData()`
|
||||||
|
init(withParent: String?) {
|
||||||
|
parent = withParent
|
||||||
|
pipeline.delegate = self
|
||||||
resetSortingOrder(force: true)
|
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)
|
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
|
||||||
NotifySortOrderChanged.observe(call: #selector(didChangeSortOrder), 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
|
/// Callback fired when user changes date filter settings. (`NotifySortOrderChanged` notification)
|
||||||
private func dataSourceCallback() -> [GroupedDomain] {
|
@objc private func didChangeSortOrder(_ notification: Notification) {
|
||||||
guard let db = AppDB else { return [] }
|
resetSortingOrder()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read user defaults and apply new sorting order. Either by setting a new or reversing the current.
|
/// 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.
|
/// - Parameter force: If `true` set new sorting even if the type does not differ.
|
||||||
private func resetSortingOrder(force: Bool = false) {
|
private func resetSortingOrder(force: Bool = false) {
|
||||||
let orderDidChange = (orderAsc =? Pref.DateFilter.OrderAsc)
|
let orderDidChange = (orderAsc <-? Pref.DateFilter.OrderAsc)
|
||||||
if currentOrder =? Pref.DateFilter.OrderBy || force {
|
if currentOrder <-? Pref.DateFilter.OrderBy || force {
|
||||||
switch currentOrder {
|
switch currentOrder {
|
||||||
case .Date:
|
case .Date:
|
||||||
pipeline.setSorting { [unowned self] in
|
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)
|
/// Callback fired when user edits list of `blocked` or `ignored` domains in settings. (`NotifyDNSFilterChanged` notification)
|
||||||
@objc private func didChangeDomainFilter(_ notification: Notification) {
|
@objc private func didChangeDomainFilter(_ notification: Notification) {
|
||||||
guard let domain = notification.object as? String else {
|
guard let domain = notification.object as? String else {
|
||||||
reloadFromSource()
|
preconditionFailure("Domain independent filter reset not implemented") // `syncUpdate(reset:)` async!
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == domain }) {
|
if let x = pipeline.dataSourceGet(where: { $0.domain == domain }) {
|
||||||
var y = obj
|
var obj = x.object
|
||||||
y.options = DomainFilter[domain]
|
obj.options = DomainFilter[domain]
|
||||||
pipeline.update(y, at: i)
|
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
|
// MARK: Table View Data Source
|
||||||
|
|
||||||
@inline(__always) var numberOfRows: Int { get { pipeline.displayObjectCount() } }
|
@inline(__always) var numberOfRows: Int { get { pipeline.displayObjectCount() } }
|
||||||
|
|
||||||
@inline(__always) subscript(_ row: Int) -> GroupedDomain { pipeline.displayObject(at: row) }
|
@inline(__always) subscript(_ row: Int) -> GroupedDomain { pipeline.displayObject(at: row) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: partial updates
|
// ################################
|
||||||
|
// #
|
||||||
|
// # MARK: - Partial Update
|
||||||
|
// #
|
||||||
|
// ################################
|
||||||
|
|
||||||
/// Callback fired when background sync added new entries to the list. (`NotifySyncInsert` notification)
|
extension GroupedDomainDataSource {
|
||||||
@objc private func syncInsert(_ notification: Notification) {
|
|
||||||
sync.pause()
|
func syncUpdate(_: SyncUpdate, reset rows: SQLiteRowRange) {
|
||||||
defer { sync.continue() }
|
var logs = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent) ?? []
|
||||||
let range = notification.object as! SQLiteRowRange
|
for (i, val) in logs.enumerated() {
|
||||||
guard let latest = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent) else {
|
logs[i].options = DomainFilter[val.domain]
|
||||||
|
}
|
||||||
|
pipeline.reset(dataSource: logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncUpdate(_: SyncUpdate, insert rows: SQLiteRowRange) {
|
||||||
|
guard let latest = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent) else {
|
||||||
assertionFailure("NotifySyncInsert fired with empty range")
|
assertionFailure("NotifySyncInsert fired with empty range")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pipeline.pauseCellAnimations(if: latest.count > 14)
|
cellAnimationsGroup(if: latest.count > 14)
|
||||||
for x in latest {
|
for x in latest {
|
||||||
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) {
|
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) {
|
||||||
pipeline.update(obj + x, at: i)
|
pipeline.update(obj + x, at: i)
|
||||||
@@ -134,21 +117,16 @@ class GroupedDomainDataSource {
|
|||||||
y.options = DomainFilter[x.domain]
|
y.options = DomainFilter[x.domain]
|
||||||
pipeline.addNew(y)
|
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)
|
func syncUpdate(_: SyncUpdate, remove rows: SQLiteRowRange) {
|
||||||
@objc private func syncRemove(_ notification: Notification) {
|
guard let outdated = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent),
|
||||||
sync.pause()
|
|
||||||
defer { sync.continue() }
|
|
||||||
let range = notification.object as! SQLiteRowRange
|
|
||||||
guard let outdated = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent),
|
|
||||||
outdated.count > 0 else {
|
outdated.count > 0 else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pipeline.pauseCellAnimations(if: outdated.count > 14)
|
cellAnimationsGroup(if: outdated.count > 14)
|
||||||
var listOfDeletes: [Int] = []
|
var listOfDeletes: [Int] = []
|
||||||
for x in outdated {
|
for x in outdated {
|
||||||
guard let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) else {
|
guard let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) else {
|
||||||
@@ -162,34 +140,10 @@ class GroupedDomainDataSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pipeline.remove(indices: listOfDeletes.sorted())
|
pipeline.remove(indices: listOfDeletes.sorted())
|
||||||
pipeline.continueCellAnimations(reloadTable: true)
|
cellAnimationsCommit()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ################################
|
|
||||||
// #
|
|
||||||
// # 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(:)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reload a single data source entry. Callback fired by `reloadFromSource()`
|
func syncUpdate(_ sender: SyncUpdate, partialRemove affectedFQDN: String) {
|
||||||
/// Only useful if `affectedFQDN` currently exists in `dataSource`. Can either update or remove entry.
|
|
||||||
private func partiallyReloadFromSource(_ affectedFQDN: String) {
|
|
||||||
let affectedParent = affectedFQDN.extractDomain()
|
let affectedParent = affectedFQDN.extractDomain()
|
||||||
guard parent == nil || parent == affectedParent else {
|
guard parent == nil || parent == affectedParent else {
|
||||||
return // does not affect current table
|
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
|
// can only happen if delete sheet is open while background sync removed the element
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if var updated = AppDB?.dnsLogsGrouped(since: sync.tsEarliest, upto: tsLatest,
|
if var updated = AppDB?.dnsLogsGrouped(range: sender.rows, matchingDomain: affected,
|
||||||
matchingDomain: affected, parentDomain: parent)?.first {
|
parentDomain: parent)?.first {
|
||||||
assert(old.object.domain == updated.domain)
|
assert(old.object.domain == updated.domain)
|
||||||
updated.options = DomainFilter[updated.domain]
|
updated.options = DomainFilter[updated.domain]
|
||||||
pipeline.update(updated, at: old.index)
|
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
|
// # MARK: - Search
|
||||||
@@ -221,9 +226,9 @@ extension GroupedDomainDataSource {
|
|||||||
func toggleSearch() {
|
func toggleSearch() {
|
||||||
if search.active { search.hide() }
|
if search.active { search.hide() }
|
||||||
else {
|
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.
|
// This is due to `addFilter` calling `reloadData()` before `search.show()` can animate it.
|
||||||
pipeline.pauseCellAnimations()
|
cellAnimationsGroup()
|
||||||
var searchTerm = ""
|
var searchTerm = ""
|
||||||
pipeline.addFilter("search") {
|
pipeline.addFilter("search") {
|
||||||
$0.domain.lowercased().contains(searchTerm)
|
$0.domain.lowercased().contains(searchTerm)
|
||||||
@@ -234,7 +239,7 @@ extension GroupedDomainDataSource {
|
|||||||
searchTerm = $0.lowercased()
|
searchTerm = $0.lowercased()
|
||||||
self.pipeline.reloadFilter(withId: "search")
|
self.pipeline.reloadFilter(withId: "search")
|
||||||
})
|
})
|
||||||
pipeline.continueCellAnimations()
|
cellAnimationsCommit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,8 +251,8 @@ extension GroupedDomainDataSource {
|
|||||||
// #
|
// #
|
||||||
// ##########################
|
// ##########################
|
||||||
|
|
||||||
protocol GroupedDomainEditRow : EditableRows, FilterPipelineDelegate {
|
protocol GroupedDomainEditRow : UIViewController, EditableRows {
|
||||||
var source: GroupedDomainDataSource { get set }
|
var source: GroupedDomainDataSource { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension GroupedDomainEditRow {
|
extension GroupedDomainEditRow {
|
||||||
@@ -274,8 +279,10 @@ extension GroupedDomainEditRow {
|
|||||||
case .ignore: showFilterSheet(entry, .ignored)
|
case .ignore: showFilterSheet(entry, .ignored)
|
||||||
case .block: showFilterSheet(entry, .blocked)
|
case .block: showFilterSheet(entry, .blocked)
|
||||||
case .delete:
|
case .delete:
|
||||||
AlertDeleteLogs(entry.domain, latest: entry.lastModified) {
|
let name = entry.domain
|
||||||
self.source.deleteHistory(domain: entry.domain, since: $0)
|
let flag = (source.parent != nil)
|
||||||
|
AlertDeleteLogs(name, latest: entry.lastModified) {
|
||||||
|
TheGreatDestroyer.deleteLogs(domain: name, since: $0, strict: flag)
|
||||||
}.presentIn(self)
|
}.presentIn(self)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ enum RecordingsDB {
|
|||||||
|
|
||||||
/// Copy log entries from generic `heap` table to recording specific `recLog` table
|
/// Copy log entries from generic `heap` table to recording specific `recLog` table
|
||||||
static func persist(_ r: Recording) {
|
static func persist(_ r: Recording) {
|
||||||
sync.syncNow() // persist changes in cache before copying recording details
|
sync.syncNow { // persist changes in cache before copying recording details
|
||||||
AppDB?.recordingLogsPersist(r)
|
AppDB?.recordingLogsPersist(r)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get list of domains that occured during the recording
|
/// Get list of domains that occured during the recording
|
||||||
|
|||||||
@@ -1,26 +1,63 @@
|
|||||||
import Foundation
|
import UIKit
|
||||||
|
|
||||||
class SyncUpdate {
|
class SyncUpdate {
|
||||||
private var lastSync: TimeInterval = 0
|
private var lastSync: TimeInterval = 0
|
||||||
private var timer: Timer!
|
private var timer: Timer!
|
||||||
private var paused: Int = 1 // first start() will decrement
|
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) {
|
init(periodic interval: TimeInterval) {
|
||||||
tsEarliest = Pref.DateFilter.lastXMinTimestamp() ?? 0
|
(filterType, tsEarliest, tsLatest) = Pref.DateFilter.restrictions()
|
||||||
|
reloadRangeFromDB()
|
||||||
|
|
||||||
NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self)
|
NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self)
|
||||||
timer = Timer.repeating(interval, call: #selector(periodicUpdate), on: self)
|
timer = Timer.repeating(interval, call: #selector(periodicUpdate), on: self)
|
||||||
syncNow() // because timer will only fire after interval
|
syncNow() // because timer will only fire after interval
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Callback fired every `7` seconds.
|
||||||
@objc private func periodicUpdate() { if paused == 0 { syncNow() } }
|
@objc private func periodicUpdate() { if paused == 0 { syncNow() } }
|
||||||
|
|
||||||
|
/// Callback fired when user changes `DateFilter` on root tableView controller
|
||||||
@objc private func didChangeDateFilter() {
|
@objc private func didChangeDateFilter() {
|
||||||
|
self.pause()
|
||||||
|
let filter = Pref.DateFilter.restrictions()
|
||||||
|
filterType = filter.type
|
||||||
DispatchQueue.global().async {
|
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.
|
/// This will immediately resume timer updates, ignoring previous `pause()` requests.
|
||||||
func start() { paused = 0 }
|
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`)
|
/// 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 is rate limited. Sync will be performed at most once per second.
|
||||||
/// - Note: This method returns immediatelly. Syncing is done in a background thread.
|
/// - 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
|
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
|
lastSync = now
|
||||||
|
self.pause() // reduce concurrent load
|
||||||
DispatchQueue.global().async {
|
DispatchQueue.global().async {
|
||||||
self.pause() // reduce concurrent load
|
self.internalSync()
|
||||||
|
block?()
|
||||||
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.continue()
|
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!
|
/// - Warning: Always call from a background thread!
|
||||||
private func set(newEarliest: Timestamp) {
|
private func update(newEarliest: Timestamp) {
|
||||||
let current = tsEarliest
|
if let (old, new) = tsEarliest <-/ newEarliest {
|
||||||
tsEarliest = newEarliest
|
if new < old {
|
||||||
if current < newEarliest {
|
publishInsert(front: true, new, (tsLatest == -1 ? old : min(old, tsLatest + 1)), scope: (0, range?.start ?? 0))
|
||||||
if let excess = AppDB?.dnsLogsRowRange(between: current, and: newEarliest) {
|
} else if range != nil {
|
||||||
NotifySyncRemove.postAsyncMain(excess)
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
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
|
/// - 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 {
|
if lhs != newValue {
|
||||||
lhs = newValue
|
lhs = newValue
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
let NotifyVPNStateChanged = NSNotification.Name("GlassVPNStateChanged") // VPNState!
|
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 NotifyDateFilterChanged = NSNotification.Name("PSIDateFilterSettingsChanged") // nil!
|
||||||
let NotifySortOrderChanged = NSNotification.Name("PSIDateFilterSortOrderChanged") // 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)!
|
let NotifyRecordingChanged = NSNotification.Name("PSIRecordingChanged") // (Recording, deleted: Bool)!
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ public enum VPNState : Int {
|
|||||||
case on = 1, inbetween, off
|
case on = 1, inbetween, off
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Pref {
|
enum Pref {
|
||||||
static func Int(_ key: String) -> Int { UserDefaults.standard.integer(forKey: key) }
|
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 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(_ key: String) -> Bool { UserDefaults.standard.bool(forKey: key) }
|
||||||
static func Bool(_ val: Bool, _ key: String) { UserDefaults.standard.set(val, 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 {
|
static var Welcome: Bool {
|
||||||
get { Pref.Bool("didShowTutorialAppWelcome") }
|
get { Pref.Bool("didShowTutorialAppWelcome") }
|
||||||
set { Pref.Bool(newValue, "didShowTutorialAppWelcome") }
|
set { Pref.Bool(newValue, "didShowTutorialAppWelcome") }
|
||||||
@@ -23,7 +25,7 @@ struct Pref {
|
|||||||
set { Pref.Bool(newValue, "didShowTutorialRecordings") }
|
set { Pref.Bool(newValue, "didShowTutorialRecordings") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
struct DateFilter {
|
enum DateFilter {
|
||||||
static var Kind: DateFilterKind {
|
static var Kind: DateFilterKind {
|
||||||
get { DateFilterKind(rawValue: Pref.Int("dateFilterType"))! }
|
get { DateFilterKind(rawValue: Pref.Int("dateFilterType"))! }
|
||||||
set { Pref.Int(newValue.rawValue, "dateFilterType") }
|
set { Pref.Int(newValue.rawValue, "dateFilterType") }
|
||||||
@@ -33,6 +35,16 @@ struct Pref {
|
|||||||
get { Pref.Int("dateFilterLastXMin") }
|
get { Pref.Int("dateFilterLastXMin") }
|
||||||
set { Pref.Int(newValue, "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`
|
/// default: `.Date`
|
||||||
static var OrderBy: DateFilterOrderBy {
|
static var OrderBy: DateFilterOrderBy {
|
||||||
get { DateFilterOrderBy(rawValue: Pref.Int("dateFilterOderType"))! }
|
get { DateFilterOrderBy(rawValue: Pref.Int("dateFilterOderType"))! }
|
||||||
@@ -44,11 +56,17 @@ struct Pref {
|
|||||||
set { Pref.Bool(newValue, "dateFilterOderAsc") }
|
set { Pref.Bool(newValue, "dateFilterOderAsc") }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return selected timestamp filter or `nil` if filtering is disabled.
|
/// - Returns: Timestamp restriction depending on current selected date filter.
|
||||||
/// - Returns: `Timestamp.now() - LastXMin * 60`
|
/// - `Off` : `(0, -1)`
|
||||||
static func lastXMinTimestamp() -> Timestamp? {
|
/// - `LastXMin` : `(now-LastXMin, -1)`
|
||||||
if Kind != .LastXMin { return nil }
|
/// - `ABRange` : `(RangeA, RangeB)`
|
||||||
return Timestamp.past(minutes: Pref.DateFilter.LastXMin)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 path = Bundle.main.url(forResource: "third-level", withExtension: "txt")
|
||||||
let content = try! String(contentsOf: path!)
|
let content = try! String(contentsOf: path!)
|
||||||
var res: [String : [String : Bool]] = [:]
|
var res: [String : [String : Bool]] = [:]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import UIKit
|
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 filterButton: UIBarButtonItem!
|
||||||
@IBOutlet private var filterButtonDetail: UIBarButtonItem!
|
@IBOutlet private var filterButtonDetail: UIBarButtonItem!
|
||||||
@@ -11,14 +11,7 @@ class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDele
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self)
|
NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self)
|
||||||
didChangeDateFilter()
|
didChangeDateFilter()
|
||||||
}
|
source.delegate = self // init lazy var, ready for tableView data source
|
||||||
|
|
||||||
private var didLoadAlready = false
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
|
||||||
if !didLoadAlready {
|
|
||||||
didLoadAlready = true
|
|
||||||
source.reloadFromSource()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||||
@@ -76,7 +69,7 @@ class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDele
|
|||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
func rowNeedsUpdate(_ row: Int) {
|
func groupedDomainDataSource(needsUpdate row: Int) {
|
||||||
let entry = source[row]
|
let entry = source[row]
|
||||||
let cell = tableView.cellForRow(at: IndexPath(row: row))
|
let cell = tableView.cellForRow(at: IndexPath(row: row))
|
||||||
cell?.detailTextLabel?.text = entry.detailCellText
|
cell?.detailTextLabel?.text = entry.detailCellText
|
||||||
|
|||||||
@@ -1,62 +1,16 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class TVCHostDetails: UITableViewController {
|
class TVCHostDetails: UITableViewController, SyncUpdateDelegate {
|
||||||
|
|
||||||
public var fullDomain: String!
|
public var fullDomain: String!
|
||||||
private var dataSource: [GroupedTsOccurrence] = []
|
private var dataSource: [GroupedTsOccurrence] = []
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
|
||||||
navigationItem.prompt = fullDomain
|
navigationItem.prompt = fullDomain
|
||||||
|
super.viewDidLoad()
|
||||||
|
sync.addObserver(self) // calls `syncUpdate(reset:)`
|
||||||
if #available(iOS 10.0, *) {
|
if #available(iOS 10.0, *) {
|
||||||
tableView.refreshControl = UIRefreshControl(call: #selector(reloadDataSource), on: self)
|
sync.allowPullToRefresh(onTVC: self, forObserver: 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..<latest.count).map { IndexPath(row: $0) }
|
|
||||||
tableView.insertRows(at: indices, with: .left)
|
|
||||||
} else {
|
|
||||||
tableView.reloadData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func syncRemove(_ notification: Notification) {
|
|
||||||
let earliest = sync.tsEarliest
|
|
||||||
if let i = dataSource.firstIndex(where: { $0.ts < earliest }) {
|
|
||||||
// since they are ordered, we can optimize
|
|
||||||
let indices = (i..<dataSource.endIndex).map { IndexPath(row: $0) }
|
|
||||||
dataSource.removeLast(dataSource.count - i)
|
|
||||||
if tableView.isFrontmost {
|
|
||||||
tableView.deleteRows(at: indices, with: .automatic)
|
|
||||||
} else {
|
|
||||||
tableView.reloadData()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,3 +27,55 @@ class TVCHostDetails: UITableViewController {
|
|||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ################################
|
||||||
|
// #
|
||||||
|
// # MARK: - Partial Update
|
||||||
|
// #
|
||||||
|
// ################################
|
||||||
|
|
||||||
|
extension TVCHostDetails {
|
||||||
|
|
||||||
|
func syncUpdate(_ _: SyncUpdate, reset rows: SQLiteRowRange) {
|
||||||
|
dataSource = AppDB?.timesForDomain(fullDomain, range: rows) ?? []
|
||||||
|
DispatchQueue.main.sync { tableView.reloadData() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncUpdate(_ _: SyncUpdate, insert rows: SQLiteRowRange) {
|
||||||
|
guard let latest = AppDB?.timesForDomain(fullDomain, range: rows), latest.count > 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..<latest.count).map { IndexPath(row: $0) }
|
||||||
|
tableView.insertRows(at: indices, with: .left)
|
||||||
|
} else {
|
||||||
|
tableView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncUpdate(_ sender: SyncUpdate, remove _: SQLiteRowRange) {
|
||||||
|
let earliest = sender.tsEarliest
|
||||||
|
let latest = sender.tsLatest
|
||||||
|
// Assuming they are ordered by ts and in descending order
|
||||||
|
if let i = dataSource.lastIndex(where: { $0.ts >= earliest }), (i+1) < dataSource.count {
|
||||||
|
let indices = ((i+1)..<dataSource.endIndex).map{ $0 }
|
||||||
|
dataSource.removeLast(dataSource.count - (i+1))
|
||||||
|
DispatchQueue.main.sync { tableView.safeDeleteRows(indices) }
|
||||||
|
}
|
||||||
|
if let i = dataSource.firstIndex(where: { $0.ts <= latest }), i > 0 {
|
||||||
|
let indices = (dataSource.startIndex..<i).map{ $0 }
|
||||||
|
dataSource.removeFirst(i)
|
||||||
|
DispatchQueue.main.sync { tableView.safeDeleteRows(indices) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncUpdate(_ sender: SyncUpdate, partialRemove affectedDomain: String) {
|
||||||
|
if fullDomain.isSubdomain(of: affectedDomain) {
|
||||||
|
syncUpdate(sender, reset: sender.rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class TVCHosts: UITableViewController, FilterPipelineDelegate {
|
class TVCHosts: UITableViewController, GroupedDomainDataSourceDelegate {
|
||||||
|
|
||||||
lazy var source = GroupedDomainDataSource(withDelegate: self, parent: parentDomain)
|
lazy var source = GroupedDomainDataSource(withParent: parentDomain)
|
||||||
|
|
||||||
public var parentDomain: String!
|
public var parentDomain: String!
|
||||||
private var isSpecial: Bool = false
|
private var isSpecial: Bool = false
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
|
||||||
navigationItem.prompt = parentDomain
|
navigationItem.prompt = parentDomain
|
||||||
|
super.viewDidLoad()
|
||||||
isSpecial = (parentDomain.first == "#") // aka: "# IP address"
|
isSpecial = (parentDomain.first == "#") // aka: "# IP address"
|
||||||
source.reloadFromSource() // init lazy var
|
source.delegate = self // init lazy var, ready for tableView data source
|
||||||
}
|
}
|
||||||
|
|
||||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||||
@@ -45,7 +45,7 @@ class TVCHosts: UITableViewController, FilterPipelineDelegate {
|
|||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
func rowNeedsUpdate(_ row: Int) {
|
func groupedDomainDataSource(needsUpdate row: Int) {
|
||||||
let entry = source[row]
|
let entry = source[row]
|
||||||
let cell = tableView.cellForRow(at: IndexPath(row: row))
|
let cell = tableView.cellForRow(at: IndexPath(row: row))
|
||||||
cell?.detailTextLabel?.text = entry.detailCellText
|
cell?.detailTextLabel?.text = entry.detailCellText
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ class VCDateFilter: UIViewController, UIGestureRecognizerDelegate {
|
|||||||
@IBOutlet private var rangeView: UIView!
|
@IBOutlet private var rangeView: UIView!
|
||||||
@IBOutlet private var buttonRangeStart: UIButton!
|
@IBOutlet private var buttonRangeStart: UIButton!
|
||||||
@IBOutlet private var buttonRangeEnd: UIButton!
|
@IBOutlet private var buttonRangeEnd: UIButton!
|
||||||
private lazy var tsRangeA: Timestamp = AppDB?.dnsLogsMinDate() ?? 0
|
private lazy var tsRangeA: Timestamp = Pref.DateFilter.RangeA ?? AppDB?.dnsLogsMinDate() ?? .now()
|
||||||
private lazy var tsRangeB: Timestamp = .now()
|
private lazy var tsRangeB: Timestamp = Pref.DateFilter.RangeB ?? .now()
|
||||||
|
|
||||||
// order by
|
// order by
|
||||||
@IBOutlet private var orderbyType: UISegmentedControl!
|
@IBOutlet private var orderbyType: UISegmentedControl!
|
||||||
@@ -31,15 +31,11 @@ class VCDateFilter: UIViewController, UIGestureRecognizerDelegate {
|
|||||||
|
|
||||||
filterBy.selectedSegmentIndex = (Pref.DateFilter.Kind == .ABRange ? 1 : 0)
|
filterBy.selectedSegmentIndex = (Pref.DateFilter.Kind == .ABRange ? 1 : 0)
|
||||||
didChangeFilterBy(filterBy)
|
didChangeFilterBy(filterBy)
|
||||||
filterBy.setEnabled(false, forSegmentAt: 1) // TODO: until range filter is ready
|
|
||||||
|
|
||||||
durationSlider.tag = -1 // otherwise wont update because `tag == 0`
|
durationSlider.tag = -1 // otherwise wont update because `tag == 0`
|
||||||
durationSlider.value = Float(durationTimes.firstIndex(of: Pref.DateFilter.LastXMin) ?? 0) / 9
|
durationSlider.value = Float(durationTimes.firstIndex(of: Pref.DateFilter.LastXMin) ?? 0) / 9
|
||||||
durationSliderChanged(durationSlider)
|
durationSliderChanged(durationSlider)
|
||||||
|
|
||||||
// Force set seconds to 00 and 59 respectively. Its retained during change.
|
|
||||||
tsRangeA = tsRangeA - tsRangeA % 60 + 00
|
|
||||||
tsRangeB = tsRangeB - tsRangeB % 60 + 59
|
|
||||||
buttonRangeStart.setTitle(DateFormat.minutes(tsRangeA), for: .normal)
|
buttonRangeStart.setTitle(DateFormat.minutes(tsRangeA), for: .normal)
|
||||||
buttonRangeEnd.setTitle(DateFormat.minutes(tsRangeB), for: .normal)
|
buttonRangeEnd.setTitle(DateFormat.minutes(tsRangeB), for: .normal)
|
||||||
|
|
||||||
@@ -70,45 +66,57 @@ class VCDateFilter: UIViewController, UIGestureRecognizerDelegate {
|
|||||||
DatePickerAlert(presentIn: self, configure: {
|
DatePickerAlert(presentIn: self, configure: {
|
||||||
$0.setDate(Date(flag ? self.tsRangeA : self.tsRangeB), animated: false)
|
$0.setDate(Date(flag ? self.tsRangeA : self.tsRangeB), animated: false)
|
||||||
}, onSuccess: {
|
}, onSuccess: {
|
||||||
flag ? (self.tsRangeA = $0.timestamp) : (self.tsRangeB = $0.timestamp)
|
var ts = $0.timestamp
|
||||||
sender.setTitle(DateFormat.minutes($0), for: .normal)
|
ts -= ts % 60 // remove seconds
|
||||||
|
// if one of these is greater than the other, adjust the latter too.
|
||||||
|
if flag || self.tsRangeA > 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 {
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||||
if gestureRecognizer.view == touch.view {
|
if gestureRecognizer.view === touch.view {
|
||||||
let newXMin = durationSlider.tag
|
saveSettings()
|
||||||
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()
|
|
||||||
dismiss(animated: true)
|
dismiss(animated: true)
|
||||||
}
|
}
|
||||||
return false
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,23 +2,16 @@ import UIKit
|
|||||||
|
|
||||||
class TVCFilter: UITableViewController, EditActionsRemove {
|
class TVCFilter: UITableViewController, EditActionsRemove {
|
||||||
var currentFilter: FilterOptions = .none // set by segue
|
var currentFilter: FilterOptions = .none // set by segue
|
||||||
private var dataSource: [String] = []
|
private lazy var dataSource = DomainFilter.list(where: currentFilter)
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
|
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
|
||||||
reloadDataSource()
|
|
||||||
}
|
|
||||||
|
|
||||||
func reloadDataSource() {
|
|
||||||
dataSource = DomainFilter.list(where: currentFilter)
|
|
||||||
tableView.reloadData()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func didChangeDomainFilter(_ notification: Notification) {
|
@objc func didChangeDomainFilter(_ notification: Notification) {
|
||||||
guard let domain = notification.object as? String else {
|
guard let domain = notification.object as? String else {
|
||||||
reloadDataSource()
|
preconditionFailure("Domain independent filter reset not implemented")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if DomainFilter[domain]?.contains(currentFilter) ?? false {
|
if DomainFilter[domain]?.contains(currentFilter) ?? false {
|
||||||
let i = dataSource.binTreeIndex(of: domain, compare: (<))!
|
let i = dataSource.binTreeIndex(of: domain, compare: (<))!
|
||||||
|
|||||||
@@ -42,10 +42,7 @@ class TVCSettings: UITableViewController {
|
|||||||
"You are about to delete all results that have been logged in the past. " +
|
"You are about to delete all results that have been logged in the past. " +
|
||||||
"Your preferences for blocked and ignored domains are preserved.\n" +
|
"Your preferences for blocked and ignored domains are preserved.\n" +
|
||||||
"Continue?", buttonText: "Delete", buttonStyle: .destructive) { _ in
|
"Continue?", buttonText: "Delete", buttonStyle: .destructive) { _ in
|
||||||
DispatchQueue.global().async {
|
TheGreatDestroyer.deleteAllLogs()
|
||||||
try? AppDB?.dnsLogsDeleteAll()
|
|
||||||
NotifyLogHistoryReset.postAsyncMain()
|
|
||||||
}
|
|
||||||
}.presentIn(self)
|
}.presentIn(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user