Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bfedda3ab | ||
|
|
26f6ea1a9a | ||
|
|
778f377e42 | ||
|
|
f284365469 | ||
|
|
5dfb7d4ba4 | ||
|
|
bb9c3a3034 | ||
|
|
8cf872a4b0 | ||
|
|
e813230824 | ||
|
|
e8bfde9243 | ||
|
|
e947ad6d4d | ||
|
|
0a53898797 | ||
|
|
946acc2460 | ||
|
|
e13b3df2c4 |
@@ -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 */; };
|
||||||
@@ -135,6 +136,8 @@
|
|||||||
54E540F4247D3F2600F7C34A /* TestDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F3247D3F2600F7C34A /* TestDataSource.swift */; };
|
54E540F4247D3F2600F7C34A /* TestDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F3247D3F2600F7C34A /* TestDataSource.swift */; };
|
||||||
54E540F8247DB90F00F7C34A /* RecordingsDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F7247DB90F00F7C34A /* RecordingsDB.swift */; };
|
54E540F8247DB90F00F7C34A /* RecordingsDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F7247DB90F00F7C34A /* RecordingsDB.swift */; };
|
||||||
54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F92482414800F7C34A /* SyncUpdate.swift */; };
|
54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F92482414800F7C34A /* SyncUpdate.swift */; };
|
||||||
|
54EFA4E6248EEE240022D618 /* DatePickerAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54EFA4E5248EEE240022D618 /* DatePickerAlert.swift */; };
|
||||||
|
54EFA4E82491A16A0022D618 /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54EFA4E72491A16A0022D618 /* Font.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -173,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; };
|
||||||
@@ -295,6 +299,8 @@
|
|||||||
54E540F3247D3F2600F7C34A /* TestDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDataSource.swift; sourceTree = "<group>"; };
|
54E540F3247D3F2600F7C34A /* TestDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDataSource.swift; sourceTree = "<group>"; };
|
||||||
54E540F7247DB90F00F7C34A /* RecordingsDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingsDB.swift; sourceTree = "<group>"; };
|
54E540F7247DB90F00F7C34A /* RecordingsDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingsDB.swift; sourceTree = "<group>"; };
|
||||||
54E540F92482414800F7C34A /* SyncUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncUpdate.swift; sourceTree = "<group>"; };
|
54E540F92482414800F7C34A /* SyncUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncUpdate.swift; sourceTree = "<group>"; };
|
||||||
|
54EFA4E5248EEE240022D618 /* DatePickerAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerAlert.swift; sourceTree = "<group>"; };
|
||||||
|
54EFA4E72491A16A0022D618 /* Font.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Font.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -418,6 +424,7 @@
|
|||||||
545DDDCE243E6267003B6544 /* TutorialSheet.swift */,
|
545DDDCE243E6267003B6544 /* TutorialSheet.swift */,
|
||||||
54D8B979246C9F2000EB2414 /* FilterPipeline.swift */,
|
54D8B979246C9F2000EB2414 /* FilterPipeline.swift */,
|
||||||
54448A3124899A4000771C96 /* SearchBarManager.swift */,
|
54448A3124899A4000771C96 /* SearchBarManager.swift */,
|
||||||
|
54EFA4E5248EEE240022D618 /* DatePickerAlert.swift */,
|
||||||
);
|
);
|
||||||
path = "Common Classes";
|
path = "Common Classes";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -429,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>";
|
||||||
@@ -442,6 +450,7 @@
|
|||||||
54B345AA241BBA5B004C53CC /* AlertSheet.swift */,
|
54B345AA241BBA5B004C53CC /* AlertSheet.swift */,
|
||||||
54448A2F248647D900771C96 /* Time.swift */,
|
54448A2F248647D900771C96 /* Time.swift */,
|
||||||
54751E502423955000168273 /* URL.swift */,
|
54751E502423955000168273 /* URL.swift */,
|
||||||
|
54EFA4E72491A16A0022D618 /* Font.swift */,
|
||||||
54448A2D2486464F00771C96 /* Array.swift */,
|
54448A2D2486464F00771C96 /* Array.swift */,
|
||||||
54D8B97B2471A7E000EB2414 /* String.swift */,
|
54D8B97B2471A7E000EB2414 /* String.swift */,
|
||||||
54B34595240F0513004C53CC /* TableView.swift */,
|
54B34595240F0513004C53CC /* TableView.swift */,
|
||||||
@@ -837,14 +846,17 @@
|
|||||||
54D8B98624796E9900EB2414 /* GroupedDomainDataSource.swift in Sources */,
|
54D8B98624796E9900EB2414 /* GroupedDomainDataSource.swift in Sources */,
|
||||||
54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */,
|
54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */,
|
||||||
54D8B97C2471A7E000EB2414 /* String.swift in Sources */,
|
54D8B97C2471A7E000EB2414 /* String.swift in Sources */,
|
||||||
|
54EFA4E6248EEE240022D618 /* DatePickerAlert.swift in Sources */,
|
||||||
54C056DD23E9EEF700214A3F /* BundleIcon.swift in Sources */,
|
54C056DD23E9EEF700214A3F /* BundleIcon.swift in Sources */,
|
||||||
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 */,
|
||||||
540E67822433483D00871BBE /* VCEditRecording.swift in Sources */,
|
540E67822433483D00871BBE /* VCEditRecording.swift in Sources */,
|
||||||
|
54EFA4E82491A16A0022D618 /* Font.swift in Sources */,
|
||||||
54D8B97A246C9F2000EB2414 /* FilterPipeline.swift in Sources */,
|
54D8B97A246C9F2000EB2414 /* FilterPipeline.swift in Sources */,
|
||||||
54B34594240E6343004C53CC /* TVCFilter.swift in Sources */,
|
54B34594240E6343004C53CC /* TVCFilter.swift in Sources */,
|
||||||
54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */,
|
54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */,
|
||||||
@@ -1099,7 +1111,7 @@
|
|||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = main/main.entitlements;
|
CODE_SIGN_ENTITLEMENTS = main/main.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
INFOPLIST_FILE = main/Info.plist;
|
INFOPLIST_FILE = main/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -1118,7 +1130,7 @@
|
|||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = main/main.entitlements;
|
CODE_SIGN_ENTITLEMENTS = main/main.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
INFOPLIST_FILE = main/Info.plist;
|
INFOPLIST_FILE = main/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -1137,7 +1149,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = GlassVPN/GlassVPN.entitlements;
|
CODE_SIGN_ENTITLEMENTS = GlassVPN/GlassVPN.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
INFOPLIST_FILE = GlassVPN/Info.plist;
|
INFOPLIST_FILE = GlassVPN/Info.plist;
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "de.uni-bamberg.psi.AppCheck.VPN";
|
PRODUCT_BUNDLE_IDENTIFIER = "de.uni-bamberg.psi.AppCheck.VPN";
|
||||||
@@ -1155,7 +1167,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = GlassVPN/GlassVPN.entitlements;
|
CODE_SIGN_ENTITLEMENTS = GlassVPN/GlassVPN.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
INFOPLIST_FILE = GlassVPN/Info.plist;
|
INFOPLIST_FILE = GlassVPN/Info.plist;
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "de.uni-bamberg.psi.AppCheck.VPN";
|
PRODUCT_BUNDLE_IDENTIFIER = "de.uni-bamberg.psi.AppCheck.VPN";
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import NetworkExtension
|
import NetworkExtension
|
||||||
|
|
||||||
fileprivate var db: SQLiteDatabase!
|
|
||||||
fileprivate var pStmt: OpaquePointer!
|
|
||||||
fileprivate var filterDomains: [String]!
|
fileprivate var filterDomains: [String]!
|
||||||
fileprivate var filterOptions: [(block: Bool, ignore: Bool)]!
|
fileprivate var filterOptions: [(block: Bool, ignore: Bool)]!
|
||||||
|
|
||||||
@@ -9,7 +7,7 @@ fileprivate var filterOptions: [(block: Bool, ignore: Bool)]!
|
|||||||
// MARK: Backward DNS Binary Tree Lookup
|
// MARK: Backward DNS Binary Tree Lookup
|
||||||
|
|
||||||
fileprivate func reloadDomainFilter() {
|
fileprivate func reloadDomainFilter() {
|
||||||
let tmp = db.loadFilters()?.map({
|
let tmp = AppDB?.loadFilters()?.map({
|
||||||
(String($0.reversed()), $1)
|
(String($0.reversed()), $1)
|
||||||
}).sorted(by: { $0.0 < $1.0 }) ?? []
|
}).sorted(by: { $0.0 < $1.0 }) ?? []
|
||||||
filterDomains = tmp.map { $0.0 }
|
filterDomains = tmp.map { $0.0 }
|
||||||
@@ -35,6 +33,18 @@ fileprivate func filterIndex(for domain: String) -> Int {
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private let queue = DispatchQueue.init(label: "PSIGlassDNSQueue", qos: .userInteractive, target: .main)
|
||||||
|
|
||||||
|
private func logAsync(_ domain: String, blocked: Bool) {
|
||||||
|
queue.async {
|
||||||
|
do {
|
||||||
|
try AppDB?.logWrite(domain, blocked: blocked)
|
||||||
|
} catch {
|
||||||
|
DDLogWarn("Couldn't write: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: ObserverFactory
|
// MARK: ObserverFactory
|
||||||
|
|
||||||
@@ -52,11 +62,11 @@ class LDObserverFactory: ObserverFactory {
|
|||||||
let i = filterIndex(for: session.host)
|
let i = filterIndex(for: session.host)
|
||||||
if i >= 0 {
|
if i >= 0 {
|
||||||
let (block, ignore) = filterOptions[i]
|
let (block, ignore) = filterOptions[i]
|
||||||
if !ignore { try? db.logWrite(pStmt, session.host, blocked: block) }
|
if !ignore { logAsync(session.host, blocked: block) }
|
||||||
if block { socket.forceDisconnect() }
|
if block { socket.forceDisconnect() }
|
||||||
} else {
|
} else {
|
||||||
// TODO: disable filter during recordings
|
// TODO: disable filter during recordings
|
||||||
try? db.logWrite(pStmt, session.host)
|
logAsync(session.host, blocked: false)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
@@ -76,9 +86,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
|
|
||||||
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
|
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
|
||||||
do {
|
do {
|
||||||
db = try SQLiteDatabase.open()
|
try SQLiteDatabase.open().initCommonScheme()
|
||||||
db.initCommonScheme()
|
|
||||||
pStmt = try db.logWritePrepare()
|
|
||||||
} catch {
|
} catch {
|
||||||
completionHandler(error)
|
completionHandler(error)
|
||||||
return
|
return
|
||||||
@@ -135,9 +143,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
ObserverFactory.currentFactory = nil
|
ObserverFactory.currentFactory = nil
|
||||||
proxyServer.stop()
|
proxyServer.stop()
|
||||||
proxyServer = nil
|
proxyServer = nil
|
||||||
db.prepared(finalize: pStmt)
|
|
||||||
pStmt = nil
|
|
||||||
db = nil
|
|
||||||
filterDomains = nil
|
filterDomains = nil
|
||||||
filterOptions = nil
|
filterOptions = nil
|
||||||
completionHandler()
|
completionHandler()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="sfA-EG-18J">
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16097" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="sfA-EG-18J">
|
||||||
<device id="retina4_0" orientation="portrait" appearance="light"/>
|
<device id="retina4_0" orientation="portrait" appearance="light"/>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<deployment identifier="iOS"/>
|
<deployment identifier="iOS"/>
|
||||||
@@ -49,19 +49,19 @@
|
|||||||
<objects>
|
<objects>
|
||||||
<viewController storyboardIdentifier="domainFilter" modalTransitionStyle="crossDissolve" id="r7v-PM-PrR" customClass="VCDateFilter" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
|
<viewController storyboardIdentifier="domainFilter" modalTransitionStyle="crossDissolve" id="r7v-PM-PrR" customClass="VCDateFilter" customModule="AppCheck" customModuleProvider="target" sceneMemberID="viewController">
|
||||||
<view key="view" contentMode="scaleToFill" id="QBv-5g-BTH">
|
<view key="view" contentMode="scaleToFill" id="QBv-5g-BTH">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="320" height="320"/>
|
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<navigationBar hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="jAM-LN-evh">
|
<navigationBar hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="jAM-LN-evh">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
|
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
|
||||||
<items>
|
<items>
|
||||||
<navigationItem title="Placeholder" id="s5o-aw-nIo">
|
<navigationItem title="Placeholder" id="s5o-aw-nIo">
|
||||||
<barButtonItem key="rightBarButtonItem" title="Item" image="filter-clear" id="oMW-R3-3Eh"/>
|
<barButtonItem key="leftBarButtonItem" title="Item" image="filter-clear" id="oMW-R3-3Eh"/>
|
||||||
</navigationItem>
|
</navigationItem>
|
||||||
</items>
|
</items>
|
||||||
</navigationBar>
|
</navigationBar>
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="pEc-vv-7Ts">
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="pEc-vv-7Ts">
|
||||||
<rect key="frame" x="78.5" y="64" width="233.5" height="217.5"/>
|
<rect key="frame" x="8" y="64" width="233.5" height="391.5"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="UNT-qn-2cg">
|
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="UNT-qn-2cg">
|
||||||
<rect key="frame" x="8" y="8" width="217.5" height="32"/>
|
<rect key="frame" x="8" y="8" width="217.5" height="32"/>
|
||||||
@@ -70,20 +70,20 @@
|
|||||||
<segment title="Date Range"/>
|
<segment title="Date Range"/>
|
||||||
</segments>
|
</segments>
|
||||||
<connections>
|
<connections>
|
||||||
<action selector="didChangeSegment:" destination="r7v-PM-PrR" eventType="valueChanged" id="cxI-lR-J7y"/>
|
<action selector="didChangeFilterBy:" destination="r7v-PM-PrR" eventType="valueChanged" id="kM6-QE-ZGV"/>
|
||||||
</connections>
|
</connections>
|
||||||
</segmentedControl>
|
</segmentedControl>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Show entries no older than" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="UBq-oH-pKp">
|
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="gEf-Ra-RyA">
|
||||||
<rect key="frame" x="10" y="55" width="213.5" height="20.5"/>
|
<rect key="frame" x="10" y="47" width="213.5" height="334.5"/>
|
||||||
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
|
|
||||||
<nil key="textColor"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="gEf-Ra-RyA">
|
|
||||||
<rect key="frame" x="10" y="83.5" width="213.5" height="124"/>
|
|
||||||
<subviews>
|
<subviews>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Show entries no older than" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="UBq-oH-pKp">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="213.5" height="20.5"/>
|
||||||
|
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
|
||||||
|
<nil key="textColor"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ucF-MH-iRP">
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ucF-MH-iRP">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="213.5" height="50"/>
|
<rect key="frame" x="0.0" y="35.5" width="213.5" height="50"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<slider opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" value="0.5" minValue="0.0" maxValue="1" translatesAutoresizingMaskIntoConstraints="NO" id="qhe-6d-hGB">
|
<slider opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" value="0.5" minValue="0.0" maxValue="1" translatesAutoresizingMaskIntoConstraints="NO" id="qhe-6d-hGB">
|
||||||
<rect key="frame" x="-2" y="0.0" width="155.5" height="51"/>
|
<rect key="frame" x="-2" y="0.0" width="155.5" height="51"/>
|
||||||
@@ -111,8 +111,14 @@
|
|||||||
<constraint firstItem="qhe-6d-hGB" firstAttribute="top" secondItem="ucF-MH-iRP" secondAttribute="top" id="eJC-d4-zg0"/>
|
<constraint firstItem="qhe-6d-hGB" firstAttribute="top" secondItem="ucF-MH-iRP" secondAttribute="top" id="eJC-d4-zg0"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Show entries within range" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rtf-o1-gk6">
|
||||||
|
<rect key="frame" x="0.0" y="100.5" width="213.5" height="20.5"/>
|
||||||
|
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
|
||||||
|
<nil key="textColor"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="9As-hA-MKt">
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="9As-hA-MKt">
|
||||||
<rect key="frame" x="0.0" y="50" width="213.5" height="74"/>
|
<rect key="frame" x="0.0" y="136" width="213.5" height="74"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="From:" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="wAd-o2-PHY">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="From:" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="wAd-o2-PHY">
|
||||||
<rect key="frame" x="0.0" y="6.5" width="44" height="20.5"/>
|
<rect key="frame" x="0.0" y="6.5" width="44" height="20.5"/>
|
||||||
@@ -159,21 +165,54 @@
|
|||||||
<constraint firstItem="wAd-o2-PHY" firstAttribute="leading" secondItem="9As-hA-MKt" secondAttribute="leading" id="zgR-pJ-vFs"/>
|
<constraint firstItem="wAd-o2-PHY" firstAttribute="leading" secondItem="9As-hA-MKt" secondAttribute="leading" id="zgR-pJ-vFs"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Order by" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="9Fe-5F-TVt">
|
||||||
|
<rect key="frame" x="0.0" y="225" width="213.5" height="20.5"/>
|
||||||
|
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
|
||||||
|
<nil key="textColor"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="cWy-un-IHC">
|
||||||
|
<rect key="frame" x="0.0" y="260.5" width="213.5" height="74"/>
|
||||||
|
<subviews>
|
||||||
|
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="UKE-MR-kRJ">
|
||||||
|
<rect key="frame" x="-2" y="0.0" width="217.5" height="36"/>
|
||||||
|
<segments>
|
||||||
|
<segment title="Date"/>
|
||||||
|
<segment title="Name"/>
|
||||||
|
<segment title="Count"/>
|
||||||
|
</segments>
|
||||||
|
</segmentedControl>
|
||||||
|
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="eG2-a4-zm5">
|
||||||
|
<rect key="frame" x="-2" y="43" width="217.5" height="32"/>
|
||||||
|
<segments>
|
||||||
|
<segment title="Ascending"/>
|
||||||
|
<segment title="Descending"/>
|
||||||
|
</segments>
|
||||||
|
</segmentedControl>
|
||||||
|
</subviews>
|
||||||
|
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="eG2-a4-zm5" firstAttribute="top" secondItem="UKE-MR-kRJ" secondAttribute="bottom" constant="8" symbolic="YES" id="6oC-bZ-XdM"/>
|
||||||
|
<constraint firstItem="eG2-a4-zm5" firstAttribute="leading" secondItem="cWy-un-IHC" secondAttribute="leading" constant="-2" id="7R0-qB-J0u"/>
|
||||||
|
<constraint firstAttribute="bottom" secondItem="eG2-a4-zm5" secondAttribute="bottom" id="JbN-vA-Rd5"/>
|
||||||
|
<constraint firstItem="UKE-MR-kRJ" firstAttribute="top" secondItem="cWy-un-IHC" secondAttribute="top" id="L21-Kf-g2d"/>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="eG2-a4-zm5" secondAttribute="trailing" constant="-2" id="cbD-H9-e1Q"/>
|
||||||
|
<constraint firstItem="UKE-MR-kRJ" firstAttribute="leading" secondItem="cWy-un-IHC" secondAttribute="leading" constant="-2" id="lKB-g4-asw"/>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="UKE-MR-kRJ" secondAttribute="trailing" constant="-2" id="xIa-X2-0Lp"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="UNT-qn-2cg" firstAttribute="top" secondItem="pEc-vv-7Ts" secondAttribute="top" constant="8" id="Awu-uv-9wF"/>
|
<constraint firstItem="UNT-qn-2cg" firstAttribute="top" secondItem="pEc-vv-7Ts" secondAttribute="top" constant="8" id="Awu-uv-9wF"/>
|
||||||
<constraint firstItem="UBq-oH-pKp" firstAttribute="top" secondItem="UNT-qn-2cg" secondAttribute="bottom" constant="16" id="FDO-1V-ffl"/>
|
|
||||||
<constraint firstItem="UNT-qn-2cg" firstAttribute="leading" secondItem="pEc-vv-7Ts" secondAttribute="leading" constant="8" id="Icx-YR-5bc"/>
|
<constraint firstItem="UNT-qn-2cg" firstAttribute="leading" secondItem="pEc-vv-7Ts" secondAttribute="leading" constant="8" id="Icx-YR-5bc"/>
|
||||||
<constraint firstItem="UBq-oH-pKp" firstAttribute="leading" secondItem="pEc-vv-7Ts" secondAttribute="leading" constant="10" id="KRb-xo-A9i"/>
|
<constraint firstItem="gEf-Ra-RyA" firstAttribute="top" secondItem="UNT-qn-2cg" secondAttribute="bottom" constant="8" symbolic="YES" id="QPi-aa-6ff"/>
|
||||||
<constraint firstItem="gEf-Ra-RyA" firstAttribute="top" secondItem="UBq-oH-pKp" secondAttribute="bottom" constant="8" symbolic="YES" id="QPi-aa-6ff"/>
|
|
||||||
<constraint firstItem="UNT-qn-2cg" firstAttribute="trailing" secondItem="pEc-vv-7Ts" secondAttribute="trailing" constant="-8" id="Sof-6L-T2D"/>
|
<constraint firstItem="UNT-qn-2cg" firstAttribute="trailing" secondItem="pEc-vv-7Ts" secondAttribute="trailing" constant="-8" id="Sof-6L-T2D"/>
|
||||||
<constraint firstItem="gEf-Ra-RyA" firstAttribute="bottom" secondItem="pEc-vv-7Ts" secondAttribute="bottom" constant="-10" id="TMx-5J-z2P"/>
|
<constraint firstItem="gEf-Ra-RyA" firstAttribute="bottom" secondItem="pEc-vv-7Ts" secondAttribute="bottom" constant="-10" id="TMx-5J-z2P"/>
|
||||||
<constraint firstItem="gEf-Ra-RyA" firstAttribute="leading" secondItem="UBq-oH-pKp" secondAttribute="leading" id="U6l-7M-bm4"/>
|
<constraint firstItem="gEf-Ra-RyA" firstAttribute="leading" secondItem="pEc-vv-7Ts" secondAttribute="leading" constant="10" id="U6l-7M-bm4"/>
|
||||||
<constraint firstItem="gEf-Ra-RyA" firstAttribute="trailing" secondItem="UBq-oH-pKp" secondAttribute="trailing" id="YKE-TR-fTB"/>
|
<constraint firstItem="gEf-Ra-RyA" firstAttribute="trailing" secondItem="pEc-vv-7Ts" secondAttribute="trailing" constant="-10" id="YKE-TR-fTB"/>
|
||||||
<constraint firstItem="UBq-oH-pKp" firstAttribute="trailing" secondItem="pEc-vv-7Ts" secondAttribute="trailing" constant="-10" id="yZd-eO-85k"/>
|
|
||||||
</constraints>
|
</constraints>
|
||||||
<userDefinedRuntimeAttributes>
|
<userDefinedRuntimeAttributes>
|
||||||
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
|
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
|
||||||
@@ -182,7 +221,7 @@
|
|||||||
</userDefinedRuntimeAttributes>
|
</userDefinedRuntimeAttributes>
|
||||||
</view>
|
</view>
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="sAi-8j-0n1" customClass="PopupTriangle" customModule="AppCheck" customModuleProvider="target">
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="sAi-8j-0n1" customClass="PopupTriangle" customModule="AppCheck" customModuleProvider="target">
|
||||||
<rect key="frame" x="278" y="46" width="28" height="22"/>
|
<rect key="frame" x="14" y="46" width="28" height="22"/>
|
||||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="22" id="MaD-aD-U8h"/>
|
<constraint firstAttribute="height" constant="22" id="MaD-aD-U8h"/>
|
||||||
@@ -200,11 +239,11 @@
|
|||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="sAi-8j-0n1" firstAttribute="bottom" secondItem="pEc-vv-7Ts" secondAttribute="top" constant="4" id="DCq-Ps-sQo"/>
|
<constraint firstItem="sAi-8j-0n1" firstAttribute="bottom" secondItem="pEc-vv-7Ts" secondAttribute="top" constant="4" id="DCq-Ps-sQo"/>
|
||||||
<constraint firstItem="pEc-vv-7Ts" firstAttribute="top" secondItem="jAM-LN-evh" secondAttribute="bottom" constant="20" id="EdA-nv-DEa"/>
|
<constraint firstItem="pEc-vv-7Ts" firstAttribute="top" secondItem="jAM-LN-evh" secondAttribute="bottom" constant="20" id="EdA-nv-DEa"/>
|
||||||
<constraint firstItem="u0F-hK-vVD" firstAttribute="trailing" secondItem="pEc-vv-7Ts" secondAttribute="trailing" constant="8" id="Iow-GV-Lxy"/>
|
<constraint firstItem="pEc-vv-7Ts" firstAttribute="leading" secondItem="u0F-hK-vVD" secondAttribute="leading" constant="8" id="Iow-GV-Lxy"/>
|
||||||
<constraint firstItem="jAM-LN-evh" firstAttribute="trailing" secondItem="u0F-hK-vVD" secondAttribute="trailing" id="Lju-K6-G89"/>
|
<constraint firstItem="jAM-LN-evh" firstAttribute="trailing" secondItem="u0F-hK-vVD" secondAttribute="trailing" id="Lju-K6-G89"/>
|
||||||
<constraint firstItem="jAM-LN-evh" firstAttribute="top" secondItem="u0F-hK-vVD" secondAttribute="top" id="MqW-YU-POp"/>
|
<constraint firstItem="jAM-LN-evh" firstAttribute="top" secondItem="u0F-hK-vVD" secondAttribute="top" id="MqW-YU-POp"/>
|
||||||
<constraint firstItem="pEc-vv-7Ts" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="u0F-hK-vVD" secondAttribute="leading" constant="8" id="V9T-2Y-oNy"/>
|
<constraint firstItem="u0F-hK-vVD" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="pEc-vv-7Ts" secondAttribute="trailing" constant="8" id="V9T-2Y-oNy"/>
|
||||||
<constraint firstItem="sAi-8j-0n1" firstAttribute="trailing" secondItem="pEc-vv-7Ts" secondAttribute="trailing" constant="-6" id="cXH-3c-s6t"/>
|
<constraint firstItem="sAi-8j-0n1" firstAttribute="leading" secondItem="pEc-vv-7Ts" secondAttribute="leading" constant="6" id="cXH-3c-s6t"/>
|
||||||
<constraint firstItem="jAM-LN-evh" firstAttribute="leading" secondItem="u0F-hK-vVD" secondAttribute="leading" id="ula-eW-vAq"/>
|
<constraint firstItem="jAM-LN-evh" firstAttribute="leading" secondItem="u0F-hK-vVD" secondAttribute="leading" id="ula-eW-vAq"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
<viewLayoutGuide key="safeArea" id="u0F-hK-vVD"/>
|
<viewLayoutGuide key="safeArea" id="u0F-hK-vVD"/>
|
||||||
@@ -213,16 +252,18 @@
|
|||||||
</connections>
|
</connections>
|
||||||
</view>
|
</view>
|
||||||
<extendedEdge key="edgesForExtendedLayout" bottom="YES"/>
|
<extendedEdge key="edgesForExtendedLayout" bottom="YES"/>
|
||||||
<size key="freeformSize" width="320" height="320"/>
|
|
||||||
<connections>
|
<connections>
|
||||||
<outlet property="buttonRangeEnd" destination="IG3-Wc-UI4" id="wAd-ca-bVQ"/>
|
<outlet property="buttonRangeEnd" destination="IG3-Wc-UI4" id="wAd-ca-bVQ"/>
|
||||||
<outlet property="buttonRangeStart" destination="FVD-kB-91w" id="HbX-Vl-uBE"/>
|
<outlet property="buttonRangeStart" destination="FVD-kB-91w" id="HbX-Vl-uBE"/>
|
||||||
<outlet property="durationLabel" destination="ika-su-PZQ" id="1Br-vu-xir"/>
|
<outlet property="durationLabel" destination="ika-su-PZQ" id="1Br-vu-xir"/>
|
||||||
<outlet property="durationSlider" destination="qhe-6d-hGB" id="wph-zX-WIz"/>
|
<outlet property="durationSlider" destination="qhe-6d-hGB" id="wph-zX-WIz"/>
|
||||||
|
<outlet property="durationTitle" destination="UBq-oH-pKp" id="BEd-Lo-a2v"/>
|
||||||
<outlet property="durationView" destination="ucF-MH-iRP" id="TCI-Pp-drf"/>
|
<outlet property="durationView" destination="ucF-MH-iRP" id="TCI-Pp-drf"/>
|
||||||
|
<outlet property="filterBy" destination="UNT-qn-2cg" id="M1J-n8-LHq"/>
|
||||||
|
<outlet property="orderbyAsc" destination="eG2-a4-zm5" id="II1-hc-pyZ"/>
|
||||||
|
<outlet property="orderbyType" destination="UKE-MR-kRJ" id="fK7-dW-MLd"/>
|
||||||
|
<outlet property="rangeTitle" destination="rtf-o1-gk6" id="2DY-xP-VOg"/>
|
||||||
<outlet property="rangeView" destination="9As-hA-MKt" id="0Mq-Gi-nF6"/>
|
<outlet property="rangeView" destination="9As-hA-MKt" id="0Mq-Gi-nF6"/>
|
||||||
<outlet property="sectionTitle" destination="UBq-oH-pKp" id="JG9-aM-n6e"/>
|
|
||||||
<outlet property="segmentControl" destination="UNT-qn-2cg" id="JKs-2W-IRd"/>
|
|
||||||
</connections>
|
</connections>
|
||||||
</viewController>
|
</viewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="xTS-RW-xLN" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="xTS-RW-xLN" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||||
@@ -232,7 +273,7 @@
|
|||||||
</connections>
|
</connections>
|
||||||
</tapGestureRecognizer>
|
</tapGestureRecognizer>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="700" y="-1800"/>
|
<point key="canvasLocation" x="700" y="-1950"/>
|
||||||
</scene>
|
</scene>
|
||||||
<!--Domains-->
|
<!--Domains-->
|
||||||
<scene sceneID="MN1-aZ-cZt">
|
<scene sceneID="MN1-aZ-cZt">
|
||||||
@@ -277,19 +318,19 @@
|
|||||||
</connections>
|
</connections>
|
||||||
</tableView>
|
</tableView>
|
||||||
<navigationItem key="navigationItem" title="Domains" id="nY5-jL-QT9">
|
<navigationItem key="navigationItem" title="Domains" id="nY5-jL-QT9">
|
||||||
<barButtonItem key="leftBarButtonItem" systemItem="search" id="FHY-of-M4V">
|
<leftBarButtonItems>
|
||||||
<connections>
|
|
||||||
<action selector="searchButtonTapped:" destination="pdd-aM-sKl" id="HH1-6f-mcM"/>
|
|
||||||
</connections>
|
|
||||||
</barButtonItem>
|
|
||||||
<rightBarButtonItems>
|
|
||||||
<barButtonItem image="filter-clear" id="FZm-Ld-jJE">
|
<barButtonItem image="filter-clear" id="FZm-Ld-jJE">
|
||||||
<connections>
|
<connections>
|
||||||
<action selector="filterButtonTapped:" destination="pdd-aM-sKl" id="Xyy-LF-eCF"/>
|
<action selector="filterButtonTapped:" destination="pdd-aM-sKl" id="Xyy-LF-eCF"/>
|
||||||
</connections>
|
</connections>
|
||||||
</barButtonItem>
|
</barButtonItem>
|
||||||
<barButtonItem enabled="NO" title="7 days" id="wxA-bC-1pN"/>
|
<barButtonItem enabled="NO" title="7 days" id="wxA-bC-1pN"/>
|
||||||
</rightBarButtonItems>
|
</leftBarButtonItems>
|
||||||
|
<barButtonItem key="rightBarButtonItem" systemItem="search" id="FHY-of-M4V">
|
||||||
|
<connections>
|
||||||
|
<action selector="searchButtonTapped:" destination="pdd-aM-sKl" id="HH1-6f-mcM"/>
|
||||||
|
</connections>
|
||||||
|
</barButtonItem>
|
||||||
</navigationItem>
|
</navigationItem>
|
||||||
<connections>
|
<connections>
|
||||||
<outlet property="filterButton" destination="FZm-Ld-jJE" id="g96-Q2-cYX"/>
|
<outlet property="filterButton" destination="FZm-Ld-jJE" id="g96-Q2-cYX"/>
|
||||||
|
|||||||
102
main/Common Classes/DatePickerAlert.swift
Normal file
102
main/Common Classes/DatePickerAlert.swift
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
class DatePickerAlert: UIViewController {
|
||||||
|
|
||||||
|
override var keyCommands: [UIKeyCommand]? {
|
||||||
|
[UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(didTapCancel))]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var callback: (Date) -> Void
|
||||||
|
private let picker: UIDatePicker = {
|
||||||
|
let x = UIDatePicker()
|
||||||
|
let h = x.sizeThatFits(.zero).height
|
||||||
|
x.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: h)
|
||||||
|
return x
|
||||||
|
}()
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
@discardableResult required init(presentIn viewController: UIViewController, configure: ((UIDatePicker) -> Void)? = nil, onSuccess: @escaping (Date) -> Void) {
|
||||||
|
callback = onSuccess
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
modalPresentationStyle = .custom
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
isModalInPresentation = true
|
||||||
|
}
|
||||||
|
presentIn(viewController, configure)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override func loadView() {
|
||||||
|
let cancel = QuickUI.button("Discard", target: self, action: #selector(didTapCancel))
|
||||||
|
let save = QuickUI.button("Save", target: self, action: #selector(didTapSave))
|
||||||
|
let now = QuickUI.button("Now", target: self, action: #selector(didTapNow))
|
||||||
|
save.titleLabel?.font = save.titleLabel?.font.bold()
|
||||||
|
now.titleLabel?.font = now.titleLabel?.font.bold()
|
||||||
|
now.setTitleColor(.sysFg, for: .normal)
|
||||||
|
//cancel.setTitleColor(.systemRed, for: .normal)
|
||||||
|
|
||||||
|
let buttons = UIStackView(arrangedSubviews: [cancel, now, save])
|
||||||
|
buttons.axis = .horizontal
|
||||||
|
buttons.distribution = .equalSpacing
|
||||||
|
|
||||||
|
let bg = UIView(frame: picker.frame)
|
||||||
|
bg.frame.size.height += buttons.frame.height + 15
|
||||||
|
bg.frame.origin.y = UIScreen.main.bounds.height - bg.frame.height - 15
|
||||||
|
bg.backgroundColor = .sysBg
|
||||||
|
bg.addSubview(picker)
|
||||||
|
bg.addSubview(buttons)
|
||||||
|
|
||||||
|
let clearBg = UIView()
|
||||||
|
clearBg.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
clearBg.addSubview(bg)
|
||||||
|
|
||||||
|
picker.anchor([.leading, .trailing, .top], to: bg)
|
||||||
|
picker.bottomAnchor =&= buttons.topAnchor
|
||||||
|
buttons.anchor([.leading, .trailing], to: bg, margin: 25)
|
||||||
|
buttons.bottomAnchor =&= bg.bottomAnchor - 15
|
||||||
|
bg.anchor([.leading, .trailing, .bottom], to: clearBg)
|
||||||
|
|
||||||
|
view = clearBg
|
||||||
|
view.isHidden = true // otherwise picker will flash on present
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func didTapNow() {
|
||||||
|
picker.date = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func didTapSave() {
|
||||||
|
dismiss(animated: true) {
|
||||||
|
self.callback(self.picker.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func didTapCancel() {
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentIn(_ viewController: UIViewController, _ configure: ((UIDatePicker) -> Void)? = nil) {
|
||||||
|
viewController.present(self, animated: false) {
|
||||||
|
let control = self.view.subviews.first!
|
||||||
|
let prev = control.frame.origin.y
|
||||||
|
control.frame.origin.y += control.frame.height
|
||||||
|
self.view.isHidden = false
|
||||||
|
|
||||||
|
configure?(self.picker)
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.3) {
|
||||||
|
self.view.backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
||||||
|
control.frame.origin.y = prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
|
||||||
|
UIView.animate(withDuration: 0.3, animations: {
|
||||||
|
let control = self.view.subviews.first!
|
||||||
|
self.view.backgroundColor = .clear
|
||||||
|
control.frame.origin.y += control.frame.height
|
||||||
|
}) { _ in
|
||||||
|
super.dismiss(animated: false, completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,27 @@
|
|||||||
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()`
|
||||||
func rowNeedsUpdate(_ row: Int)
|
func filterPipelineDidReset()
|
||||||
|
/// Call `safeDeleteRows()`
|
||||||
|
func filterPipeline(delete rows: [Int])
|
||||||
|
/// Call `safeInsertRow()`
|
||||||
|
func filterPipeline(insert row: Int)
|
||||||
|
/// Call `safeReloadRow()`
|
||||||
|
func filterPipeline(update row: Int)
|
||||||
|
/// Call `safeMoveRow()`
|
||||||
|
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 +42,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 +65,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,37 +82,48 @@ 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)
|
||||||
if i == pipeline.count {
|
if i == pipeline.count {
|
||||||
// only if we don't reset other layers we can assure `toLessRestrictive`
|
// only if we don't reset other layers we can assure `toLessRestrictive`
|
||||||
display?.reset(toLessRestrictive: lastLayerIndices())
|
display?.apply(lessRestrictive: lastLayerIndices())
|
||||||
} 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`.
|
||||||
|
/// However, the `predicate` must be dynamic and support a sort order flag.
|
||||||
|
/// - Note: Will call `filterPipelineDidReset()`
|
||||||
|
/// - Warning: Make sure `predicate` does reflect the change or it will lead to data inconsistency!
|
||||||
|
func reverseSorting() {
|
||||||
|
// TODO: use semaphore to prevent concurrent edits
|
||||||
|
display?.reverseOrder()
|
||||||
|
delegate?.filterPipelineDidReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-built filter and display sorting order.
|
/// Re-built filter and display sorting order.
|
||||||
@@ -164,26 +162,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
|
||||||
@@ -193,10 +173,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`
|
||||||
@@ -210,27 +191,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.
|
||||||
@@ -243,14 +220,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
|
||||||
@@ -344,6 +323,7 @@ class PipelineSorting<T> {
|
|||||||
|
|
||||||
/// Create a fresh, already sorted, display order projection.
|
/// Create a fresh, already sorted, display order projection.
|
||||||
/// - 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.
|
||||||
|
/// - Complexity: O(*n* log *n*), where *n* is the length of the `filter`.
|
||||||
required init(_ predicate: @escaping Predicate, pipe: FilterPipeline<T>) {
|
required init(_ predicate: @escaping Predicate, pipe: FilterPipeline<T>) {
|
||||||
comperator = { [unowned pipe] in
|
comperator = { [unowned pipe] in
|
||||||
predicate(pipe.dataSource[$0], pipe.dataSource[$1])
|
predicate(pipe.dataSource[$0], pipe.dataSource[$1])
|
||||||
@@ -351,6 +331,12 @@ class PipelineSorting<T> {
|
|||||||
reset(to: pipe.lastLayerIndices())
|
reset(to: pipe.lastLayerIndices())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// - Warning: Make sure `predicate` does reflect the change. Or it will lead to data inconsistency.
|
||||||
|
/// - Complexity: O(*n*), where *n* is the length of the `filter`.
|
||||||
|
fileprivate func reverseOrder() {
|
||||||
|
projection.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
/// Replace current `projection` with new filter indices and apply sorting.
|
/// Replace current `projection` with new filter indices and apply sorting.
|
||||||
/// - Complexity: O(*n* log *n*), where *n* is the length of the `filter`.
|
/// - Complexity: O(*n* log *n*), where *n* is the length of the `filter`.
|
||||||
fileprivate func reset(to filterIndices: [Int]) {
|
fileprivate func reset(to filterIndices: [Int]) {
|
||||||
@@ -367,7 +353,7 @@ class PipelineSorting<T> {
|
|||||||
/// After removing a layer of filtering the previous layers are less restrictive and thus contain more indices.
|
/// After removing a layer of filtering the previous layers are less restrictive and thus contain more indices.
|
||||||
/// Therefore, the difference between both index sets will be inserted into the projection.
|
/// Therefore, the difference between both index sets will be inserted into the projection.
|
||||||
/// - Complexity: O(*m* log *n*), where *m* is the difference to the previous layer and *n* is the length of the `projection`.
|
/// - Complexity: O(*m* log *n*), where *m* is the difference to the previous layer and *n* is the length of the `projection`.
|
||||||
fileprivate func reset(toLessRestrictive filterIndices: [Int]) {
|
fileprivate func apply(lessRestrictive filterIndices: [Int]) {
|
||||||
for x in filterIndices.difference(toSubset: projection.sorted(), compare: (<)) {
|
for x in filterIndices.difference(toSubset: projection.sorted(), compare: (<)) {
|
||||||
insertNew(x)
|
insertNew(x)
|
||||||
}
|
}
|
||||||
@@ -380,8 +366,9 @@ class PipelineSorting<T> {
|
|||||||
/// - Returns: Index in the projection
|
/// - Returns: Index in the projection
|
||||||
/// - Complexity: O(log *n*), where *n* is the length of the `projection`.
|
/// - Complexity: O(log *n*), where *n* is the length of the `projection`.
|
||||||
@discardableResult fileprivate func insertNew(_ index: Int, previousIndex prev: Int = -1) -> Int {
|
@discardableResult fileprivate func insertNew(_ index: Int, previousIndex prev: Int = -1) -> Int {
|
||||||
if prev >= 0, prev < projection.count {
|
if prev >= 0, prev <= projection.count { // '<=' because previous delete removed one element
|
||||||
if (prev == 0 || !comperator(index, projection[prev - 1])), !comperator(projection[prev], index) {
|
if (prev == 0 || !comperator(index, projection[prev - 1])),
|
||||||
|
(prev == projection.count || !comperator(projection[prev], index)) {
|
||||||
// If element can be inserted at the same position without resorting, do that
|
// If element can be inserted at the same position without resorting, do that
|
||||||
projection.insert(index, at: prev)
|
projection.insert(index, at: prev)
|
||||||
return prev
|
return prev
|
||||||
|
|||||||
@@ -39,32 +39,3 @@ struct QuickUI {
|
|||||||
return txt
|
return txt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension NSMutableAttributedString {
|
|
||||||
static private var def: UIFont = .preferredFont(forTextStyle: .body)
|
|
||||||
|
|
||||||
func normal(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: .preferredFont(forTextStyle: style)) }
|
|
||||||
func bold(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).bold()) }
|
|
||||||
func italic(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).italic()) }
|
|
||||||
|
|
||||||
func h1(_ str: String) -> Self { normal(str, .title1) }
|
|
||||||
func h2(_ str: String) -> Self { normal(str, .title2) }
|
|
||||||
func h3(_ str: String) -> Self { normal(str, .title3) }
|
|
||||||
|
|
||||||
private func append(_ str: String, withFont: UIFont) -> Self {
|
|
||||||
append(NSAttributedString(string: str, attributes: [
|
|
||||||
.font : withFont,
|
|
||||||
.foregroundColor : UIColor.sysFg
|
|
||||||
]))
|
|
||||||
return self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
extension UIFont {
|
|
||||||
func withTraits(traits: UIFontDescriptor.SymbolicTraits) -> UIFont {
|
|
||||||
UIFont(descriptor: fontDescriptor.withSymbolicTraits(traits)!, size: 0) // keep size as is
|
|
||||||
}
|
|
||||||
func bold() -> UIFont { withTraits(traits: .traitBold) }
|
|
||||||
func italic() -> UIFont { withTraits(traits: .traitItalic) }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,12 +54,15 @@ class SearchBarManager: NSObject, UISearchBarDelegate {
|
|||||||
hideAndRelease()
|
hideAndRelease()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let h = searchBar.frame.height
|
||||||
if active {
|
if active {
|
||||||
tv.scrollToTop(animated: false)
|
tv.scrollToTop(animated: false)
|
||||||
tv.tableHeaderView = searchBar
|
tv.tableHeaderView = searchBar
|
||||||
tv.frame.origin.y = -searchBar.frame.height
|
tv.frame.origin.y -= h
|
||||||
|
tv.frame.size.height += h
|
||||||
UIView.animate(withDuration: 0.3, animations: {
|
UIView.animate(withDuration: 0.3, animations: {
|
||||||
tv.frame.origin.y = 0
|
tv.frame.origin.y += h
|
||||||
|
tv.frame.size.height -= h
|
||||||
}) { _ in
|
}) { _ in
|
||||||
tv.reloadData()
|
tv.reloadData()
|
||||||
self.searchBar.becomeFirstResponder()
|
self.searchBar.becomeFirstResponder()
|
||||||
@@ -67,10 +70,12 @@ class SearchBarManager: NSObject, UISearchBarDelegate {
|
|||||||
} else {
|
} else {
|
||||||
searchBar.resignFirstResponder()
|
searchBar.resignFirstResponder()
|
||||||
UIView.animate(withDuration: 0.3, animations: {
|
UIView.animate(withDuration: 0.3, animations: {
|
||||||
tv.frame.origin.y = -(tv.tableHeaderView?.frame.height ?? 0)
|
tv.frame.origin.y -= h
|
||||||
|
tv.frame.size.height += h
|
||||||
tv.scrollToTop(animated: false) // false to let UIView animate the change
|
tv.scrollToTop(animated: false) // false to let UIView animate the change
|
||||||
}) { _ in
|
}) { _ in
|
||||||
tv.frame.origin.y = 0
|
tv.frame.origin.y += h
|
||||||
|
tv.frame.size.height -= h
|
||||||
self.hideAndRelease()
|
self.hideAndRelease()
|
||||||
tv.reloadData()
|
tv.reloadData()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ class TutorialSheet: UIViewController, UIScrollViewDelegate {
|
|||||||
|
|
||||||
let content = UIView()
|
let content = UIView()
|
||||||
x.addSubview(content)
|
x.addSubview(content)
|
||||||
content.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
content.anchor([.left, .right, .top, .bottom], to: x)
|
content.anchor([.left, .right, .top, .bottom], to: x)
|
||||||
content.anchor([.width, .height], to: x) | .defaultLow
|
content.anchor([.width, .height], to: x) | .defaultLow
|
||||||
return x
|
return x
|
||||||
@@ -62,7 +61,7 @@ class TutorialSheet: UIViewController, UIScrollViewDelegate {
|
|||||||
|
|
||||||
// MARK: Init
|
// MARK: Init
|
||||||
|
|
||||||
required init?(coder: NSCoder) { super.init(coder: coder) }
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
required init() {
|
required init() {
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
@@ -98,7 +97,6 @@ class TutorialSheet: UIViewController, UIScrollViewDelegate {
|
|||||||
pager.numberOfPages += 1
|
pager.numberOfPages += 1
|
||||||
updateButtonTitle()
|
updateButtonTitle()
|
||||||
let x = UIStackView(frame: pageScroll.bounds)
|
let x = UIStackView(frame: pageScroll.bounds)
|
||||||
x.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
x.axis = .vertical
|
x.axis = .vertical
|
||||||
x.backgroundColor = UIColor.black
|
x.backgroundColor = UIColor.black
|
||||||
x.isOpaque = true
|
x.isOpaque = true
|
||||||
@@ -125,8 +123,6 @@ class TutorialSheet: UIViewController, UIScrollViewDelegate {
|
|||||||
sheetBg.addSubview(pageScroll)
|
sheetBg.addSubview(pageScroll)
|
||||||
sheetBg.addSubview(button)
|
sheetBg.addSubview(button)
|
||||||
|
|
||||||
for x in sheetBg.subviews { x.translatesAutoresizingMaskIntoConstraints = false }
|
|
||||||
|
|
||||||
pager.anchor([.top, .left, .right], to: sheetBg)
|
pager.anchor([.top, .left, .right], to: sheetBg)
|
||||||
pageScroll.topAnchor =&= pager.bottomAnchor
|
pageScroll.topAnchor =&= pager.bottomAnchor
|
||||||
pageScroll.anchor([.left, .right, .top, .bottom], to: sheetBg, margin: cornerRadius/2) | .defaultHigh
|
pageScroll.anchor([.left, .right, .top, .bottom], to: sheetBg, margin: cornerRadius/2) | .defaultHigh
|
||||||
|
|||||||
@@ -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,11 +146,23 @@ extension SQLiteDatabase {
|
|||||||
|
|
||||||
// MARK: read
|
// MARK: read
|
||||||
|
|
||||||
|
/// `SELECT min(ts) FROM heap`
|
||||||
|
func dnsLogsMinDate() -> Timestamp? {
|
||||||
|
try? run(sql:"SELECT min(ts) FROM heap") {
|
||||||
|
try ifStep($0, SQLITE_ROW)
|
||||||
|
return sqlite3_column_int64($0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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)
|
||||||
@@ -144,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"
|
||||||
@@ -181,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))
|
||||||
|
|||||||
@@ -25,16 +25,22 @@ extension CreateTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension SQLiteDatabase {
|
extension SQLiteDatabase {
|
||||||
|
// /// `INSERT INTO cache (dns, opt) VALUES (?, ?);`
|
||||||
|
// func logWritePrepare() throws -> OpaquePointer {
|
||||||
|
// try prepare(sql: "INSERT INTO cache (dns, opt) VALUES (?, ?);")
|
||||||
|
// }
|
||||||
|
// /// `prep` must exist and be initialized with `logWritePrepare()`
|
||||||
|
// func logWrite(_ pStmt: OpaquePointer!, _ domain: String, blocked: Bool = false) throws {
|
||||||
|
// guard let prep = pStmt else {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// try prepared(run: prep, bind: [BindText(domain), BindInt32(blocked ? 1 : 0)])
|
||||||
|
// }
|
||||||
/// `INSERT INTO cache (dns, opt) VALUES (?, ?);`
|
/// `INSERT INTO cache (dns, opt) VALUES (?, ?);`
|
||||||
func logWritePrepare() throws -> OpaquePointer {
|
func logWrite(_ domain: String, blocked: Bool = false) throws {
|
||||||
try prepare(sql: "INSERT INTO cache (dns, opt) VALUES (?, ?);")
|
try self.run(sql: "INSERT INTO cache (dns, opt) VALUES (?, ?);",
|
||||||
}
|
bind: [BindText(domain), BindInt32(blocked ? 1 : 0)])
|
||||||
/// `prep` must exist and be initialized with `logWritePrepare()`
|
{ try ifStep($0, SQLITE_DONE) }
|
||||||
func logWrite(_ pStmt: OpaquePointer!, _ domain: String, blocked: Bool = false) throws {
|
|
||||||
guard let prep = pStmt else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try prepared(run: prep, bind: [BindText(domain), BindInt32(blocked ? 1 : 0)])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,10 +58,10 @@ extension CreateTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct FilterOptions: OptionSet {
|
struct FilterOptions: OptionSet {
|
||||||
let rawValue: Int32
|
let rawValue: Int32
|
||||||
static let none = FilterOptions([])
|
static let none = FilterOptions([])
|
||||||
static let blocked = FilterOptions(rawValue: 1 << 0)
|
static let blocked = FilterOptions(rawValue: 1 << 0)
|
||||||
static let ignored = FilterOptions(rawValue: 1 << 1)
|
static let ignored = FilterOptions(rawValue: 1 << 1)
|
||||||
static let any = FilterOptions(rawValue: 0b11)
|
static let any = FilterOptions(rawValue: 0b11)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -34,7 +35,7 @@ class SQLiteDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
sqlite3_close(dbPointer)
|
sqlite3_close_v2(dbPointer)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func destroyDatabase(path: String = URL.internalDB().relativePath) {
|
static func destroyDatabase(path: String = URL.internalDB().relativePath) {
|
||||||
@@ -46,15 +47,10 @@ class SQLiteDatabase {
|
|||||||
|
|
||||||
static func open(path: String = URL.internalDB().relativePath) throws -> SQLiteDatabase {
|
static func open(path: String = URL.internalDB().relativePath) throws -> SQLiteDatabase {
|
||||||
var db: OpaquePointer?
|
var db: OpaquePointer?
|
||||||
//sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_SHAREDCACHE, nil)
|
if sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK {
|
||||||
if sqlite3_open(path, &db) == SQLITE_OK {
|
|
||||||
return SQLiteDatabase(dbPointer: db)
|
return SQLiteDatabase(dbPointer: db)
|
||||||
} else {
|
} else {
|
||||||
defer {
|
defer { sqlite3_close_v2(db) }
|
||||||
if db != nil {
|
|
||||||
sqlite3_close(db)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let errorPointer = sqlite3_errmsg(db) {
|
if let errorPointer = sqlite3_errmsg(db) {
|
||||||
let message = String(cString: errorPointer)
|
let message = String(cString: errorPointer)
|
||||||
throw SQLiteError.OpenDatabase(message: message)
|
throw SQLiteError.OpenDatabase(message: message)
|
||||||
@@ -221,6 +217,7 @@ extension SQLiteDatabase {
|
|||||||
func prepare(sql: String) throws -> OpaquePointer {
|
func prepare(sql: String) throws -> OpaquePointer {
|
||||||
var pStmt: OpaquePointer?
|
var pStmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(dbPointer, sql, -1, &pStmt, nil) == SQLITE_OK, let S = pStmt else {
|
guard sqlite3_prepare_v2(dbPointer, sql, -1, &pStmt, nil) == SQLITE_OK, let S = pStmt else {
|
||||||
|
sqlite3_finalize(pStmt)
|
||||||
throw SQLiteError.Prepare(message: errorMessage)
|
throw SQLiteError.Prepare(message: errorMessage)
|
||||||
}
|
}
|
||||||
return S
|
return S
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ extension GroupedDomain {
|
|||||||
extension GroupedDomain {
|
extension GroupedDomain {
|
||||||
var detailCellText: String { get {
|
var detailCellText: String { get {
|
||||||
return blocked > 0
|
return blocked > 0
|
||||||
? "\(lastModified.asDateTime()) — \(blocked)/\(total) blocked"
|
? "\(DateFormat.seconds(lastModified)) — \(blocked)/\(total) blocked"
|
||||||
: "\(lastModified.asDateTime()) — \(total)"
|
: "\(DateFormat.seconds(lastModified)) — \(total)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,78 +1,81 @@
|
|||||||
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 lazy var search = SearchBarManager(on: delegate!.tableView)
|
||||||
|
private var currentOrder: DateFilterOrderBy = .Date
|
||||||
|
private var orderAsc = false
|
||||||
|
|
||||||
private let parent: String?
|
/// Will init `sync.allowPullToRefresh()` on `tableView.refreshControl` as well.
|
||||||
let pipeline: FilterPipeline<GroupedDomain>
|
weak var delegate: GroupedDomainDataSourceDelegate? {
|
||||||
private lazy var search = SearchBarManager(on: pipeline.delegate!.tableView)
|
willSet { if #available(iOS 10.0, *), newValue !== delegate {
|
||||||
|
sync.allowPullToRefresh(onTVC: newValue, forObserver: self)
|
||||||
|
}}}
|
||||||
|
|
||||||
init(withDelegate tvc: FilterPipelineDelegate, parent p: String?) {
|
/// - Note: Will call `tableview.reloadData()`
|
||||||
parent = p
|
init(withParent: String?) {
|
||||||
pipeline = .init(withDelegate: tvc)
|
parent = withParent
|
||||||
pipeline.setDataSource { [unowned self] in self.dataSourceCallback() }
|
pipeline.delegate = self
|
||||||
pipeline.setSorting {
|
resetSortingOrder(force: true)
|
||||||
$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)
|
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
|
||||||
NotifySyncInsert.observe(call: #selector(syncInsert), on: self)
|
NotifySortOrderChanged.observe(call: #selector(didChangeSortOrder), 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pause recurring background updates to force reload `dataSource`.
|
/// Read user defaults and apply new sorting order. Either by setting a new or reversing the current.
|
||||||
/// Callback fired on user action `pull-to-refresh`, or another background task triggered `NotifyLogHistoryReset`.
|
/// - Parameter force: If `true` set new sorting even if the type does not differ.
|
||||||
/// - Parameter sender: May be either `UIRefreshControl` or `Notification`
|
private func resetSortingOrder(force: Bool = false) {
|
||||||
/// (optional: pass single domain as the notification object).
|
let orderAscChanged = (orderAsc <-? Pref.DateFilter.OrderAsc)
|
||||||
@objc func reloadFromSource(sender: Any? = nil) {
|
let orderTypChanged = (currentOrder <-? Pref.DateFilter.OrderBy)
|
||||||
weak var refreshControl = sender as? UIRefreshControl
|
if orderTypChanged || force {
|
||||||
let notification = sender as? Notification
|
switch currentOrder {
|
||||||
sync.pause()
|
case .Date:
|
||||||
if let affectedDomain = notification?.object as? String {
|
pipeline.setSorting { [unowned self] in
|
||||||
partiallyReloadFromSource(affectedDomain)
|
self.orderAsc ? $0.lastModified < $1.lastModified : $0.lastModified > $1.lastModified
|
||||||
sync.continue()
|
}
|
||||||
} else {
|
case .Name:
|
||||||
pipeline.reload(fromSource: true, whenDone: {
|
pipeline.setSorting { [unowned self] in
|
||||||
sync.syncNow() // sync outstanding entries in cache
|
self.orderAsc ? $0.domain < $1.domain : $0.domain > $1.domain
|
||||||
sync.continue()
|
}
|
||||||
refreshControl?.endRefreshing()
|
case .Count:
|
||||||
})
|
pipeline.setSorting { [unowned self] in
|
||||||
|
self.orderAsc ? $0.total < $1.total : $0.total > $1.total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if orderAscChanged {
|
||||||
|
pipeline.reverseSorting()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Callback fired when user editslist 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,100 +85,138 @@ class GroupedDomainDataSource {
|
|||||||
@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
|
|
||||||
|
|
||||||
/// Callback fired when background sync added new entries to the list. (`NotifySyncInsert` notification)
|
|
||||||
@objc private func syncInsert(_ notification: Notification) {
|
|
||||||
sync.pause()
|
|
||||||
defer { sync.continue() }
|
|
||||||
let range = notification.object as! SQLiteRowRange
|
|
||||||
guard let latest = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent) else {
|
|
||||||
assertionFailure("NotifySyncInsert fired with empty range")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pipeline.pauseCellAnimations(if: latest.count > 14)
|
|
||||||
for x in latest {
|
|
||||||
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) {
|
|
||||||
pipeline.update(obj + x, at: i)
|
|
||||||
} else {
|
|
||||||
var y = x
|
|
||||||
y.options = DomainFilter[x.domain]
|
|
||||||
pipeline.addNew(y)
|
|
||||||
}
|
|
||||||
tsLatest = max(tsLatest, x.lastModified)
|
|
||||||
}
|
|
||||||
pipeline.continueCellAnimations(reloadTable: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Callback fired when background sync removed old entries from the list. (`NotifySyncRemove` notification)
|
|
||||||
@objc private func syncRemove(_ notification: Notification) {
|
|
||||||
sync.pause()
|
|
||||||
defer { sync.continue() }
|
|
||||||
let range = notification.object as! SQLiteRowRange
|
|
||||||
guard let outdated = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent),
|
|
||||||
outdated.count > 0 else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pipeline.pauseCellAnimations(if: outdated.count > 14)
|
|
||||||
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())
|
|
||||||
pipeline.continueCellAnimations(reloadTable: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ################################
|
// ################################
|
||||||
// #
|
// #
|
||||||
// # MARK: - Delete History
|
// # MARK: - Partial Update
|
||||||
// #
|
// #
|
||||||
// ################################
|
// ################################
|
||||||
|
|
||||||
extension GroupedDomainDataSource {
|
extension GroupedDomainDataSource {
|
||||||
|
|
||||||
/// Callback fired when user performs row edit -> delete action
|
func syncUpdate(_: SyncUpdate, reset rows: SQLiteRowRange) {
|
||||||
func deleteHistory(domain: String, since ts: Timestamp) {
|
var logs = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent) ?? []
|
||||||
let flag = (parent != nil)
|
for (i, val) in logs.enumerated() {
|
||||||
DispatchQueue.global().async {
|
logs[i].options = DomainFilter[val.domain]
|
||||||
guard let db = AppDB, db.dnsLogsDelete(domain, strict: flag, since: ts) > 0 else {
|
}
|
||||||
return // nothing has changed
|
DispatchQueue.main.sync {
|
||||||
}
|
pipeline.reset(dataSource: logs)
|
||||||
db.vacuum()
|
|
||||||
NotifyLogHistoryReset.postAsyncMain(domain) // calls partiallyReloadFromSource(:)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reload a single data source entry. Callback fired by `reloadFromSource()`
|
func syncUpdate(_: SyncUpdate, insert rows: SQLiteRowRange, affects: SyncUpdateEnd) {
|
||||||
/// Only useful if `affectedFQDN` currently exists in `dataSource`. Can either update or remove entry.
|
guard let latest = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent) else {
|
||||||
private func partiallyReloadFromSource(_ affectedFQDN: String) {
|
assertionFailure("NotifySyncInsert fired with empty range")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
cellAnimationsGroup(if: latest.count > 14)
|
||||||
|
for x in latest {
|
||||||
|
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) {
|
||||||
|
pipeline.update(obj + x, at: i)
|
||||||
|
} else {
|
||||||
|
var y = x
|
||||||
|
y.options = DomainFilter[x.domain]
|
||||||
|
pipeline.addNew(y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cellAnimationsCommit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncUpdate(_ sender: SyncUpdate, remove rows: SQLiteRowRange, affects: SyncUpdateEnd) {
|
||||||
|
if affects == .Latest {
|
||||||
|
// TODO: alternatively query last modified from db (last entry _before_ range)
|
||||||
|
syncUpdate(sender, reset: sender.rows)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let outdated = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent),
|
||||||
|
outdated.count > 0 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
cellAnimationsGroup(if: outdated.count > 14)
|
||||||
|
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())
|
||||||
|
cellAnimationsCommit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncUpdate(_ sender: SyncUpdate, partialRemove 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
|
||||||
}
|
}
|
||||||
let affected = (parent == nil ? affectedParent : affectedFQDN)
|
let affected = (parent == nil ? affectedParent : affectedFQDN)
|
||||||
guard let old = pipeline.dataSourceGet(where: { $0.domain == affected }) else {
|
let updated = AppDB?.dnsLogsGrouped(range: sender.rows, matchingDomain: affected, parentDomain: parent)?.first
|
||||||
// can only happen if delete sheet is open while background sync removed the element
|
DispatchQueue.main.sync {
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
if var updated = updated {
|
||||||
|
assert(old.object.domain == updated.domain)
|
||||||
|
updated.options = DomainFilter[updated.domain]
|
||||||
|
pipeline.update(updated, at: old.index)
|
||||||
|
} else {
|
||||||
|
pipeline.remove(indices: [old.index])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if var updated = AppDB?.dnsLogsGrouped(since: sync.tsEarliest, upto: tsLatest,
|
}
|
||||||
matchingDomain: affected, parentDomain: parent)?.first {
|
}
|
||||||
assert(old.object.domain == updated.domain)
|
|
||||||
updated.options = DomainFilter[updated.domain]
|
|
||||||
pipeline.update(updated, at: old.index)
|
// #################################
|
||||||
} else {
|
// #
|
||||||
pipeline.remove(indices: [old.index])
|
// # 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 || delegate?.tableView.isFrontmost == false {
|
||||||
|
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
|
||||||
|
delegate?.tableView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Collect animations and post them in a single animations block.
|
||||||
|
// This will require enormous work to translate them into a final set.
|
||||||
|
func filterPipelineDidReset() { delegate?.tableView.reloadData() }
|
||||||
|
func filterPipeline(delete rows: [Int]) { delegate?.tableView.safeDeleteRows(rows) }
|
||||||
|
func filterPipeline(insert row: Int) { delegate?.tableView.safeInsertRow(row, with: .left) }
|
||||||
|
func filterPipeline(update row: Int) {
|
||||||
|
guard let tv = delegate?.tableView else { return }
|
||||||
|
if !tv.isEditing { tv.safeReloadRow(row) }
|
||||||
|
else if tv.isFrontmost == true {
|
||||||
|
delegate?.groupedDomainDataSource(needsUpdate: row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func filterPipeline(move oldRow: Int, to newRow: Int) {
|
||||||
|
delegate?.tableView.safeMoveRow(oldRow, to: newRow)
|
||||||
|
if delegate?.tableView.isFrontmost == true {
|
||||||
|
delegate?.groupedDomainDataSource(needsUpdate: newRow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,12 +229,13 @@ extension GroupedDomainDataSource {
|
|||||||
// ################################
|
// ################################
|
||||||
|
|
||||||
extension GroupedDomainDataSource {
|
extension GroupedDomainDataSource {
|
||||||
|
// TODO: permanently show search bar as table header?
|
||||||
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)
|
||||||
@@ -204,7 +246,7 @@ extension GroupedDomainDataSource {
|
|||||||
searchTerm = $0.lowercased()
|
searchTerm = $0.lowercased()
|
||||||
self.pipeline.reloadFilter(withId: "search")
|
self.pipeline.reloadFilter(withId: "search")
|
||||||
})
|
})
|
||||||
pipeline.continueCellAnimations()
|
cellAnimationsCommit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,8 +258,8 @@ extension GroupedDomainDataSource {
|
|||||||
// #
|
// #
|
||||||
// ##########################
|
// ##########################
|
||||||
|
|
||||||
protocol GroupedDomainEditRow : EditableRows, FilterPipelineDelegate {
|
protocol GroupedDomainEditRow : UIViewController, EditableRows {
|
||||||
var source: GroupedDomainDataSource { get set }
|
var source: GroupedDomainDataSource { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension GroupedDomainEditRow {
|
extension GroupedDomainEditRow {
|
||||||
@@ -244,8 +286,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,67 @@
|
|||||||
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()
|
||||||
|
/// `tsEarliest ?? 0`
|
||||||
|
private var tsMin: Timestamp { tsEarliest ?? 0 }
|
||||||
|
/// `(tsLatest + 1) ?? 0`
|
||||||
|
private var tsMax: Timestamp { (tsLatest ?? -1) + 1 }
|
||||||
|
|
||||||
|
/// Returns invalid range `(-1,-1)` if collection contains no rows
|
||||||
|
var rows: SQLiteRowRange { get { range ?? (-1,-1) } }
|
||||||
|
private(set) var tsEarliest: Timestamp? // as set per user, not actual earliest
|
||||||
|
private(set) var tsLatest: Timestamp? // as set per user, not actual latest
|
||||||
|
|
||||||
|
|
||||||
init(periodic interval: TimeInterval) {
|
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.tsMin <= (filter.earliest ?? 0) {
|
||||||
|
self.set(newEarliest: filter.earliest)
|
||||||
|
self.set(newLatest: filter.latest)
|
||||||
|
} else {
|
||||||
|
self.set(newLatest: filter.latest)
|
||||||
|
self.set(newEarliest: filter.earliest)
|
||||||
|
}
|
||||||
|
self.continue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// - Warning: Always call from a background thread!
|
||||||
|
func needsReloadDB(domain: String? = nil) {
|
||||||
|
assert(!Thread.isMainThread)
|
||||||
|
reloadRangeFromDB()
|
||||||
|
if let dom = domain {
|
||||||
|
notifyObservers { $0.syncUpdate(self, partialRemove: dom) }
|
||||||
|
} else {
|
||||||
|
notifyObservers { $0.syncUpdate(self, reset: rows) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Sync Now
|
||||||
|
|
||||||
/// This will immediately resume timer updates, ignoring previous `pause()` requests.
|
/// This will immediately resume timer updates, ignoring previous `pause()` requests.
|
||||||
func start() { paused = 0 }
|
func start() { paused = 0 }
|
||||||
|
|
||||||
@@ -37,38 +78,203 @@ 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 newest = AppDB?.dnsLogsPersist() { // move cache -> heap
|
||||||
|
if filterType == .ABRange {
|
||||||
|
// ... even if we filter a few later
|
||||||
|
if let r = rows(tsMin, tsMax, scope: newest) {
|
||||||
|
notify(insert: r, .Latest)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notify(insert: newest, .Latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filterType == .LastXMin {
|
||||||
|
set(newEarliest: Timestamp.past(minutes: Pref.DateFilter.LastXMin))
|
||||||
|
}
|
||||||
|
// TODO: periodic hard delete old logs (will reset rowids!)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Internal
|
||||||
|
|
||||||
|
private func rows(_ ts1: Timestamp, _ ts2: Timestamp, scope: SQLiteRowRange = (0,0)) -> SQLiteRowRange? {
|
||||||
|
AppDB?.dnsLogsRowRange(between: ts1, and: ts2, within: scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadRangeFromDB() {
|
||||||
|
// `nil` is not SQLiteRowRange(0,0) aka. full collection.
|
||||||
|
// `nil` means invalid range. e.g. ts restriction too high or empty db.
|
||||||
|
range = rows(tsMin, tsMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update internal `tsEarliest`, then post `NotifySyncInsert` or `NotifySyncRemove` notification with row ids.
|
||||||
/// - Warning: Always call from a background thread!
|
/// - Warning: Always call from a background thread!
|
||||||
private func set(newEarliest: Timestamp) {
|
private func set(newEarliest: Timestamp?) {
|
||||||
let current = tsEarliest
|
func from(_ t: Timestamp?) -> Timestamp { t ?? 0 }
|
||||||
tsEarliest = newEarliest
|
func to(_ t: Timestamp) -> Timestamp { tsLatest == nil ? t : min(t, tsMax) }
|
||||||
if current < newEarliest {
|
|
||||||
if let excess = AppDB?.dnsLogsRowRange(between: current, and: newEarliest) {
|
if let (old, new) = tsEarliest <-/ newEarliest {
|
||||||
NotifySyncRemove.postAsyncMain(excess)
|
if old != nil, (new == nil || new! < old!) {
|
||||||
|
if let r = rows(from(new), to(old!), scope: (0, range?.start ?? 0)) {
|
||||||
|
notify(insert: r, .Earliest)
|
||||||
|
}
|
||||||
|
} else if range != nil {
|
||||||
|
if let r = rows(from(old), to(new!), scope: range!) {
|
||||||
|
notify(remove: r, .Earliest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} 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 set(newLatest: Timestamp?) {
|
||||||
|
func from(_ t: Timestamp) -> Timestamp { max(t + 1, tsMin) }
|
||||||
|
func to(_ t: Timestamp?) -> Timestamp { t == nil ? 0 : t! + 1 }
|
||||||
|
// +1: include upper end because `dnsLogsRowRange` selects `ts < X`
|
||||||
|
|
||||||
|
if let (old, new) = tsLatest <-/ newLatest {
|
||||||
|
if old != nil, (new == nil || old! < new!) {
|
||||||
|
if let r = rows(from(old!), to(new), scope: (range?.end ?? 0, 0)) {
|
||||||
|
notify(insert: r, .Latest)
|
||||||
|
}
|
||||||
|
} else if range != nil {
|
||||||
|
// FIXME: removing latest entries will invalidate "last changed" label
|
||||||
|
if let r = rows(from(new!), to(old), scope: range!) {
|
||||||
|
notify(remove: r, .Latest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} // else: nothing changed
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - Warning: Always call from a background thread!
|
||||||
|
private func notify(insert r: SQLiteRowRange, _ end: SyncUpdateEnd) {
|
||||||
|
if range == nil { range = r }
|
||||||
|
else {
|
||||||
|
switch end {
|
||||||
|
case .Earliest: range!.start = r.start
|
||||||
|
case .Latest: range!.end = r.end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyObservers { $0.syncUpdate(self, insert: r, affects: end) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - Warning: `range` must not be `nil`!
|
||||||
|
/// - Warning: Always call from a background thread!
|
||||||
|
private func notify(remove r: SQLiteRowRange, _ end: SyncUpdateEnd) {
|
||||||
|
switch end {
|
||||||
|
case .Earliest: range!.start = r.end + 1
|
||||||
|
case .Latest: range!.end = r.start - 1
|
||||||
|
}
|
||||||
|
if range!.start > range!.end { range = nil }
|
||||||
|
notifyObservers { $0.syncUpdate(self, remove: r, affects: end) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Observer List
|
||||||
|
|
||||||
|
private var observers: [WeakObserver] = []
|
||||||
|
|
||||||
|
/// Add `delegate` to observer list and immediatelly call `syncUpdate(reset:)` (on background thread).
|
||||||
|
func addObserver(_ delegate: SyncUpdateDelegate) {
|
||||||
|
observers.removeAll { $0.target == nil }
|
||||||
|
observers.append(.init(target: delegate))
|
||||||
|
DispatchQueue.global().async {
|
||||||
|
delegate.syncUpdate(self, reset: self.rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - Warning: Always call from a background thread!
|
||||||
|
private func notifyObservers(_ block: (SyncUpdateDelegate) -> Void) {
|
||||||
|
assert(!Thread.isMainThread)
|
||||||
|
self.pause()
|
||||||
|
for o in observers where o.target != nil { block(o.target!) }
|
||||||
|
self.continue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper class for `SyncUpdateDelegate` that supports weak references
|
||||||
|
private struct WeakObserver {
|
||||||
|
weak var target: SyncUpdateDelegate?
|
||||||
|
weak var pullToRefresh: UIRefreshControl?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncUpdateEnd { case Earliest, Latest }
|
||||||
|
|
||||||
|
protocol SyncUpdateDelegate : AnyObject {
|
||||||
|
/// `SyncUpdate` has unpredictable changes. Reload your `dataSource`.
|
||||||
|
/// - Warning: This function will **always** be called from a background thread.
|
||||||
|
func syncUpdate(_ sender: SyncUpdate, reset rows: SQLiteRowRange)
|
||||||
|
|
||||||
|
/// `SyncUpdate` added new `rows` to database. Sync changes to your `dataSource`.
|
||||||
|
/// - Warning: This function will **always** be called from a background thread.
|
||||||
|
func syncUpdate(_ sender: SyncUpdate, insert rows: SQLiteRowRange, affects: SyncUpdateEnd)
|
||||||
|
|
||||||
|
/// `SyncUpdate` outdated some `rows` in database. Sync changes to your `dataSource`.
|
||||||
|
/// - Warning: This function will **always** be called from a background thread.
|
||||||
|
func syncUpdate(_ sender: SyncUpdate, remove rows: SQLiteRowRange, affects: SyncUpdateEnd)
|
||||||
|
|
||||||
|
/// Background process did delete some entries in database that match `affectedDomain`.
|
||||||
|
/// Update or remove entries from your `dataSource`.
|
||||||
|
/// - Warning: This function will **always** be called from a background thread.
|
||||||
|
func syncUpdate(_ sender: SyncUpdate, partialRemove affectedDomain: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Pull-To-Refresh
|
||||||
|
|
||||||
|
@available(iOS 10.0, *)
|
||||||
|
extension SyncUpdate {
|
||||||
|
|
||||||
|
/// Add Pull-To-Refresh control to `tableViewController`. On action notify `observer.syncUpdate(reset:)`
|
||||||
|
/// - Warning: Must be called after `addObserver()` such that `observer` exists in list of observers.
|
||||||
|
func allowPullToRefresh(onTVC tableViewController: UITableViewController?, forObserver: SyncUpdateDelegate) {
|
||||||
|
guard let i = observers.firstIndex(where: { $0.target === forObserver }) else {
|
||||||
|
assertionFailure("You must add the observer before enabling Pull-To-Refresh!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// remove previous
|
||||||
|
observers[i].pullToRefresh?.removeTarget(self, action: #selector(pullToRefresh), for: .valueChanged)
|
||||||
|
observers[i].pullToRefresh = nil
|
||||||
|
if let tvc = tableViewController {
|
||||||
|
let rc = UIRefreshControl()
|
||||||
|
rc.addTarget(self, action: #selector(pullToRefresh), for: .valueChanged)
|
||||||
|
tvc.tableView.refreshControl = rc
|
||||||
|
observers[i].pullToRefresh = rc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull-To-Refresh callback method. Find observer with corresponding `RefreshControl` and notify `syncUpdate(reset:)`
|
||||||
|
@objc private func pullToRefresh(sender: UIRefreshControl) {
|
||||||
|
guard let x = observers.first(where: { $0.pullToRefresh === sender }) else {
|
||||||
|
assertionFailure("Should never happen. RefreshControl removed from table view while keeping it active somewhere else.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncNow {
|
||||||
|
x.target?.syncUpdate(self, reset: self.rows)
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
sender.endRefreshing()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,22 @@ import Foundation
|
|||||||
|
|
||||||
#if IOS_SIMULATOR
|
#if IOS_SIMULATOR
|
||||||
|
|
||||||
private let db = AppDB!
|
|
||||||
private var pStmt: OpaquePointer?
|
|
||||||
|
|
||||||
class TestDataSource {
|
class TestDataSource {
|
||||||
|
|
||||||
static func load() {
|
static func load() {
|
||||||
QLog.Debug("SQLite path: \(URL.internalDB())")
|
QLog.Debug("SQLite path: \(URL.internalDB())")
|
||||||
|
|
||||||
|
let db = AppDB!
|
||||||
let deleted = db.dnsLogsDelete("test.com", strict: false)
|
let deleted = db.dnsLogsDelete("test.com", strict: false)
|
||||||
try? db.run(sql: "DELETE FROM cache;")
|
try? db.run(sql: "DELETE FROM cache;")
|
||||||
QLog.Debug("Deleting \(deleted) rows matching 'test.com' (+ \(db.numberOfChanges) in cache)")
|
QLog.Debug("Deleting \(deleted) rows matching 'test.com' (+ \(db.numberOfChanges) in cache)")
|
||||||
|
|
||||||
QLog.Debug("Writing 33 test logs")
|
QLog.Debug("Writing 33 test logs")
|
||||||
pStmt = try! db.logWritePrepare()
|
try? db.logWrite("keeptest.com", blocked: false)
|
||||||
try? db.logWrite(pStmt, "keeptest.com", blocked: false)
|
for _ in 1...4 { try? db.logWrite("test.com", blocked: false) }
|
||||||
for _ in 1...4 { try? db.logWrite(pStmt, "test.com", blocked: false) }
|
for _ in 1...7 { try? db.logWrite("i.test.com", blocked: false) }
|
||||||
for _ in 1...7 { try? db.logWrite(pStmt, "i.test.com", blocked: false) }
|
for i in 1...8 { try? db.logWrite("b.test.com", blocked: i>5) }
|
||||||
for i in 1...8 { try? db.logWrite(pStmt, "b.test.com", blocked: i>5) }
|
for i in 1...13 { try? db.logWrite("bi.test.com", blocked: i%2==0) }
|
||||||
for i in 1...13 { try? db.logWrite(pStmt, "bi.test.com", blocked: i%2==0) }
|
|
||||||
|
|
||||||
db.dnsLogsPersist()
|
db.dnsLogsPersist()
|
||||||
|
|
||||||
@@ -36,7 +33,7 @@ class TestDataSource {
|
|||||||
|
|
||||||
@objc static func insertRandom() {
|
@objc static func insertRandom() {
|
||||||
//QLog.Debug("Inserting 1 periodic log entry")
|
//QLog.Debug("Inserting 1 periodic log entry")
|
||||||
try? db.logWrite(pStmt, "\(arc4random() % 5).count.test.com", blocked: true)
|
try? AppDB?.logWrite("\(arc4random() % 5).count.test.com", blocked: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ extension UIView {
|
|||||||
private static let inverseItem: [NSLayoutConstraint.Attribute] = [.right, .bottom, .trailing, .lastBaseline, .rightMargin, .bottomMargin, .trailingMargin]
|
private static let inverseItem: [NSLayoutConstraint.Attribute] = [.right, .bottom, .trailing, .lastBaseline, .rightMargin, .bottomMargin, .trailingMargin]
|
||||||
|
|
||||||
/// Create and active constraints for provided edges. Constraints will anchor the same edge on both `self` and `other`.
|
/// Create and active constraints for provided edges. Constraints will anchor the same edge on both `self` and `other`.
|
||||||
|
/// - Note: Will set `translatesAutoresizingMaskIntoConstraints = false`
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - edges: List of constraint attributes, e.g. `[.top, .bottom, .left, .right]`
|
/// - edges: List of constraint attributes, e.g. `[.top, .bottom, .left, .right]`
|
||||||
/// - other: Instance to bind to, e.g. `UIView` or `UILayoutGuide`
|
/// - other: Instance to bind to, e.g. `UIView` or `UILayoutGuide`
|
||||||
@@ -66,7 +67,8 @@ extension UIView {
|
|||||||
/// - rel: Constraint relation. (Default: `.equal`)
|
/// - rel: Constraint relation. (Default: `.equal`)
|
||||||
/// - Returns: List of created and active constraints
|
/// - Returns: List of created and active constraints
|
||||||
@discardableResult func anchor(_ edges: [NSLayoutConstraint.Attribute], to other: Any, margin: CGFloat = 0, if rel: NSLayoutConstraint.Relation = .equal) -> [NSLayoutConstraint] {
|
@discardableResult func anchor(_ edges: [NSLayoutConstraint.Attribute], to other: Any, margin: CGFloat = 0, if rel: NSLayoutConstraint.Relation = .equal) -> [NSLayoutConstraint] {
|
||||||
edges.map {
|
translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return edges.map {
|
||||||
let (A, B) = UIView.inverseItem.contains($0) ? (other, self) : (self, other)
|
let (A, B) = UIView.inverseItem.contains($0) ? (other, self) : (self, other)
|
||||||
return NSLayoutConstraint(item: A, attribute: $0, relatedBy: rel, toItem: B, attribute: $0, multiplier: 1, constant: margin).on()
|
return NSLayoutConstraint(item: A, attribute: $0, relatedBy: rel, toItem: B, attribute: $0, multiplier: 1, constant: margin).on()
|
||||||
}
|
}
|
||||||
|
|||||||
34
main/Extensions/Font.swift
Normal file
34
main/Extensions/Font.swift
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIFont {
|
||||||
|
func withTraits(traits: UIFontDescriptor.SymbolicTraits) -> UIFont {
|
||||||
|
UIFont(descriptor: fontDescriptor.withSymbolicTraits(traits)!, size: 0) // keep size as is
|
||||||
|
}
|
||||||
|
func bold() -> UIFont { withTraits(traits: .traitBold) }
|
||||||
|
func italic() -> UIFont { withTraits(traits: .traitItalic) }
|
||||||
|
func monoSpace() -> UIFont {
|
||||||
|
let traits = fontDescriptor.object(forKey: .traits) as? [UIFontDescriptor.TraitKey: Any] ?? [:]
|
||||||
|
let weight = (traits[.weight] as? CGFloat) ?? UIFont.Weight.regular.rawValue
|
||||||
|
return .monospacedDigitSystemFont(ofSize: pointSize, weight: .init(rawValue: weight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NSMutableAttributedString {
|
||||||
|
static private var def: UIFont = .preferredFont(forTextStyle: .body)
|
||||||
|
|
||||||
|
func normal(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: .preferredFont(forTextStyle: style)) }
|
||||||
|
func bold(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).bold()) }
|
||||||
|
func italic(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).italic()) }
|
||||||
|
|
||||||
|
func h1(_ str: String) -> Self { normal(str, .title1) }
|
||||||
|
func h2(_ str: String) -> Self { normal(str, .title2) }
|
||||||
|
func h3(_ str: String) -> Self { normal(str, .title3) }
|
||||||
|
|
||||||
|
private func append(_ str: String, withFont: UIFont) -> Self {
|
||||||
|
append(NSAttributedString(string: str, attributes: [
|
||||||
|
.font : withFont,
|
||||||
|
.foregroundColor : UIColor.sysFg
|
||||||
|
]))
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,3 +26,35 @@ extension UIEdgeInsets {
|
|||||||
self.init(top: top ?? all, left: left ?? all, bottom: bottom ?? all, right: right ?? all)
|
self.init(top: top ?? all, left: left ?? all, bottom: bottom ?? all, right: right ?? all)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
precedencegroup CompareAssignPrecedence {
|
||||||
|
assignment: true
|
||||||
|
associativity: left
|
||||||
|
higherThan: ComparisonPrecedence
|
||||||
|
}
|
||||||
|
|
||||||
|
infix operator <-? : CompareAssignPrecedence
|
||||||
|
infix operator <-/ : CompareAssignPrecedence
|
||||||
|
extension Equatable {
|
||||||
|
/// Assign a new value to `lhs` if `newValue` differs from the previous value. Return `false` if they are equal.
|
||||||
|
/// - Returns: `true` if `lhs` was overwritten with another value
|
||||||
|
static func <-?(lhs: inout Self, newValue: Self) -> Bool {
|
||||||
|
if lhs != newValue {
|
||||||
|
lhs = newValue
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign a new value to `lhs` if `newValue` differs from the previous value.
|
||||||
|
/// Return tuple with both values. Or `nil` if they are equal.
|
||||||
|
/// - Returns: `nil` if `previousValue == newValue`
|
||||||
|
static func <-/(lhs: inout Self, newValue: Self) -> (previousValue: Self, newValue: Self)? {
|
||||||
|
let previousValue = lhs
|
||||||
|
if previousValue != newValue {
|
||||||
|
lhs = newValue
|
||||||
|
return (previousValue, newValue)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +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 NotifyLogHistoryReset = NSNotification.Name("PSILogHistoryReset") // domain: String?
|
let NotifySortOrderChanged = NSNotification.Name("PSIDateFilterSortOrderChanged") // nil!
|
||||||
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,35 +7,72 @@ public enum VPNState : Int {
|
|||||||
case on = 1, inbetween, off
|
case on = 1, inbetween, off
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Pref {
|
enum Pref {
|
||||||
struct DidShowTutorial {
|
static func Int(_ key: String) -> Int { UserDefaults.standard.integer(forKey: key) }
|
||||||
|
static func Int(_ val: Int, _ key: String) { UserDefaults.standard.set(val, forKey: key) }
|
||||||
|
static func Bool(_ key: String) -> Bool { UserDefaults.standard.bool(forKey: key) }
|
||||||
|
static func Bool(_ val: Bool, _ key: String) { UserDefaults.standard.set(val, forKey: key) }
|
||||||
|
static func `Any`(_ key: String) -> Any? { UserDefaults.standard.object(forKey: key) }
|
||||||
|
static func `Any`(_ val: Any?, _ key: String) { UserDefaults.standard.set(val, forKey: key) }
|
||||||
|
|
||||||
|
enum DidShowTutorial {
|
||||||
static var Welcome: Bool {
|
static var Welcome: Bool {
|
||||||
get { UserDefaults.standard.bool(forKey: "didShowTutorialAppWelcome") }
|
get { Pref.Bool("didShowTutorialAppWelcome") }
|
||||||
set { UserDefaults.standard.set(newValue, forKey: "didShowTutorialAppWelcome") }
|
set { Pref.Bool(newValue, "didShowTutorialAppWelcome") }
|
||||||
}
|
}
|
||||||
static var Recordings: Bool {
|
static var Recordings: Bool {
|
||||||
get { UserDefaults.standard.bool(forKey: "didShowTutorialRecordings") }
|
get { Pref.Bool("didShowTutorialRecordings") }
|
||||||
set { UserDefaults.standard.set(newValue, forKey: "didShowTutorialRecordings") }
|
set { Pref.Bool(newValue, "didShowTutorialRecordings") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
struct DateFilter {
|
enum DateFilter {
|
||||||
static var Kind: DateFilterKind {
|
static var Kind: DateFilterKind {
|
||||||
get { DateFilterKind(rawValue: UserDefaults.standard.integer(forKey: "dateFilterType"))! }
|
get { DateFilterKind(rawValue: Pref.Int("dateFilterType"))! }
|
||||||
set { UserDefaults.standard.set(newValue.rawValue, forKey: "dateFilterType") }
|
set { Pref.Int(newValue.rawValue, "dateFilterType") }
|
||||||
}
|
}
|
||||||
|
/// Default: `0` (disabled)
|
||||||
static var LastXMin: Int {
|
static var LastXMin: Int {
|
||||||
get { UserDefaults.standard.integer(forKey: "dateFilterLastXMin") }
|
get { Pref.Int("dateFilterLastXMin") }
|
||||||
set { UserDefaults.standard.set(newValue, forKey: "dateFilterLastXMin") }
|
set { Pref.Int(newValue, "dateFilterLastXMin") }
|
||||||
|
}
|
||||||
|
/// Default: `nil` (disabled)
|
||||||
|
static var RangeA: Timestamp? {
|
||||||
|
get { Pref.Any("dateFilterRangeA") as? Timestamp }
|
||||||
|
set { Pref.Any(newValue, "dateFilterRangeA") }
|
||||||
|
}
|
||||||
|
/// Default: `nil` (disabled)
|
||||||
|
static var RangeB: Timestamp? {
|
||||||
|
get { Pref.Any("dateFilterRangeB") as? Timestamp }
|
||||||
|
set { Pref.Any(newValue, "dateFilterRangeB") }
|
||||||
|
}
|
||||||
|
/// default: `.Date`
|
||||||
|
static var OrderBy: DateFilterOrderBy {
|
||||||
|
get { DateFilterOrderBy(rawValue: Pref.Int("dateFilterOderType"))! }
|
||||||
|
set { Pref.Int(newValue.rawValue, "dateFilterOderType") }
|
||||||
|
}
|
||||||
|
/// default: `false` (Desc)
|
||||||
|
static var OrderAsc: Bool {
|
||||||
|
get { Pref.Bool("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` : `(nil, nil)`
|
||||||
static func lastXMinTimestamp() -> Timestamp? {
|
/// - `LastXMin` : `(now-LastXMin, nil)`
|
||||||
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, nil, nil)
|
||||||
|
case .LastXMin: return (type, Timestamp.past(minutes: Pref.DateFilter.LastXMin), nil)
|
||||||
|
case .ABRange: return (type, Pref.DateFilter.RangeA, Pref.DateFilter.RangeB)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
enum DateFilterKind: Int {
|
enum DateFilterKind: Int {
|
||||||
case Off = 0, LastXMin = 1, ABRange = 2;
|
case Off = 0, LastXMin = 1, ABRange = 2;
|
||||||
}
|
}
|
||||||
|
enum DateFilterOrderBy: Int {
|
||||||
|
case Date = 0, Name = 1, Count = 2;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]] = [:]
|
||||||
|
|||||||
@@ -19,18 +19,26 @@ extension UITableView {
|
|||||||
/// Returns `true` if this `tableView` is the currently frontmost visible
|
/// Returns `true` if this `tableView` is the currently frontmost visible
|
||||||
var isFrontmost: Bool { window?.isKeyWindow ?? false }
|
var isFrontmost: Bool { window?.isKeyWindow ?? false }
|
||||||
|
|
||||||
|
/// If frontmost window, perform `insertRows()`; If not, perform `reloadData()`
|
||||||
|
func safeInsertRow(_ index: Int, with animation: UITableView.RowAnimation = .automatic) {
|
||||||
|
isFrontmost ? insertRows(at: [IndexPath(row: index)], with: animation) : reloadData()
|
||||||
|
}
|
||||||
|
/// If frontmost window, perform `insertRows()`; If not, perform `reloadData()`
|
||||||
|
func safeInsertRows(_ range: Range<Int>, with animation: UITableView.RowAnimation = .automatic) {
|
||||||
|
isFrontmost ? insertRows(at: range.map {IndexPath(row: $0)}, with: animation) : reloadData()
|
||||||
|
}
|
||||||
/// If frontmost window, perform `deleteRows()`; If not, perform `reloadData()`
|
/// If frontmost window, perform `deleteRows()`; If not, perform `reloadData()`
|
||||||
func safeDeleteRows(_ indices: [Int], with animation: UITableView.RowAnimation = .automatic) {
|
func safeDeleteRows(_ indices: [Int], with animation: UITableView.RowAnimation = .automatic) {
|
||||||
isFrontmost ? deleteRows(at: indices.map {IndexPath(row: $0)}, with: animation) : reloadData()
|
isFrontmost ? deleteRows(at: indices.map {IndexPath(row: $0)}, with: animation) : reloadData()
|
||||||
}
|
}
|
||||||
|
/// If frontmost window, perform `deleteRows()`; If not, perform `reloadData()`
|
||||||
|
func safeDeleteRows(_ range: Range<Int>, with animation: UITableView.RowAnimation = .automatic) {
|
||||||
|
isFrontmost ? deleteRows(at: range.map {IndexPath(row: $0)}, with: animation) : reloadData()
|
||||||
|
}
|
||||||
/// If frontmost window, perform `reloadRows()`; If not, perform `reloadData()`
|
/// If frontmost window, perform `reloadRows()`; If not, perform `reloadData()`
|
||||||
func safeReloadRow(_ index: Int, with animation: UITableView.RowAnimation = .automatic) {
|
func safeReloadRow(_ index: Int, with animation: UITableView.RowAnimation = .automatic) {
|
||||||
isFrontmost ? reloadRows(at: [IndexPath(row: index)], with: animation) : reloadData()
|
isFrontmost ? reloadRows(at: [IndexPath(row: index)], with: animation) : reloadData()
|
||||||
}
|
}
|
||||||
/// If frontmost window, perform `insertRows()`; If not, perform `reloadData()`
|
|
||||||
func safeInsertRow(_ index: Int, with animation: UITableView.RowAnimation = .automatic) {
|
|
||||||
isFrontmost ? insertRows(at: [IndexPath(row: index)], with: animation) : reloadData()
|
|
||||||
}
|
|
||||||
/// If frontmost window, perform `moveRow()`; If not, perform `reloadData()`
|
/// If frontmost window, perform `moveRow()`; If not, perform `reloadData()`
|
||||||
func safeMoveRow(_ from: Int, to: Int) {
|
func safeMoveRow(_ from: Int, to: Int) {
|
||||||
isFrontmost ? moveRow(at: IndexPath(row: from), to: IndexPath(row: to)) : reloadData()
|
isFrontmost ? moveRow(at: IndexPath(row: from), to: IndexPath(row: to)) : reloadData()
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
private let dateTimeFormat = DateFormatter(withFormat: "yyyy-MM-dd HH:mm:ss")
|
|
||||||
|
|
||||||
extension DateFormatter {
|
extension DateFormatter {
|
||||||
convenience init(withFormat: String) {
|
convenience init(withFormat: String) {
|
||||||
self.init()
|
self.init()
|
||||||
@@ -9,26 +7,18 @@ extension DateFormatter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Timestamp {
|
extension Date {
|
||||||
/// Time string with format `yyyy-MM-dd HH:mm:ss`
|
|
||||||
func asDateTime() -> String {
|
|
||||||
dateTimeFormat.string(from: Date.init(timeIntervalSince1970: Double(self)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert `Timestamp` to `Date`
|
/// Convert `Timestamp` to `Date`
|
||||||
func toDate() -> Date {
|
init(_ ts: Timestamp) { self.init(timeIntervalSince1970: Double(ts)) }
|
||||||
Date(timeIntervalSince1970: Double(self))
|
/// Convert `Date` to `Timestamp`
|
||||||
}
|
var timestamp: Timestamp { get { Timestamp(self.timeIntervalSince1970) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Timestamp {
|
||||||
/// Current time as `Timestamp` (second accuracy)
|
/// Current time as `Timestamp` (second accuracy)
|
||||||
static func now() -> Timestamp {
|
static func now() -> Timestamp { Date().timestamp }
|
||||||
Timestamp(Date().timeIntervalSince1970)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create `Timestamp` with `now() - minutes * 60`
|
/// Create `Timestamp` with `now() - minutes * 60`
|
||||||
static func past(minutes: Int) -> Timestamp {
|
static func past(minutes: Int) -> Timestamp { now() - Timestamp(minutes * 60) }
|
||||||
now() - Timestamp(minutes * 60)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Timer {
|
extension Timer {
|
||||||
@@ -39,6 +29,24 @@ extension Timer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - DateFormat
|
||||||
|
|
||||||
|
enum DateFormat {
|
||||||
|
private static let _hms = DateFormatter(withFormat: "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private static let _hm = DateFormatter(withFormat: "yyyy-MM-dd HH:mm")
|
||||||
|
|
||||||
|
/// Format: `yyyy-MM-dd HH:mm:ss`
|
||||||
|
static func seconds(_ date: Date) -> String { _hms.string(from: date) }
|
||||||
|
/// Format: `yyyy-MM-dd HH:mm:ss`
|
||||||
|
static func seconds(_ ts: Timestamp) -> String { _hms.string(from: Date(ts)) }
|
||||||
|
/// Format: `yyyy-MM-dd HH:mm`
|
||||||
|
static func minutes(_ date: Date) -> String { _hm.string(from: date) }
|
||||||
|
/// Format: `yyyy-MM-dd HH:mm`
|
||||||
|
static func minutes(_ ts: Timestamp) -> String { _hm.string(from: Date(ts)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - TimeFormat
|
// MARK: - TimeFormat
|
||||||
|
|
||||||
struct TimeFormat {
|
struct TimeFormat {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class TVCPreviousRecords: UITableViewController, EditActionsRemove {
|
|||||||
let x = dataSource[indexPath.row]
|
let x = dataSource[indexPath.row]
|
||||||
cell.textLabel?.text = x.title ?? x.fallbackTitle
|
cell.textLabel?.text = x.title ?? x.fallbackTitle
|
||||||
cell.textLabel?.textColor = (x.title == nil) ? .systemGray : nil
|
cell.textLabel?.textColor = (x.title == nil) ? .systemGray : nil
|
||||||
cell.detailTextLabel?.text = "at \(x.start.asDateTime()), duration: \(x.durationString ?? "?")"
|
cell.detailTextLabel?.text = "at \(DateFormat.seconds(x.start)), duration: \(x.durationString ?? "?")"
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate
|
|||||||
inputTitle.text = record.title
|
inputTitle.text = record.title
|
||||||
inputNotes.text = record.notes
|
inputNotes.text = record.notes
|
||||||
inputDetails.text = """
|
inputDetails.text = """
|
||||||
Start: \(record.start.asDateTime())
|
Start: \(DateFormat.seconds(record.start))
|
||||||
End: \(record.stop?.asDateTime() ?? "?")
|
End: \(record.stop == nil ? "?" : DateFormat.seconds(record.stop!))
|
||||||
Duration: \(record.durationString ?? "?")
|
Duration: \(record.durationString ?? "?")
|
||||||
"""
|
"""
|
||||||
validateSaveButton()
|
validateSaveButton()
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
|
|||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
prevRecController = (children.first as! UINavigationController)
|
prevRecController = (children.first as! UINavigationController)
|
||||||
prevRecController.delegate = self
|
prevRecController.delegate = self
|
||||||
// Duplicate font attributes but set monospace
|
timeLabel.font = timeLabel.font.monoSpace()
|
||||||
let traits = timeLabel.font.fontDescriptor.object(forKey: .traits) as? [UIFontDescriptor.TraitKey: Any] ?? [:]
|
|
||||||
let weight = traits[.weight] as? CGFloat ?? UIFont.Weight.regular.rawValue
|
|
||||||
timeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: timeLabel.font.pointSize, weight: UIFont.Weight(rawValue: weight))
|
|
||||||
// hide timer if not running
|
// hide timer if not running
|
||||||
updateUI(setRecording: false, animated: false)
|
updateUI(setRecording: false, animated: false)
|
||||||
currentRecording = RecordingsDB.getCurrent()
|
currentRecording = RecordingsDB.getCurrent()
|
||||||
@@ -71,7 +68,7 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
|
|||||||
guard let r = currentRecording, r.stop == nil else {
|
guard let r = currentRecording, r.stop == nil else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordingTimer = Timer.repeating(0.086, call: #selector(timerCallback(_:)), on: self, userInfo: r.start.toDate())
|
recordingTimer = Timer.repeating(0.086, call: #selector(timerCallback(_:)), on: self, userInfo: Date(r.start))
|
||||||
updateUI(setRecording: true, animated: animate)
|
updateUI(setRecording: true, animated: animate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 @@
|
|||||||
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] = []
|
||||||
|
// TODO: respect date reverse sort order
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +22,66 @@ class TVCHostDetails: UITableViewController {
|
|||||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "HostDetailCell")!
|
let cell = tableView.dequeueReusableCell(withIdentifier: "HostDetailCell")!
|
||||||
let src = dataSource[indexPath.row]
|
let src = dataSource[indexPath.row]
|
||||||
cell.textLabel?.text = src.ts.asDateTime()
|
cell.textLabel?.text = DateFormat.seconds(src.ts)
|
||||||
cell.detailTextLabel?.text = (src.total > 1) ? "\(src.total)x" : nil
|
cell.detailTextLabel?.text = (src.total > 1) ? "\(src.total)x" : nil
|
||||||
cell.imageView?.image = (src.blocked > 0 ? UIImage(named: "shield-x") : nil)
|
cell.imageView?.image = (src.blocked > 0 ? UIImage(named: "shield-x") : nil)
|
||||||
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, affects: SyncUpdateEnd) {
|
||||||
|
guard let latest = AppDB?.timesForDomain(fullDomain, range: rows), latest.count > 0 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Assuming they are ordered by ts and in descending order
|
||||||
|
let range: Range<Int>
|
||||||
|
switch affects {
|
||||||
|
case .Earliest:
|
||||||
|
range = dataSource.endIndex..<(dataSource.endIndex + latest.count)
|
||||||
|
dataSource.append(contentsOf: latest)
|
||||||
|
case .Latest:
|
||||||
|
range = dataSource.startIndex..<(dataSource.startIndex + latest.count)
|
||||||
|
dataSource.insert(contentsOf: latest, at: 0)
|
||||||
|
}
|
||||||
|
DispatchQueue.main.sync { tableView.safeInsertRows(range, with: .left) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncUpdate(_ sender: SyncUpdate, remove _: SQLiteRowRange, affects: SyncUpdateEnd) {
|
||||||
|
// Assuming they are ordered by ts and in descending order
|
||||||
|
let range: Range<Int>
|
||||||
|
switch affects {
|
||||||
|
case .Earliest:
|
||||||
|
guard let t = sender.tsEarliest,
|
||||||
|
let i = dataSource.lastIndex(where: { $0.ts >= t }),
|
||||||
|
(i+1) < dataSource.count else { return }
|
||||||
|
range = (i+1)..<dataSource.endIndex
|
||||||
|
dataSource.removeLast(dataSource.count - (i+1))
|
||||||
|
case .Latest:
|
||||||
|
guard let t = sender.tsLatest,
|
||||||
|
let i = dataSource.firstIndex(where: { $0.ts <= t }),
|
||||||
|
i > 0 else { return }
|
||||||
|
range = dataSource.startIndex..<i
|
||||||
|
dataSource.removeFirst(i)
|
||||||
|
}
|
||||||
|
DispatchQueue.main.sync { tableView.safeDeleteRows(range) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -4,48 +4,51 @@ import UIKit
|
|||||||
|
|
||||||
class VCDateFilter: UIViewController, UIGestureRecognizerDelegate {
|
class VCDateFilter: UIViewController, UIGestureRecognizerDelegate {
|
||||||
|
|
||||||
@IBOutlet private var segmentControl: UISegmentedControl!
|
@IBOutlet private var filterBy: UISegmentedControl!
|
||||||
@IBOutlet private var sectionTitle: UILabel!
|
|
||||||
|
|
||||||
// entries no older than
|
// entries no older than
|
||||||
|
@IBOutlet private var durationTitle: UILabel!
|
||||||
@IBOutlet private var durationView: UIView!
|
@IBOutlet private var durationView: UIView!
|
||||||
@IBOutlet private var durationSlider: UISlider!
|
@IBOutlet private var durationSlider: UISlider!
|
||||||
@IBOutlet private var durationLabel: UILabel!
|
@IBOutlet private var durationLabel: UILabel!
|
||||||
private let durationTimes = [0, 1, 20, 60, 360, 720, 1440, 2880, 4320, 10080]
|
private let durationTimes = [0, 1, 20, 60, 360, 720, 1440, 2880, 4320, 10080]
|
||||||
|
|
||||||
// entries within range
|
// entries within range
|
||||||
|
@IBOutlet private var rangeTitle: UILabel!
|
||||||
@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 = Pref.DateFilter.RangeA ?? AppDB?.dnsLogsMinDate() ?? .now()
|
||||||
|
private lazy var tsRangeB: Timestamp = Pref.DateFilter.RangeB ?? .now()
|
||||||
|
|
||||||
|
// order by
|
||||||
|
@IBOutlet private var orderbyType: UISegmentedControl!
|
||||||
|
@IBOutlet private var orderbyAsc: UISegmentedControl!
|
||||||
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
segmentControl.selectedSegmentIndex = (Pref.DateFilter.Kind == .ABRange ? 1 : 0)
|
filterBy.selectedSegmentIndex = (Pref.DateFilter.Kind == .ABRange ? 1 : 0)
|
||||||
didChangeSegment(segmentControl)
|
didChangeFilterBy(filterBy)
|
||||||
segmentControl.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)
|
||||||
|
|
||||||
var a = Timestamp(4).asDateTime() // TODO: load from preferences
|
buttonRangeStart.setTitle(DateFormat.minutes(tsRangeA), for: .normal)
|
||||||
var b = Timestamp.now().asDateTime()
|
buttonRangeEnd.setTitle(DateFormat.minutes(tsRangeB), for: .normal)
|
||||||
a.removeLast(3) // remove seconds
|
|
||||||
b.removeLast(3)
|
orderbyType.selectedSegmentIndex = Pref.DateFilter.OrderBy.rawValue
|
||||||
buttonRangeStart.setTitle(a, for: .normal)
|
orderbyAsc.selectedSegmentIndex = (Pref.DateFilter.OrderAsc ? 0 : 1)
|
||||||
buttonRangeEnd.setTitle(b, for: .normal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction private func didChangeSegment(_ sender: UISegmentedControl) {
|
@IBAction private func didChangeFilterBy(_ sender: UISegmentedControl) {
|
||||||
durationView.isHidden = (sender.selectedSegmentIndex != 0)
|
let firstSelected = (sender.selectedSegmentIndex == 0)
|
||||||
rangeView.isHidden = (sender.selectedSegmentIndex != 1)
|
durationTitle.isHidden = !firstSelected
|
||||||
switch sender.selectedSegmentIndex {
|
durationView.isHidden = !firstSelected
|
||||||
case 0: sectionTitle.text = "Show entries no older than"
|
rangeTitle.isHidden = firstSelected
|
||||||
case 1: sectionTitle.text = "Show entries within range"
|
rangeView.isHidden = firstSelected
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction private func durationSliderChanged(_ sender: UISlider) {
|
@IBAction private func durationSliderChanged(_ sender: UISlider) {
|
||||||
@@ -59,29 +62,61 @@ class VCDateFilter: UIViewController, UIGestureRecognizerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@IBAction private func didTapRangeButton(_ sender: UIButton) {
|
@IBAction private func didTapRangeButton(_ sender: UIButton) {
|
||||||
// TODO: show date picker
|
let flag = (sender == buttonRangeStart)
|
||||||
|
DatePickerAlert(presentIn: self, configure: {
|
||||||
|
$0.setDate(Date(flag ? self.tsRangeA : self.tsRangeB), animated: false)
|
||||||
|
}, onSuccess: {
|
||||||
|
var ts = $0.timestamp
|
||||||
|
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 newKind: DateFilterKind
|
|
||||||
if segmentControl.selectedSegmentIndex == 1 {
|
|
||||||
newKind = .ABRange
|
|
||||||
} else if newXMin > 0 {
|
|
||||||
newKind = .LastXMin
|
|
||||||
} else {
|
|
||||||
newKind = .Off
|
|
||||||
}
|
|
||||||
if Pref.DateFilter.Kind != newKind || Pref.DateFilter.LastXMin != newXMin {
|
|
||||||
Pref.DateFilter.Kind = newKind
|
|
||||||
Pref.DateFilter.LastXMin = newXMin
|
|
||||||
NotifyDateFilterChanged.post()
|
|
||||||
}
|
|
||||||
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