- Revamp whole DB to Display flow - Filter Pipeline, arbitrary filtering and sorting - Binary tree arrays for faster lookup & manipulation - DB: introducing custom functions - DB scheme: split req into heap & cache - cache written by GlassVPN only - heap written by Main App only - Introducing DB separation: DBCore, DBCommon, DBAppOnly - Introducing DB data sources: TestDataSource, GroupedDomainDataSource, RecordingsDB, DomainFilter - Background sync: Move entries from cache to heap and notify all observers - GlassVPN: Binary tree filter lookup - GlassVPN: Reusing prepared statement
251 lines
8.1 KiB
Swift
251 lines
8.1 KiB
Swift
import UIKit
|
|
|
|
// ##########################
|
|
// #
|
|
// # MARK: DataSource
|
|
// #
|
|
// ##########################
|
|
|
|
class GroupedDomainDataSource {
|
|
|
|
private var tsLatest: Timestamp = 0
|
|
|
|
private let parent: String?
|
|
let pipeline: FilterPipeline<GroupedDomain>
|
|
|
|
init(withDelegate tvc: FilterPipelineDelegate, parent p: String?) {
|
|
parent = p
|
|
pipeline = .init(withDelegate: tvc)
|
|
pipeline.setDataSource { [unowned self] in self.dataSourceCallback() }
|
|
pipeline.setSorting {
|
|
$0.lastModified > $1.lastModified
|
|
}
|
|
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)
|
|
NotifySyncInsert.observe(call: #selector(syncInsert), on: self)
|
|
NotifySyncRemove.observe(call: #selector(syncRemove), on: self)
|
|
}
|
|
|
|
/// Callback fired only when pipeline resets data source
|
|
private func dataSourceCallback() -> [GroupedDomain] {
|
|
guard let db = AppDB else { return [] }
|
|
let earliest = sync.tsEarliest
|
|
tsLatest = earliest
|
|
var log = db.dnsLogsGrouped(since: earliest, parentDomain: parent) ?? []
|
|
for (i, val) in log.enumerated() {
|
|
log[i].options = DomainFilter[val.domain]
|
|
tsLatest = max(tsLatest, val.lastModified)
|
|
}
|
|
return log
|
|
}
|
|
|
|
/// 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.start()
|
|
} else {
|
|
pipeline.reload(fromSource: true, whenDone: {
|
|
sync.start()
|
|
refreshControl?.endRefreshing()
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Callback fired when user editslist of `blocked` or `ignored` domains in settings. (`NotifyDNSFilterChanged` notification)
|
|
@objc private func didChangeDomainFilter(_ notification: Notification) {
|
|
guard let domain = notification.object as? String else {
|
|
reloadFromSource()
|
|
return
|
|
}
|
|
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == domain }) {
|
|
var y = obj
|
|
y.options = DomainFilter[domain]
|
|
pipeline.update(y, at: i)
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: Table View Data Source
|
|
|
|
@inline(__always) var numberOfRows: Int { get { pipeline.displayObjectCount() } }
|
|
|
|
@inline(__always) subscript(_ row: Int) -> GroupedDomain { pipeline.displayObject(at: row) }
|
|
|
|
|
|
// MARK: partial updates
|
|
|
|
/// Callback fired when background sync added new entries to the list. (`NotifySyncInsert` notification)
|
|
@objc private func syncInsert(_ notification: Notification) {
|
|
let range = notification.object as! SQLiteRowRange
|
|
guard let latest = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent) else {
|
|
assertionFailure("NotifySyncInsert fired with empty range")
|
|
return
|
|
}
|
|
for x in latest {
|
|
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) {
|
|
pipeline.update(obj + x, at: i)
|
|
} else {
|
|
var y = x
|
|
y.options = DomainFilter[x.domain]
|
|
pipeline.addNew(y)
|
|
}
|
|
tsLatest = max(tsLatest, x.lastModified)
|
|
}
|
|
}
|
|
|
|
/// Callback fired when background sync removed old entries from the list. (`NotifySyncRemove` notification)
|
|
@objc private func syncRemove(_ notification: Notification) {
|
|
let range = notification.object as! SQLiteRowRange
|
|
guard let outdated = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent),
|
|
outdated.count > 0 else {
|
|
assertionFailure("NotifySyncRemove fired with empty range")
|
|
return
|
|
}
|
|
var listOfDeletes: [Int] = []
|
|
for x in outdated {
|
|
guard let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) else {
|
|
assertionFailure("Try to remove non-existent element")
|
|
continue // should never happen
|
|
}
|
|
if obj.total > x.total {
|
|
pipeline.update(obj - x, at: i)
|
|
} else {
|
|
listOfDeletes.append(i)
|
|
}
|
|
}
|
|
pipeline.remove(indices: listOfDeletes.sorted())
|
|
}
|
|
}
|
|
|
|
|
|
// ################################
|
|
// #
|
|
// # 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 deleteReloadFromSource(:)
|
|
}
|
|
}
|
|
|
|
/// Reload a single data source entry. Callback fired by `reloadFromSource()`
|
|
private func partiallyReloadFromSource(_ affectedFQDN: String) {
|
|
let affectedParent = affectedFQDN.extractDomain()
|
|
guard parent == nil || parent == affectedParent else {
|
|
return // does not affect current table
|
|
}
|
|
let affected = (parent == nil ? affectedParent : affectedFQDN)
|
|
guard let old = pipeline.dataSourceGet(where: { $0.domain == affected }) else {
|
|
// can only happen if delete sheet is open while background sync removed the element
|
|
return
|
|
}
|
|
var removeOld = true
|
|
if let new = AppDB?.dnsLogsGrouped(since: sync.tsEarliest, upto: tsLatest, matchingDomain: affected, parentDomain: parent) {
|
|
assert(new.count < 2)
|
|
for var x in new {
|
|
x.options = DomainFilter[x.domain]
|
|
if old.object.domain == x.domain {
|
|
pipeline.update(x, at: old.index)
|
|
removeOld = false
|
|
} else {
|
|
pipeline.addNew(x)
|
|
}
|
|
}
|
|
}
|
|
if removeOld { pipeline.remove(indices: [old.index]) }
|
|
}
|
|
}
|
|
|
|
|
|
// ##########################
|
|
// #
|
|
// # MARK: - Edit Row
|
|
// #
|
|
// ##########################
|
|
|
|
protocol GroupedDomainEditRow : EditableRows, FilterPipelineDelegate {
|
|
var source: GroupedDomainDataSource { get set }
|
|
}
|
|
|
|
extension GroupedDomainEditRow {
|
|
|
|
func editableRowActions(_ index: IndexPath) -> [(RowAction, String)] {
|
|
let x = source[index.row]
|
|
if x.domain.starts(with: "#") {
|
|
return [(.delete, "Delete")]
|
|
}
|
|
let b = x.options?.contains(.blocked) ?? false
|
|
let i = x.options?.contains(.ignored) ?? false
|
|
return [(.delete, "Delete"), (.block, b ? "Unblock" : "Block"), (.ignore, i ? "Unignore" : "Ignore")]
|
|
}
|
|
|
|
func editableRowActionColor(_: IndexPath, _ action: RowAction) -> UIColor? {
|
|
action == .block ? .systemOrange : nil
|
|
}
|
|
|
|
func editableRowUserInfo(_ index: IndexPath) -> Any? { source[index.row] }
|
|
|
|
func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool {
|
|
let entry = userInfo as! GroupedDomain
|
|
switch action {
|
|
case .ignore: showFilterSheet(entry, .ignored)
|
|
case .block: showFilterSheet(entry, .blocked)
|
|
case .delete:
|
|
AlertDeleteLogs(entry.domain, latest: entry.lastModified) {
|
|
self.source.deleteHistory(domain: entry.domain, since: $0)
|
|
}.presentIn(self)
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func showFilterSheet(_ entry: GroupedDomain, _ filter: FilterOptions) {
|
|
if entry.options?.contains(filter) ?? false {
|
|
DomainFilter.update(entry.domain, remove: filter)
|
|
} else {
|
|
// TODO: alert sheet
|
|
DomainFilter.update(entry.domain, add: filter)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Extensions
|
|
extension TVCDomains : GroupedDomainEditRow {
|
|
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
|
getRowActionsIOS9(indexPath)
|
|
}
|
|
@available(iOS 11.0, *)
|
|
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
|
getRowActionsIOS11(indexPath)
|
|
}
|
|
}
|
|
|
|
extension TVCHosts : GroupedDomainEditRow {
|
|
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
|
getRowActionsIOS9(indexPath)
|
|
}
|
|
@available(iOS 11.0, *)
|
|
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
|
getRowActionsIOS11(indexPath)
|
|
}
|
|
}
|