From b17fb3c3540a0a26cabbb8eeb5da3421bc974f88 Mon Sep 17 00:00:00 2001 From: relikd Date: Tue, 2 Jun 2020 21:45:08 +0200 Subject: [PATCH] Refactoring I. - Revamp whole DB to Display flow - Filter Pipeline, arbitrary filtering and sorting - Binary tree arrays for faster lookup & manipulation - DB: introducing custom functions - DB scheme: split req into heap & cache - cache written by GlassVPN only - heap written by Main App only - Introducing DB separation: DBCore, DBCommon, DBAppOnly - Introducing DB data sources: TestDataSource, GroupedDomainDataSource, RecordingsDB, DomainFilter - Background sync: Move entries from cache to heap and notify all observers - GlassVPN: Binary tree filter lookup - GlassVPN: Reusing prepared statement --- AppCheck.xcodeproj/project.pbxproj | 78 ++- GlassVPN/PacketTunnelProvider.swift | 158 +++--- main/AppDelegate.swift | 15 +- main/Base.lproj/Main.storyboard | 100 ++-- main/Common Classes/EditableRows.swift | 147 ------ main/Common Classes/FilterPipeline.swift | 416 ++++++++++++++++ main/DB/DBAppOnly.swift | 386 ++++++++++++++ main/DB/DBCommon.swift | 88 ++++ main/DB/DBCore.swift | 244 +++++++++ main/{Extensions => DB}/DBExtensions.swift | 32 +- main/DB/DBWrapper.swift | 375 -------------- main/DB/SQDB.swift | 469 ------------------ main/Data Source/DomainFilter.swift | 51 ++ .../Data Source/GroupedDomainDataSource.swift | 250 ++++++++++ main/Data Source/RecordingsDB.swift | 43 ++ main/Data Source/SyncUpdate.swift | 44 ++ main/Data Source/TestDataSource.swift | 41 ++ main/Extensions/AlertSheet.swift | 4 +- main/Extensions/Array.swift | 77 +++ main/Extensions/Generic.swift | 96 ---- main/Extensions/Notifications.swift | 6 +- main/Extensions/SharedState.swift | 2 +- main/Extensions/String.swift | 51 ++ main/Extensions/TableView.swift | 144 ++---- main/Extensions/Time.swift | 74 +++ main/Extensions/URL.swift | 8 +- main/Recordings/TVCPreviousRecords.swift | 12 +- main/Recordings/TVCRecordingDetails.swift | 20 +- main/Recordings/VCEditRecording.swift | 6 +- main/Recordings/VCRecordings.swift | 6 +- main/Requests/TVCDomains.swift | 93 ++-- main/Requests/TVCHostDetails.swift | 47 +- main/Requests/TVCHosts.swift | 36 +- main/Requests/VCDateFilter.swift | 1 - main/Settings/TVCFilter.swift | 43 +- main/Settings/TVCSettings.swift | 33 +- 36 files changed, 2214 insertions(+), 1482 deletions(-) delete mode 100644 main/Common Classes/EditableRows.swift create mode 100644 main/Common Classes/FilterPipeline.swift create mode 100644 main/DB/DBAppOnly.swift create mode 100644 main/DB/DBCommon.swift create mode 100644 main/DB/DBCore.swift rename main/{Extensions => DB}/DBExtensions.swift (55%) delete mode 100644 main/DB/DBWrapper.swift delete mode 100644 main/DB/SQDB.swift create mode 100644 main/Data Source/DomainFilter.swift create mode 100644 main/Data Source/GroupedDomainDataSource.swift create mode 100644 main/Data Source/RecordingsDB.swift create mode 100644 main/Data Source/SyncUpdate.swift create mode 100644 main/Data Source/TestDataSource.swift create mode 100644 main/Extensions/Array.swift create mode 100644 main/Extensions/String.swift create mode 100644 main/Extensions/Time.swift diff --git a/AppCheck.xcodeproj/project.pbxproj b/AppCheck.xcodeproj/project.pbxproj index 7f881f4..43cd818 100644 --- a/AppCheck.xcodeproj/project.pbxproj +++ b/AppCheck.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 540C6457240D929300E948F9 /* EditableRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540C6456240D929300E948F9 /* EditableRows.swift */; }; 540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E677F242D2CF100871BBE /* VCRecordings.swift */; }; 540E67822433483D00871BBE /* VCEditRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E67812433483D00871BBE /* VCEditRecording.swift */; }; 540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */; }; @@ -21,22 +20,23 @@ 542E2A9A24051556001462DC /* TVCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A9924051556001462DC /* TVCSettings.swift */; }; 543CDB2023EEE61900B7F323 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543CDB1F23EEE61900B7F323 /* PacketTunnelProvider.swift */; }; 543CDB2523EEE61900B7F323 /* GlassVPN.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 543CDB1D23EEE61900B7F323 /* GlassVPN.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 54448A2E2486464F00771C96 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54448A2D2486464F00771C96 /* Array.swift */; }; + 54448A30248647D900771C96 /* Time.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54448A2F248647D900771C96 /* Time.swift */; }; 544C95262407B1C700AB89D0 /* SharedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544C95252407B1C700AB89D0 /* SharedState.swift */; }; 5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */; }; 545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDCE243E6267003B6544 /* TutorialSheet.swift */; }; 545DDDD124436983003B6544 /* QuickUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDD024436983003B6544 /* QuickUI.swift */; }; 545DDDD424466D37003B6544 /* AutoLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDD324466D37003B6544 /* AutoLayout.swift */; }; - 546063E523FEFAFE008F505A /* SQDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7562223D7B2DC008F0C41 /* SQDB.swift */; }; + 546063E523FEFAFE008F505A /* DBCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7562223D7B2DC008F0C41 /* DBCore.swift */; }; 54751E512423955100168273 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54751E502423955000168273 /* URL.swift */; }; 54751E522423955100168273 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54751E502423955000168273 /* URL.swift */; }; - 54953E3323DC752E0054345C /* SQDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7562223D7B2DC008F0C41 /* SQDB.swift */; }; + 54953E3323DC752E0054345C /* DBCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7562223D7B2DC008F0C41 /* DBCore.swift */; }; 54953E5F23DEBE840054345C /* TVCDomains.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E5E23DEBE840054345C /* TVCDomains.swift */; }; 54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6023E0D69A0054345C /* TVCHosts.swift */; }; 54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6E23E44CD00054345C /* TVCHostDetails.swift */; }; 54953E7123E473F10054345C /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 54953E7023E473F10054345C /* Settings.bundle */; }; 54B34594240E6343004C53CC /* TVCFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B34593240E6343004C53CC /* TVCFilter.swift */; }; 54B34596240F0513004C53CC /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B34595240F0513004C53CC /* TableView.swift */; }; - 54B345992414F491004C53CC /* DBWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345982414F491004C53CC /* DBWrapper.swift */; }; 54B345A6241BB982004C53CC /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345A5241BB982004C53CC /* Notifications.swift */; }; 54B345A9241BBA0B004C53CC /* Generic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345A8241BBA0B004C53CC /* Generic.swift */; }; 54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345AA241BBA5B004C53CC /* AlertSheet.swift */; }; @@ -124,6 +124,16 @@ 54CA02BE2426D4F3003A5E04 /* DDLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02BD2426D4F3003A5E04 /* DDLog.swift */; }; 54CA02C32426DCCD003A5E04 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02BF2426DCCC003A5E04 /* GCDAsyncSocket.m */; }; 54CA02C42426DCCD003A5E04 /* GCDAsyncUdpSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 54CA02C02426DCCD003A5E04 /* GCDAsyncUdpSocket.m */; }; + 54D8B97A246C9F2000EB2414 /* FilterPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B979246C9F2000EB2414 /* FilterPipeline.swift */; }; + 54D8B97C2471A7E000EB2414 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B97B2471A7E000EB2414 /* String.swift */; }; + 54D8B97E2471B88900EB2414 /* DBCommon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B97D2471B88900EB2414 /* DBCommon.swift */; }; + 54D8B97F2471B89100EB2414 /* DBCommon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B97D2471B88900EB2414 /* DBCommon.swift */; }; + 54D8B9832471BD8100EB2414 /* DBAppOnly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B9822471BD8100EB2414 /* DBAppOnly.swift */; }; + 54D8B98624796E9900EB2414 /* GroupedDomainDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D8B98524796E9800EB2414 /* GroupedDomainDataSource.swift */; }; + 54E540F2247C423200F7C34A /* DomainFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F1247C423200F7C34A /* DomainFilter.swift */; }; + 54E540F4247D3F2600F7C34A /* TestDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F3247D3F2600F7C34A /* TestDataSource.swift */; }; + 54E540F8247DB90F00F7C34A /* RecordingsDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F7247DB90F00F7C34A /* RecordingsDB.swift */; }; + 54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F92482414800F7C34A /* SyncUpdate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -151,7 +161,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 540C6456240D929300E948F9 /* EditableRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableRows.swift; sourceTree = ""; }; 540E677F242D2CF100871BBE /* VCRecordings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCRecordings.swift; sourceTree = ""; }; 540E67812433483D00871BBE /* VCEditRecording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCEditRecording.swift; sourceTree = ""; }; 540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCPreviousRecords.swift; sourceTree = ""; }; @@ -169,6 +178,8 @@ 543CDB1F23EEE61900B7F323 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; 543CDB2123EEE61900B7F323 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 543CDB2223EEE61900B7F323 /* GlassVPN.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GlassVPN.entitlements; sourceTree = ""; }; + 54448A2D2486464F00771C96 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; + 54448A2F248647D900771C96 /* Time.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Time.swift; sourceTree = ""; }; 544C95252407B1C700AB89D0 /* SharedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedState.swift; sourceTree = ""; }; 5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCRecordingDetails.swift; sourceTree = ""; }; 545DDDCE243E6267003B6544 /* TutorialSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialSheet.swift; sourceTree = ""; }; @@ -182,13 +193,12 @@ 54953E7023E473F10054345C /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; 54B34593240E6343004C53CC /* TVCFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCFilter.swift; sourceTree = ""; }; 54B34595240F0513004C53CC /* TableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableView.swift; sourceTree = ""; }; - 54B345982414F491004C53CC /* DBWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWrapper.swift; sourceTree = ""; }; 54B345A5241BB982004C53CC /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; 54B345A8241BBA0B004C53CC /* Generic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generic.swift; sourceTree = ""; }; 54B345AA241BBA5B004C53CC /* AlertSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertSheet.swift; sourceTree = ""; }; 54B345AC241BBB00004C53CC /* DBExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBExtensions.swift; sourceTree = ""; }; 54B345AF242264F8004C53CC /* third-level.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "third-level.txt"; sourceTree = ""; }; - 54B7562223D7B2DC008F0C41 /* SQDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQDB.swift; sourceTree = ""; }; + 54B7562223D7B2DC008F0C41 /* DBCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBCore.swift; sourceTree = ""; }; 54C056DA23E9E36E00214A3F /* AppInfoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoType.swift; sourceTree = ""; }; 54C056DC23E9EEF700214A3F /* BundleIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIcon.swift; sourceTree = ""; }; 54CA00D62426A803003A5E04 /* CocoaAsyncSocket.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CocoaAsyncSocket.h; sourceTree = ""; }; @@ -274,6 +284,15 @@ 54CA02C02426DCCD003A5E04 /* GCDAsyncUdpSocket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GCDAsyncUdpSocket.m; sourceTree = ""; }; 54CA02C12426DCCD003A5E04 /* GCDAsyncSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCDAsyncSocket.h; sourceTree = ""; }; 54CA02C22426DCCD003A5E04 /* GCDAsyncUdpSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCDAsyncUdpSocket.h; sourceTree = ""; }; + 54D8B979246C9F2000EB2414 /* FilterPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterPipeline.swift; sourceTree = ""; }; + 54D8B97B2471A7E000EB2414 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; + 54D8B97D2471B88900EB2414 /* DBCommon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBCommon.swift; sourceTree = ""; }; + 54D8B9822471BD8100EB2414 /* DBAppOnly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBAppOnly.swift; sourceTree = ""; }; + 54D8B98524796E9800EB2414 /* GroupedDomainDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedDomainDataSource.swift; sourceTree = ""; }; + 54E540F1247C423200F7C34A /* DomainFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainFilter.swift; sourceTree = ""; }; + 54E540F3247D3F2600F7C34A /* TestDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDataSource.swift; sourceTree = ""; }; + 54E540F7247DB90F00F7C34A /* RecordingsDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingsDB.swift; sourceTree = ""; }; + 54E540F92482414800F7C34A /* SyncUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncUpdate.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -348,6 +367,7 @@ isa = PBXGroup; children = ( 54B3459A2415651C004C53CC /* DB */, + 54E540F0247C386500F7C34A /* Data Source */, 54B345A4241BB975004C53CC /* Extensions */, 545DDDD224436A03003B6544 /* Common Classes */, 548B1F9423D338EC005B047C /* main.entitlements */, @@ -394,7 +414,7 @@ children = ( 545DDDD024436983003B6544 /* QuickUI.swift */, 545DDDCE243E6267003B6544 /* TutorialSheet.swift */, - 540C6456240D929300E948F9 /* EditableRows.swift */, + 54D8B979246C9F2000EB2414 /* FilterPipeline.swift */, ); path = "Common Classes"; sourceTree = ""; @@ -402,8 +422,10 @@ 54B3459A2415651C004C53CC /* DB */ = { isa = PBXGroup; children = ( - 54B7562223D7B2DC008F0C41 /* SQDB.swift */, - 54B345982414F491004C53CC /* DBWrapper.swift */, + 54B7562223D7B2DC008F0C41 /* DBCore.swift */, + 54D8B97D2471B88900EB2414 /* DBCommon.swift */, + 54D8B9822471BD8100EB2414 /* DBAppOnly.swift */, + 54B345AC241BBB00004C53CC /* DBExtensions.swift */, ); path = DB; sourceTree = ""; @@ -414,10 +436,12 @@ 544C95252407B1C700AB89D0 /* SharedState.swift */, 54B345A8241BBA0B004C53CC /* Generic.swift */, 54B345A5241BB982004C53CC /* Notifications.swift */, - 54B345AC241BBB00004C53CC /* DBExtensions.swift */, 54B345AA241BBA5B004C53CC /* AlertSheet.swift */, - 54B34595240F0513004C53CC /* TableView.swift */, + 54448A2F248647D900771C96 /* Time.swift */, 54751E502423955000168273 /* URL.swift */, + 54448A2D2486464F00771C96 /* Array.swift */, + 54D8B97B2471A7E000EB2414 /* String.swift */, + 54B34595240F0513004C53CC /* TableView.swift */, 545DDDD324466D37003B6544 /* AutoLayout.swift */, ); path = Extensions; @@ -655,6 +679,18 @@ path = ProxySocket; sourceTree = ""; }; + 54E540F0247C386500F7C34A /* Data Source */ = { + isa = PBXGroup; + children = ( + 54E540F3247D3F2600F7C34A /* TestDataSource.swift */, + 54E540F92482414800F7C34A /* SyncUpdate.swift */, + 54D8B98524796E9800EB2414 /* GroupedDomainDataSource.swift */, + 54E540F1247C423200F7C34A /* DomainFilter.swift */, + 54E540F7247DB90F00F7C34A /* RecordingsDB.swift */, + ); + path = "Data Source"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -772,33 +808,42 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 54E540F8247DB90F00F7C34A /* RecordingsDB.swift in Sources */, + 54E540F4247D3F2600F7C34A /* TestDataSource.swift in Sources */, 545DDDD424466D37003B6544 /* AutoLayout.swift in Sources */, 54B345AD241BBB00004C53CC /* DBExtensions.swift in Sources */, + 54E540F2247C423200F7C34A /* DomainFilter.swift in Sources */, + 54D8B97E2471B88900EB2414 /* DBCommon.swift in Sources */, + 54D8B9832471BD8100EB2414 /* DBAppOnly.swift in Sources */, 540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */, 54B345A6241BB982004C53CC /* Notifications.swift in Sources */, + 54448A2E2486464F00771C96 /* Array.swift in Sources */, 54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */, 544C95262407B1C700AB89D0 /* SharedState.swift in Sources */, 545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */, 54B345A9241BBA0B004C53CC /* Generic.swift in Sources */, 54B34596240F0513004C53CC /* TableView.swift in Sources */, 540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */, - 54953E3323DC752E0054345C /* SQDB.swift in Sources */, + 54953E3323DC752E0054345C /* DBCore.swift in Sources */, + 54448A30248647D900771C96 /* Time.swift in Sources */, 54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */, - 540C6457240D929300E948F9 /* EditableRows.swift in Sources */, 54751E512423955100168273 /* URL.swift in Sources */, 542E2A9A24051556001462DC /* TVCSettings.swift in Sources */, 54953E5F23DEBE840054345C /* TVCDomains.swift in Sources */, 54C056DB23E9E36E00214A3F /* AppInfoType.swift in Sources */, + 54D8B98624796E9900EB2414 /* GroupedDomainDataSource.swift in Sources */, 54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */, + 54D8B97C2471A7E000EB2414 /* String.swift in Sources */, 54C056DD23E9EEF700214A3F /* BundleIcon.swift in Sources */, 542E2A982404973F001462DC /* TBCMain.swift in Sources */, 5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */, 5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */, 545DDDD124436983003B6544 /* QuickUI.swift in Sources */, 541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */, - 54B345992414F491004C53CC /* DBWrapper.swift in Sources */, 540E67822433483D00871BBE /* VCEditRecording.swift in Sources */, + 54D8B97A246C9F2000EB2414 /* FilterPipeline.swift in Sources */, 54B34594240E6343004C53CC /* TVCFilter.swift in Sources */, + 54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -850,6 +895,7 @@ 54CA02A62426B2FD003A5E04 /* AdapterSocket.swift in Sources */, 54CA02742426B2FD003A5E04 /* IPMask.swift in Sources */, 54CA02BB2426B2FD003A5E04 /* SOCKS5ProxySocket.swift in Sources */, + 54D8B97F2471B89100EB2414 /* DBCommon.swift in Sources */, 54CA02A42426B2FD003A5E04 /* SecureHTTPAdapter.swift in Sources */, 54CA02942426B2FD003A5E04 /* DNSResolver.swift in Sources */, 54CA025F2426B2FD003A5E04 /* ProxyServer.swift in Sources */, @@ -881,7 +927,7 @@ 54CA02972426B2FD003A5E04 /* IPPacket.swift in Sources */, 54CA026A2426B2FD003A5E04 /* RawSocketFactory.swift in Sources */, 54CA02A02426B2FD003A5E04 /* TunnelEvent.swift in Sources */, - 546063E523FEFAFE008F505A /* SQDB.swift in Sources */, + 546063E523FEFAFE008F505A /* DBCore.swift in Sources */, 54CA02872426B2FD003A5E04 /* IPRangeListRule.swift in Sources */, 54CA02922426B2FD003A5E04 /* DNSSession.swift in Sources */, 54CA026D2426B2FD003A5E04 /* Opt.swift in Sources */, diff --git a/GlassVPN/PacketTunnelProvider.swift b/GlassVPN/PacketTunnelProvider.swift index 27c6519..b584ee8 100644 --- a/GlassVPN/PacketTunnelProvider.swift +++ b/GlassVPN/PacketTunnelProvider.swift @@ -1,13 +1,47 @@ import NetworkExtension -fileprivate var db: SQLiteDatabase? -fileprivate var domainFilters: [String : FilterOptions] = [:] +fileprivate var db: SQLiteDatabase! +fileprivate var pStmt: OpaquePointer! +fileprivate var filterDomains: [String]! +fileprivate var filterOptions: [(block: Bool, ignore: Bool)]! + + +// MARK: Backward DNS Binary Tree Lookup + +fileprivate func reloadDomainFilter() { + let tmp = db.loadFilters()?.map({ + (String($0.reversed()), $1) + }).sorted(by: { $0.0 < $1.0 }) ?? [] + filterDomains = tmp.map { $0.0 } + filterOptions = tmp.map { ($1.contains(.blocked), $1.contains(.ignored)) } +} + +fileprivate func filterIndex(for domain: String) -> Int { + let reverseDomain = String(domain.reversed()) + var lo = 0, hi = filterDomains.count - 1 + while lo <= hi { + let mid = (lo + hi)/2 + if filterDomains[mid] < reverseDomain { + lo = mid + 1 + } else if reverseDomain < filterDomains[mid] { + hi = mid - 1 + } else { + return mid + } + } + if lo > 0, reverseDomain.hasPrefix(filterDomains[lo - 1] + ".") { + return lo - 1 + } + return -1 +} + // MARK: ObserverFactory class LDObserverFactory: ObserverFactory { - + override func getObserverForProxySocket(_ socket: ProxySocket) -> Observer? { + // TODO: replace NEKit with custom proxy with minimal footprint return LDProxySocketObserver() } @@ -15,66 +49,64 @@ class LDObserverFactory: ObserverFactory { override func signal(_ event: ProxySocketEvent) { switch event { case .receivedRequest(let session, let socket): - DDLogDebug("DNS: \(session.host)") - let match = domainFilters.first { session.host == $0.key || session.host.hasSuffix("." + $0.key) } - let block = match?.value.contains(.blocked) ?? false - let ignore = match?.value.contains(.ignored) ?? false - if !ignore { try? db?.insertDNSQuery(session.host, blocked: block) } - else { DDLogDebug("ignored") } - if block { DDLogDebug("blocked"); socket.forceDisconnect() } + let i = filterIndex(for: session.host) + if i >= 0 { + let (block, ignore) = filterOptions[i] + if !ignore { try? db.logWrite(pStmt, session.host, blocked: block) } + if block { socket.forceDisconnect() } + } else { + // TODO: disable filter during recordings + try? db.logWrite(pStmt, session.host) + } default: break } } - } } + // MARK: NEPacketTunnelProvider class PacketTunnelProvider: NEPacketTunnelProvider { let proxyServerPort: UInt16 = 9090 - let proxyServerAddress = "127.0.0.1" + let proxyServerAddress = "127.0.0.1" var proxyServer: GCDHTTPProxyServer! - func reloadDomainFilter() { - domainFilters = db?.loadFilters() ?? [:] - } - - override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { - DDLogVerbose("startTunnel") + override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { do { db = try SQLiteDatabase.open() - db!.initScheme() + db.initCommonScheme() + pStmt = try db.logWritePrepare() } catch { completionHandler(error) return } - if proxyServer != nil { - proxyServer.stop() - } - proxyServer = nil - reloadDomainFilter() + if proxyServer != nil { + proxyServer.stop() + } + proxyServer = nil + // Create proxy - let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: proxyServerAddress) - settings.mtu = NSNumber(value: 1500) - - let proxySettings = NEProxySettings() - proxySettings.httpEnabled = true; - proxySettings.httpServer = NEProxyServer(address: proxyServerAddress, port: Int(proxyServerPort)) - proxySettings.httpsEnabled = true; - proxySettings.httpsServer = NEProxyServer(address: proxyServerAddress, port: Int(proxyServerPort)) - proxySettings.excludeSimpleHostnames = false; - proxySettings.exceptionList = [] - proxySettings.matchDomains = [""] - - settings.dnsSettings = NEDNSSettings(servers: ["127.0.0.1"]) - settings.proxySettings = proxySettings; + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: proxyServerAddress) + settings.mtu = NSNumber(value: 1500) + + let proxySettings = NEProxySettings() + proxySettings.httpEnabled = true; + proxySettings.httpServer = NEProxyServer(address: proxyServerAddress, port: Int(proxyServerPort)) + proxySettings.httpsEnabled = true; + proxySettings.httpsServer = NEProxyServer(address: proxyServerAddress, port: Int(proxyServerPort)) + proxySettings.excludeSimpleHostnames = false; + proxySettings.exceptionList = [] + proxySettings.matchDomains = [""] + + settings.dnsSettings = NEDNSSettings(servers: ["127.0.0.1"]) + settings.proxySettings = proxySettings; RawSocketFactory.TunnelProvider = self - ObserverFactory.currentFactory = LDObserverFactory() + ObserverFactory.currentFactory = LDObserverFactory() self.setTunnelNetworkSettings(settings) { error in guard error == nil else { @@ -82,36 +114,38 @@ class PacketTunnelProvider: NEPacketTunnelProvider { completionHandler(error) return } - DDLogVerbose("setTunnelNetworkSettings success \(self.packetFlow)") completionHandler(nil) self.proxyServer = GCDHTTPProxyServer(address: IPAddress(fromString: self.proxyServerAddress), port: Port(port: self.proxyServerPort)) - do { - try self.proxyServer.start() - completionHandler(nil) - } - catch let proxyError { - DDLogError("Error starting proxy server \(proxyError)") - completionHandler(proxyError) - } + do { + try self.proxyServer.start() + completionHandler(nil) + } + catch let proxyError { + DDLogError("Error starting proxy server \(proxyError)") + completionHandler(proxyError) + } } - } + } - override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { DDLogVerbose("stopTunnel with reason: \(reason)") - db = nil DNSServer.currentServer = nil - RawSocketFactory.TunnelProvider = nil - ObserverFactory.currentFactory = nil - proxyServer.stop() - proxyServer = nil - completionHandler() - exit(EXIT_SUCCESS) - } - - override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { - DDLogVerbose("handleAppMessage") + RawSocketFactory.TunnelProvider = nil + ObserverFactory.currentFactory = nil + proxyServer.stop() + proxyServer = nil + db.prepared(finalize: pStmt) + pStmt = nil + db = nil + filterDomains = nil + filterOptions = nil + completionHandler() + exit(EXIT_SUCCESS) + } + + override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { reloadDomainFilter() - } + } } + diff --git a/main/AppDelegate.swift b/main/AppDelegate.swift index b904c17..6cdfa00 100644 --- a/main/AppDelegate.swift +++ b/main/AppDelegate.swift @@ -14,16 +14,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UserDefaults.standard.set(false, forKey: "kill_db") SQLiteDatabase.destroyDatabase() } - try? SQLiteDatabase.open().initScheme() + if let db = AppDB { + db.initCommonScheme() + db.initAppOnlyScheme() + } - DBWrp.initContentOfDB() + #if IOS_SIMULATOR + TestDataSource.load() + #endif loadVPN { mgr in self.managerVPN = mgr self.postVPNState() } NSNotification.Name.NEVPNStatusDidChange.observe(call: #selector(vpnStatusChanged(_:)), on: self) - NotifyDNSFilterChanged.observe(call: #selector(filterDidChange), on: self) + NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self) + + sync.start() return true } @@ -31,7 +38,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { postRawVPNState((notification.object as? NETunnelProviderSession)?.status ?? .invalid) } - @objc private func filterDidChange() { + @objc private func didChangeDomainFilter() { // Notify VPN extension about changes if let session = self.managerVPN?.connection as? NETunnelProviderSession, session.status == .connected { diff --git a/main/Base.lproj/Main.storyboard b/main/Base.lproj/Main.storyboard index 104078e..f228880 100644 --- a/main/Base.lproj/Main.storyboard +++ b/main/Base.lproj/Main.storyboard @@ -852,10 +852,60 @@ Duration: 60:00 - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -876,52 +926,6 @@ Duration: 60:00 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/main/Common Classes/EditableRows.swift b/main/Common Classes/EditableRows.swift deleted file mode 100644 index 8872e25..0000000 --- a/main/Common Classes/EditableRows.swift +++ /dev/null @@ -1,147 +0,0 @@ -import UIKit - -public enum RowAction { - case ignore, block, delete -// static let all: [RowAction] = [.ignore, .block, .delete] -} - -// MARK: - Generic - -protocol EditableRows { - func editableRowUserInfo(_ index: IndexPath) -> Any? - func editableRowActions(_ index: IndexPath) -> [(RowAction, String)] - func editableRowActionColor(_ index: IndexPath, _ action: RowAction) -> UIColor? - @discardableResult func editableRowCallback(_ atIndexPath: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool -} - -extension EditableRows where Self: UITableViewController { - fileprivate func getRowActionsIOS9(_ index: IndexPath) -> [UITableViewRowAction]? { - let userInfo = editableRowUserInfo(index) - return editableRowActions(index).compactMap { a,t in - let x = UITableViewRowAction(style: a == .delete ? .destructive : .normal, title: t) { self.editableRowCallback($1, a, userInfo) } - if let color = editableRowActionColor(index, a) { - x.backgroundColor = color - } - return x - } - } - @available(iOS 11.0, *) - fileprivate func getRowActionsIOS11(_ index: IndexPath) -> UISwipeActionsConfiguration? { - let userInfo = editableRowUserInfo(index) - return UISwipeActionsConfiguration(actions: editableRowActions(index).compactMap { a,t in - let x = UIContextualAction(style: a == .delete ? .destructive : .normal, title: t) { $2(self.editableRowCallback(index, a, userInfo)) } - x.backgroundColor = editableRowActionColor(index, a) - return x - }) - } - func editableRowUserInfo(_ index: IndexPath) -> Any? { nil } -} - - - -// MARK: - Edit Ignore-Block-Delete - -protocol EditActionsIgnoreBlockDelete : EditableRows { - var dataSource: [GroupedDomain] { get set } -} -extension EditActionsIgnoreBlockDelete where Self: UITableViewController { - func editableRowActions(_ index: IndexPath) -> [(RowAction, String)] { - let x = dataSource[index.row] - if x.domain.starts(with: "#") { - return [(.delete, "Delete")] - } - let b = x.options?.contains(.blocked) ?? false - let i = x.options?.contains(.ignored) ?? false - return [(.delete, "Delete"), (.block, b ? "Unblock" : "Block"), (.ignore, i ? "Unignore" : "Ignore")] - } - - func editableRowActionColor(_: IndexPath, _ action: RowAction) -> UIColor? { - action == .block ? .systemOrange : nil - } - - func editableRowUserInfo(_ index: IndexPath) -> Any? { dataSource[index.row] } - - func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool { - let entry = userInfo as! GroupedDomain - switch action { - case .ignore: showFilterSheet(entry, .ignored) - case .block: showFilterSheet(entry, .blocked) - case .delete: - AlertDeleteLogs(entry.domain, latest: entry.lastModified) { - DBWrp.deleteHistory(domain: entry.domain, since: $0) - }.presentIn(self) - } - return true - } - - private func showFilterSheet(_ entry: GroupedDomain, _ filter: FilterOptions) { - if entry.options?.contains(filter) ?? false { - DBWrp.updateFilter(entry.domain, remove: filter) - } else { - // TODO: alert sheet - DBWrp.updateFilter(entry.domain, add: filter) - } - } -} - -// MARK: Extensions -extension TVCDomains : EditActionsIgnoreBlockDelete { - override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - getRowActionsIOS9(indexPath) - } - @available(iOS 11.0, *) - override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - getRowActionsIOS11(indexPath) - } -} - -extension TVCHosts : EditActionsIgnoreBlockDelete { - override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - getRowActionsIOS9(indexPath) - } - @available(iOS 11.0, *) - override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - getRowActionsIOS11(indexPath) - } -} - - - -// MARK: - Edit Remove - -protocol EditActionsRemove : EditableRows {} -extension EditActionsRemove where Self: UITableViewController { - func editableRowActions(_: IndexPath) -> [(RowAction, String)] { [(.delete, "Remove")] } - func editableRowActionColor(_: IndexPath, _: RowAction) -> UIColor? { nil } -} - -// MARK: Extensions -extension TVCFilter : EditableRows { - override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - getRowActionsIOS9(indexPath) - } - @available(iOS 11.0, *) - override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - getRowActionsIOS11(indexPath) - } -} - -extension TVCPreviousRecords : EditableRows { - override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - getRowActionsIOS9(indexPath) - } - @available(iOS 11.0, *) - override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - getRowActionsIOS11(indexPath) - } -} - -extension TVCRecordingDetails : EditableRows { - override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - getRowActionsIOS9(indexPath) - } - @available(iOS 11.0, *) - override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - getRowActionsIOS11(indexPath) - } -} diff --git a/main/Common Classes/FilterPipeline.swift b/main/Common Classes/FilterPipeline.swift new file mode 100644 index 0000000..dbc256c --- /dev/null +++ b/main/Common Classes/FilterPipeline.swift @@ -0,0 +1,416 @@ +import UIKit + +protocol FilterPipelineDelegate: UITableViewController { + /// Currently only called when a row is moved and the `tableView` is frontmost. + func rowNeedsUpdate(_ row: Int) +} + +// MARK: FilterPipeline + +class FilterPipeline { + typealias DataSourceQuery = () -> [T] + + private var sourceQuery: DataSourceQuery! + private(set) fileprivate var dataSource: [T] = [] + + private var pipeline: [PipelineFilter] = [] + private var display: PipelineSorting! + private(set) weak var delegate: FilterPipelineDelegate? + + required init(withDelegate: FilterPipelineDelegate) { + delegate = withDelegate + } + + /// Set a new `dataSource` query and immediately apply all filters and sorting. + /// - Note: You must call `reload(fromSource:)` manually! + /// - Note: Always use `[unowned self]` + func setDataSource(query: @escaping DataSourceQuery) { + sourceQuery = query + } + + /// - Returns: Number of elements in `projection` + @inline(__always) func displayObjectCount() -> Int { display.projection.count } + + /// Dereference `projection` index to `dataSource` index + /// - Complexity: O(1) + @inline(__always) func displayObject(at index: Int) -> T { dataSource[display.projection[index]] } + + /// Search and return first element in `dataSource` that matches `predicate`. + /// - Returns: Index in `dataSource` and found object or `nil` if no matching item found. + /// - Complexity: O(*n*), where *n* is the length of the `dataSource`. + func dataSourceGet(where predicate: ((T) -> Bool)) -> (index: Int, object: T)? { + guard let i = dataSource.firstIndex(where: predicate) else { + return nil + } + return (i, dataSource[i]) + } + + /// Search and return list of `dataSource` elements that match the given `predicate`. + /// - Returns: Sorted list of indices and objects in `dataSource`. + /// - Complexity: O(*m* + *n*), where *n* is the length of the `dataSource` and *m* is the number of matches. +// func dataSourceAll(where predicate: ((T) -> Bool)) -> [(index: Int, object: T)] { +// dataSource.enumerated().compactMap { predicate($1) ? ($0, $1) : nil } +// } + + /// Re-query data source and re-built filter and display sorting order. + /// - Parameter fromSource: If `false` only re-built filter and sort order + func reload(fromSource: Bool, whenDone: @escaping () -> Void) { + DispatchQueue.global().async { + if fromSource { + self.dataSource = self.sourceQuery() + } + self.resetFilters() + DispatchQueue.main.sync { + self.delegate?.tableView.reloadData() + whenDone() + } + } + } + + /// Returns the index set of either the last filter layer, or `dataSource` if no filter is set yet. + fileprivate func lastFilterLayerIndices() -> [Int] { + pipeline.last?.selection ?? dataSource.indices.arr() + } + + /// Get pipeline index of filter with given identifier + private func indexOfFilter(_ identifier: String) -> Int? { + pipeline.firstIndex(where: {$0.id == identifier}) + } + + + // MARK: manage pipeline + + /// 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. + /// - Parameters: + /// - identifier: Use this id to find the filter again. For reload and remove operations. + /// - otherId: If `nil` or non-existent the new filter will be appended at the end. + /// - predicate: Return `true` if you want to keep the element. + func addFilter(_ identifier: String, before otherId: String? = nil, _ predicate: @escaping PipelineFilter.Predicate) { + let newFilter = PipelineFilter(identifier, predicate) + if let other = otherId, let i = indexOfFilter(other) { + pipeline.insert(newFilter, at: i) + resetFilters(startingAt: i) + } else { + newFilter.reset(to: dataSource, previous: pipeline.last) + pipeline.append(newFilter) + display?.apply(moreRestrictive: newFilter) + } + } + + /// Find and remove filter with given identifier. Will automatically update remaining filters and display sorting. + func removeFilter(withId ident: String) { + if let i = indexOfFilter(ident) { + pipeline.remove(at: i) + if i == pipeline.count { + // only if we don't reset other layers we can assure `toLessRestrictive` + display?.reset(toLessRestrictive: pipeline.last) + } else { + resetFilters(startingAt: i) + } + } + } + + /// Start filter evaluation on all entries from previous filter. + func reloadFilter(withId ident: String) { + if let i = indexOfFilter(ident) { + resetFilters(startingAt: i) + } + } + + /// Remove last `k` filters from the filter pipeline. Thus showing more entries from previous layers. + func popLastFilter(k: Int = 1) { + guard k > 0, k <= pipeline.count else { return } + pipeline.removeLast(k) + display?.reset(toLessRestrictive: pipeline.last) + } + + /// Sets the sort and display order. You should set the `delegate` to automatically update your `tableView`. + /// - Parameter predicate: Return `true` if first element should be sorted before second element. + func setSorting(_ predicate: @escaping PipelineSorting.Predicate) { + display = .init(predicate, pipe: self) + } + + /// Re-built filter and display sorting order. + /// - Parameter index: Must be: `index <= pipeline.count` + private func resetFilters(startingAt index: Int = 0) { + for i in index..0) ? pipeline[i-1] : nil) + } + // Reset is NOT less-restrictive because filters are dynamic + // Calling reset on a filter twice may yield different results + // E.g. if filter uses variables outside of scope (current time, search term) + display?.reset() + } + + /// Push object through filter pipeline to check whether it survives all filters. + /// - Parameter index: The index of the object in the original `dataSource` + /// - Returns: `changed` is `true` if element persists or should be removed with this update. + /// `display` indicates whther element should be shown (`true`) or hidden (`false`). + /// - Complexity: O(*m* log *n*), where *m* is the number of filters and *n* the number of elements in each filter. + private func processPipeline(with obj: T, at index: Int) -> (changed: Bool, display: Bool) { + var keepGoing = true + for filter in pipeline { + let lastIndex: Int? + if keepGoing { + (keepGoing, lastIndex) = filter.update(obj, at: index) + } else { + lastIndex = filter.remove(dataSource: index) + } + // if it isnt in this layer, it wont appear in the following either + if lastIndex == nil { return (false, false) } + } + return (true, keepGoing) + } + + + // MARK: data updates + + /// Add new element to the original `dataSource` and immediately apply filter and sorting. + /// - Complexity: O((*m*+1) log *n*), where *m* is the number of filters and *n* the number of elements in each filter. + func addNew(_ obj: T) { + let index = dataSource.count + dataSource.append(obj) + for filter in pipeline { + if filter.add(obj, at: index) == nil { return } + } + // survived all filters + let displayIndex = display.insertNew(index) + delegate?.tableView.safeInsertRow(displayIndex, with: .left) + } + + /// Update element at `index` in the original `dataSource` and immediately re-apply filter and sorting. + /// - Parameters: + /// - obj: Element to be added. Will overwrite previous `dataSource` object. + /// - index: Index in the original `dataSource` + /// - Complexity: O(*n* + (*m*+1) log *n*), where *m* is the number of filters and *n* the number of elements in each filter / projection. + func update(_ obj: T, at index: Int) { + let status = processPipeline(with: obj, at: index) + guard status.changed else { return } + let oldPos = display.deleteOld(index) + dataSource[index] = obj + guard status.display else { + if let old = oldPos { delegate?.tableView.safeDeleteRows([old]) } + return + } + let newPos = display.insertNew(index) + if let old = oldPos { + if old == newPos { + delegate?.tableView.safeReloadRow(old) + } else { + delegate?.tableView.safeMoveRow(old, to: newPos) + if delegate?.tableView.isFrontmost ?? false { + delegate?.rowNeedsUpdate(newPos) + } + } + } else { + delegate?.tableView.safeInsertRow(newPos, with: .left) + } + } + + /// Remove elements from the original `dataSource`, from all filters, and from display sorting. + /// - Parameter sorted: Indices in the original `dataSource` + /// - Complexity: O(*t*(*m*+*n*) + *m* log *n*), where *t* is the number of filters, + /// *m* the number of elements in each filter / projection, and *n* the length of `sorted` indices. + func remove(indices sorted: [Int]) { + guard sorted.count > 0 else { return } + for i in sorted.reversed() { + dataSource.remove(at: i) + } + for filter in pipeline { + filter.shiftRemove(indices: sorted) + } + let indices = display.shiftRemove(indices: sorted) + delegate?.tableView.safeDeleteRows(indices) + } +} + + +// MARK: - Filter + +class PipelineFilter { + typealias Predicate = (T) -> Bool + + let id: String + private(set) var selection: [Int] = [] + private let shouldPersist: Predicate + + /// - Parameter predicate: Return `true` if you want to keep the element + required init(_ identifier: String, _ predicate: @escaping Predicate) { + self.id = identifier + shouldPersist = predicate + } + + /// Reset selection indices by copying the indices from the previous filter or using + /// the indices of the data source if no previous filter is present. + fileprivate func reset(to dataSource: [T], previous filter: PipelineFilter? = nil) { + selection = (filter != nil) ? filter!.selection : dataSource.indices.arr() + selection.removeAll { !shouldPersist(dataSource[$0]) } + } + + /// Apply filter to `obj` and either insert or do nothing. + /// - Parameters: + /// - obj: Object that should be inserted if filter allows. + /// - index: Index of object in original `dataSource` + /// - Returns: Index in `selection` or `nil` if `obj` is removed by the filter. + /// - Complexity: + /// * O(1), if `index` is appended at end. + /// * O(log *n*), where *n* is the length of the `selection`. + fileprivate func add(_ obj: T, at index: Int) -> Int? { + guard shouldPersist(obj) else { + return nil + } + if selection.last ?? 0 < index { // in case we only append at end + selection.append(index) + return selection.count - 1 + } + return selection.binTreeInsert(index, compare: (<)) + } + + /// Search and remove original `dataSource` index + /// - Parameter index: Index of object in original `dataSource` + /// - Returns: Index of removed element in `selection` or `nil` if element does not exist + /// - Complexity: O(log *n*), where *n* is the length of the `selection`. + fileprivate func remove(dataSource index: Int) -> Int? { + selection.binTreeRemove(index, compare: (<)) + } + + /// Find `selection` index for corresponding `dataSource` index + /// - Parameter index: Index of object in original `dataSource` + /// - Returns: Index in `selection` or `nil` if element does not exist. + /// - Complexity: O(log *n*), where *n* is the length of the `selection`. + fileprivate func index(ofDataSource index: Int) -> Int? { + selection.binTreeIndex(of: index, compare: (<), mustExist: true) + } + + /// Perform filter check and update internal `selection` indices. + /// - Parameters: + /// - obj: Object that was inserted or updated. + /// - index: Index where the object is located after the update. + /// - Returns: `keep` indicates whether the value should be displayed (`true`) or hidden (`false`). + /// `idx` contains the selection filter index or `nil` if the value should be removed. + /// - Complexity: O(log *n*), where *n* is the length of the `selection`. + fileprivate func update(_ obj: T, at index: Int) -> (keep: Bool, idx: Int?) { + let currentIndex = self.index(ofDataSource: index) + if shouldPersist(obj) { + return (true, currentIndex ?? selection.binTreeInsert(index, compare: (<))) + } + if let i = currentIndex { selection.remove(at: i) } + return (false, currentIndex) + } + + /// Instead of re-sorting we can decrement all remaining elements after X. + /// - Parameter sorted: Elements to remove from collection + /// - Complexity: O(*m*+*n*), where *m* is the length of the `selection`. + /// *n* is equal to: *length of selection* `-` *index of first element* of `sorted` indices + fileprivate func shiftRemove(indices sorted: [Int]) { + guard sorted.count > 0 else { + return + } + var list = sorted + var del = list.popLast() + for (i, val) in selection.enumerated().reversed() { + while let d = del, d > val { + del = list.popLast() + } + guard let d = del else { break } + if d < val { selection[i] -= (list.count + 1) } + else if d == val { selection.remove(at: i) } + } + } +} + + +// MARK: - Sorting + +class PipelineSorting { + typealias Predicate = (T, T) -> Bool + + private(set) var projection: [Int] = [] + private let comperator: (Int, Int) -> Bool // links to pipeline.dataSource + private let previousLayerIndices: () -> [Int] // links to pipeline + + /// Create a fresh, already sorted, display order projection. + /// - Parameter predicate: Return `true` if first element should be sorted before second element. + required init(_ predicate: @escaping Predicate, pipe: FilterPipeline) { + comperator = { [unowned pipe] in + predicate(pipe.dataSource[$0], pipe.dataSource[$1]) + } + previousLayerIndices = { [unowned pipe] in + pipe.lastFilterLayerIndices() + } + reset() + } + + /// Apply a new layer of filtering. Every layer can only restrict the display even further. + /// Therefore, indices that were removed in the last layer will be removed from the projection too. + /// - Complexity: O(*m* log *n*), where *m* is the length of the `projection` and *n* the length of the `filter`. + fileprivate func apply(moreRestrictive filter: PipelineFilter) { + projection.removeAll { filter.index(ofDataSource: $0) == nil } + } + + /// Remove a layer of filtering. Previous layers are less restrictive and contain more indices. + /// Therefore, the difference between both index sets will be inserted into the projection. + /// - Parameter filter: If `nil`, reset to last filter layer or `dataSource` + /// - Complexity: + /// * O(*m* log *n*), if `filter != nil`. + /// Where *n* is the length of the `projection` and *m* is the difference between both layers. + /// * O(*n* log *n*), if `filter == nil`. + /// Where *n* is the length of the previous layer (or `dataSource`). + fileprivate func reset(toLessRestrictive filter: PipelineFilter? = nil) { + if let indices = filter?.selection.difference(toSubset: projection.sorted(), compare: (<)) { + for idx in indices { + insertNew(idx) + } + } else { + projection = previousLayerIndices().sorted(by: comperator) + } + } + + /// Add new element and automatically sort according to predicate + /// - Parameter index: Index of the element position in the original `dataSource` + /// - Returns: Index in the projection + /// - Complexity: O(log *n*), where *n* is the length of the `projection`. + @discardableResult fileprivate func insertNew(_ index: Int) -> Int { + projection.binTreeInsert(index, compare: comperator) + } + + /// Remove element from projection + /// - Parameter index: Index of the element position in the original `dataSource` + /// - Returns: Index in the projection or `nil` if element did not exist + /// - Complexity: O(*n*), where *n* is the length of the `projection`. + fileprivate func deleteOld(_ index: Int) -> Int? { + guard let i = projection.firstIndex(of: index) else { + return nil + } + projection.remove(at: i) + return i + } + + /// Instead of re-sorting we can decrement all remaining elements after X. + /// - Parameter sorted: Elements to remove from collection + /// - Returns: List of `projection` indices that were removed (reverse sort order) + /// - Complexity: O(*m* log *n*), where *m* is the length of the `projection` and *n* is the length of `sorted`. + @discardableResult fileprivate func shiftRemove(indices sorted: [Int]) -> [Int] { + guard sorted.count > 0 else { + return [] + } + var listOfDeletes: [Int] = [] + let min = sorted.first!, max = sorted.last! + for (i, val) in projection.enumerated().reversed() { + guard val >= min else { continue } + if val > max { + projection[i] -= sorted.count + } else { + let c = sorted.binTreeIndex(of: val, compare: (<))! + if val == sorted[c] { + projection.remove(at: i) + listOfDeletes.append(i) + } else { + projection[i] -= c + } + } + } + return listOfDeletes + } +} diff --git a/main/DB/DBAppOnly.swift b/main/DB/DBAppOnly.swift new file mode 100644 index 0000000..bb90bd7 --- /dev/null +++ b/main/DB/DBAppOnly.swift @@ -0,0 +1,386 @@ +import Foundation +import SQLite3 + +typealias Timestamp = sqlite3_int64 + +extension SQLiteDatabase { + func initAppOnlyScheme() { + try? run(sql: CreateTable.heap) + try? run(sql: CreateTable.rec) + try? run(sql: CreateTable.recLog) + do { + try migrateDB() + } catch { + QLog.Error("during migration: \(error)") + } + } + + func migrateDB() throws { + let version = try run(sql: "PRAGMA user_version;") { stmt -> Int32 in + try ifStep(stmt, SQLITE_ROW) + return sqlite3_column_int(stmt, 0) + } + if version != 1 { + // version 0 -> 1: req(domain) -> heap(fqdn, domain) + if version == 0 { + try tempMigrate() + } + try run(sql: "PRAGMA user_version = 1;") + } + } + + private func tempMigrate() throws { // TODO: remove with next internal release + do { + try run(sql: "SELECT 1 FROM req LIMIT 1;") // fails if req doesnt exist + createFunction("domainof") { ($0.first as! String).extractDomain() } + try run(sql: """ + BEGIN TRANSACTION; + INSERT INTO heap(ts,fqdn,domain,opt) SELECT ts,domain,domainof(domain),nullif(logOpt,0) FROM req; + DROP TABLE req; + COMMIT; + """) + } catch { /* no need to migrate */ } + } +} + +private enum TableName: String { + case heap, cache +} + +extension SQLiteDatabase { + fileprivate func lastRowId(_ table: TableName) -> SQLiteRowID { + (try? run(sql:"SELECT rowid FROM \(table.rawValue) ORDER BY rowid DESC LIMIT 1;") { + try ifStep($0, SQLITE_ROW) + return sqlite3_column_int64($0, 0) + }) ?? 0 + } +} + +struct WhereClauseBuilder: CustomStringConvertible { + var description: String = "" + private let prefix: String + private(set) var bindings: [DBBinding] = [] + init(prefix p: String = "WHERE") { prefix = "\(p) " } + mutating func and(_ clause: String, _ bind: DBBinding ...) { + description.append((description=="" ? prefix : " AND ") + clause) + bindings.append(contentsOf: bind) + } +} + + + +// MARK: - DNSLog + +extension CreateTable { + /// `ts`: Timestamp, `fqdn`: String, `domain`: String, `opt`: Int + static var heap: String {""" + CREATE TABLE IF NOT EXISTS heap( + ts INTEGER DEFAULT (strftime('%s','now')), + fqdn TEXT NOT NULL, + domain TEXT NOT NULL, + opt INTEGER + ); + """} // opt currently only used as "blocked" flag +} + +struct GroupedDomain { + let domain: String, total: Int32, blocked: Int32, lastModified: Timestamp + var options: FilterOptions? = nil +} +typealias GroupedTsOccurrence = (ts: Timestamp, total: Int32, blocked: Int32) + +extension SQLiteDatabase { + + // MARK: write + + /// Move newest entries from `cache` to `heap` and return range (in `heap`) of newly inserted entries. + /// - Returns: `nil` in case no entries were transmitted. + @discardableResult func dnsLogsPersist() -> SQLiteRowRange? { + guard lastRowId(.cache) > 0 else { return nil } + let before = lastRowId(.heap) + 1 + createFunction("domainof") { ($0.first as! String).extractDomain() } + try? run(sql:""" + BEGIN TRANSACTION; + INSERT INTO heap(ts,fqdn,domain,opt) SELECT ts,dns,domainof(dns),nullif(opt&1,0) FROM cache; + DELETE FROM cache; + COMMIT; + """) + let after = lastRowId(.heap) + return (before > after) ? nil : (before, after) + } + + /// `DELETE FROM heap; DELETE FROM cache;` + func dnsLogsDeleteAll() throws { + try? run(sql: "DELETE FROM heap; DELETE FROM cache;") + vacuum() + } + + /// Delete rows matching `ts >= ? AND domain = ?` + /// - Parameter strict: If `true`, use `fqdn` instead of `domain` column + /// - Returns: Number of changes aka. Number of rows deleted + @discardableResult func dnsLogsDelete(_ domain: String, strict: Bool, since ts: Timestamp = 0) -> Int32 { + var Where = WhereClauseBuilder() + if ts != 0 { Where.and("ts >= ?", BindInt64(ts)) } + Where.and("\(strict ? "fqdn" : "domain") = ?", BindText(domain)) // (fqdn = ? OR fqdn LIKE '%.' || ?) + return (try? run(sql: "DELETE FROM heap \(Where);", bind: Where.bindings) { stmt -> Int32 in + try ifStep(stmt, SQLITE_DONE) + return numberOfChanges + }) ?? 0 + } + + // MARK: read + + /// Select min and max row id with given condition `ts >= ? AND ts < ?` + /// - Returns: `nil` in case no rows are matching the condition + func dnsLogsRowRange(between ts: Timestamp, and ts2: Timestamp) -> SQLiteRowRange? { + try? run(sql:"SELECT min(rowid), max(rowid) FROM heap WHERE ts >= ? AND ts < ?", + bind: [BindInt64(ts), BindInt64(ts2)]) { + try ifStep($0, SQLITE_ROW) + let max = sqlite3_column_int64($0, 1) + return (max == 0) ? nil : (sqlite3_column_int64($0, 0), max) + } + } + + /// Group DNS logs by domain, count occurences and number of blocked requests. + /// - Parameters: + /// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end ` + /// - ts: Restrict result set `ts >= ?` + /// - ts2: Restrict result set `ts < ?` + /// - matchingDomain: Restrict `(fqdn|domain) = ?`. Which column is used is determined by `parentDomain`. + /// - parentDomain: If `nil` returns `domain` column. Else returns `fqdn` column with restriction on `domain == parentDomain`. + /// - Returns: List of grouped domains with no particular sorting order. + func dnsLogsGrouped(range: SQLiteRowRange? = nil, since ts: Timestamp = 0, upto ts2: Timestamp = 0, + matchingDomain: String? = nil, parentDomain: String? = nil) -> [GroupedDomain]? + { + var Where = WhereClauseBuilder() + if let from = range?.start { Where.and("rowid >= ?", BindInt64(from)) } + if let to = range?.end { Where.and("rowid <= ?", BindInt64(to)) } + if ts != 0 { Where.and("ts >= ?", BindInt64(ts)) } + if ts2 != 0 { Where.and("ts < ?", BindInt64(ts2)) } + let col: String // fqdn or domain + if let parent = parentDomain { // is subdomain + col = "fqdn" + Where.and("domain = ?", BindText(parent)) + } else { + col = "domain" + } + if let matching = matchingDomain { // (fqdn = ? OR fqdn LIKE '%.' || ?) + Where.and("\(col) = ?", BindText(matching)) + } + return try? run(sql: "SELECT \(col), COUNT(*), COUNT(opt), MAX(ts) FROM heap \(Where) GROUP BY \(col);", bind: Where.bindings) { + allRows($0) { + GroupedDomain(domain: readText($0, 0) ?? "", + total: sqlite3_column_int($0, 1), + blocked: sqlite3_column_int($0, 2), + lastModified: sqlite3_column_int64($0, 3)) + } + } + } + + /// Get list or individual DNS entries. Mutliple entries in the very same second are grouped. + /// - Parameters: + /// - fqdn: Exact match for domain name `fqdn = ?` + /// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end ` + /// - ts: Restrict result set `ts >= ?` + /// - ts2: Restrict result set `ts < ?` + /// - Returns: List sorted by reverse timestamp order (newest first) + func timesForDomain(_ fqdn: String, range: SQLiteRowRange? = nil, since ts: Timestamp = 0, upto ts2: Timestamp = 0) -> [GroupedTsOccurrence]? { + var Where = WhereClauseBuilder() + if let from = range?.start { Where.and("rowid >= ?", BindInt64(from)) } + if let to = range?.end { Where.and("rowid <= ?", BindInt64(to)) } + if ts != 0 { Where.and("ts >= ?", BindInt64(ts)) } + if ts2 != 0 { Where.and("ts < ?", BindInt64(ts2)) } + Where.and("fqdn = ?", BindText(fqdn)) + return try? run(sql: "SELECT ts, COUNT(ts), COUNT(opt) FROM heap \(Where) GROUP BY ts ORDER BY ts DESC;", bind: Where.bindings) { + allRows($0) { + (sqlite3_column_int64($0, 0), sqlite3_column_int($0, 1), sqlite3_column_int($0, 2)) + } + } + } +} + + + +// MARK: - Recordings + +extension CreateTable { + /// `id`: Primary, `start`: Timestamp, `stop`: Timestamp, `appid`: String, `title`: String, `notes`: String + static var rec: String {""" + CREATE TABLE IF NOT EXISTS rec( + id INTEGER PRIMARY KEY, + start INTEGER DEFAULT (strftime('%s','now')), + stop INTEGER, + appid TEXT, + title TEXT, + notes TEXT + ); + """} +} + +struct Recording { + let id: sqlite3_int64 + let start: Timestamp + let stop: Timestamp? + var appId: String? = nil + var title: String? = nil + var notes: String? = nil +} + +extension SQLiteDatabase { + + // MARK: write + + /// Create new recording with `stop` set to `NULL`. + func recordingStartNew() throws -> Recording { + try run(sql: "INSERT INTO rec (stop) VALUES (NULL);") { stmt -> Recording in + try ifStep(stmt, SQLITE_DONE) + return try recordingGet(withID: lastInsertedRow) + } + } + + /// Update given recording by setting `stop` to current time. + func recordingStop(_ r: inout Recording) { + guard r.stop == nil else { return } + let theID = r.id + try? run(sql: "UPDATE rec SET stop = (strftime('%s','now')) WHERE id = ? LIMIT 1;", + bind: [BindInt64(theID)]) { stmt -> Void in + try ifStep(stmt, SQLITE_DONE) + r = try recordingGet(withID: theID) + } + } + + /// Update given recording by replacing `title`, `appid`, and `notes` with new values. + func recordingUpdate(_ r: Recording) { + try? run(sql: "UPDATE rec SET title = ?, appid = ?, notes = ? WHERE id = ? LIMIT 1;", + bind: [BindTextOrNil(r.title), BindTextOrNil(r.appId), BindTextOrNil(r.notes), BindInt64(r.id)]) { stmt -> Void in + sqlite3_step(stmt) + } + } + + /// Delete recording and all of its entries. + /// - Returns: `true` on success + func recordingDelete(_ r: Recording) throws -> Bool { + _ = try? recordingLogsDelete(r.id) + return try run(sql: "DELETE FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(r.id)]) { + try ifStep($0, SQLITE_DONE) + return numberOfChanges > 0 + } + } + + // MARK: read + + private func readRecording(_ stmt: OpaquePointer) -> Recording { + let end = sqlite3_column_int64(stmt, 2) + return Recording(id: sqlite3_column_int64(stmt, 0), + start: sqlite3_column_int64(stmt, 1), + stop: end == 0 ? nil : end, + appId: readText(stmt, 3), + title: readText(stmt, 4), + notes: readText(stmt, 5)) + } + + /// `WHERE stop IS NULL` + func recordingGetOngoing() -> Recording? { + try? run(sql: "SELECT * FROM rec WHERE stop IS NULL LIMIT 1;") { + try ifStep($0, SQLITE_ROW) + return readRecording($0) + } + } + + /// `WHERE stop IS NOT NULL` + func recordingGetAll() -> [Recording]? { + try? run(sql: "SELECT * FROM rec WHERE stop IS NOT NULL;") { + allRows($0) { readRecording($0) } + } + } + + /// `WHERE id = ?` + private func recordingGet(withID: sqlite3_int64) throws -> Recording { + try run(sql: "SELECT * FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(withID)]) { + try ifStep($0, SQLITE_ROW) + return readRecording($0) + } + } +} + + + +// MARK: - RecordingLog + +extension CreateTable { + /// `rid`: Reference `rec(id)`, `ts`: Timestamp, `domain`: String + static var recLog: String {""" + CREATE TABLE IF NOT EXISTS recLog( + rid INTEGER REFERENCES rec(id) ON DELETE CASCADE, + ts INTEGER, + domain TEXT + ); + """} +} + +typealias RecordLog = (domain: String, count: Int32) + +extension SQLiteDatabase { + + // MARK: write + + /// Duplicate and copy all log entries for given recording to `recLog` table + func recordingLogsPersist(_ r: Recording) { + guard let end = r.stop else { return } + // TODO: make sure cache entries get copied too. + // either by copying them directly from cache or perform sync first + try? run(sql: """ + INSERT INTO recLog (rid, ts, domain) SELECT ?, ts, fqdn FROM heap + WHERE heap.ts >= ? AND heap.ts <= ? + """, bind: [BindInt64(r.id), BindInt64(r.start), BindInt64(end)]) { + try ifStep($0, SQLITE_DONE) + } + } + + /// Delete all log entries with given recording id. Optional: only delete entries for a single domain + /// - Parameter d: If `nil` remove all entries for given recording + /// - Returns: Number of deleted rows + func recordingLogsDelete(_ recId: sqlite3_int64, matchingDomain d: String? = nil) throws -> Int32 { + try run(sql: "DELETE FROM recLog WHERE rid = ? \(d==nil ? "" : "AND domain = ?");", + bind: [BindInt64(recId), d==nil ? nil : BindText(d!)]) { + try ifStep($0, SQLITE_DONE) + return numberOfChanges + } + } + + // MARK: read + + /// List of domains and count occurences for given recording. + func recordingLogsGetGrouped(_ r: Recording) -> [RecordLog]? { + try? run(sql: "SELECT domain, COUNT() FROM recLog WHERE rid = ? GROUP BY domain;", + bind: [BindInt64(r.id)]) { + allRows($0) { (readText($0, 0) ?? "", sqlite3_column_int($0, 1)) } + } + } +} + + + +// MARK: - DBSettings + +//extension CreateTable { +// static var settings: String { +// "CREATE TABLE IF NOT EXISTS settings(key TEXT UNIQUE NOT NULL, val TEXT);" +// } +//} +// +//extension SQLiteDatabase { +// func getSetting(for key: String) -> String? { +// try? run(sql: "SELECT val FROM settings WHERE key = ?;", +// bind: [BindText(key)]) { readText($0, 0) } +// } +// func setSetting(_ value: String?, for key: String) { +// if let value = value { +// try? run(sql: "INSERT OR REPLACE INTO settings (key, val) VALUES (?, ?);", +// bind: [BindText(value), BindText(key)]) { step($0) } +// } else { +// try? run(sql: "DELETE FROM settings WHERE key = ?;", +// bind: [BindText(key)]) { step($0) } +// } +// } +//} diff --git a/main/DB/DBCommon.swift b/main/DB/DBCommon.swift new file mode 100644 index 0000000..38e939a --- /dev/null +++ b/main/DB/DBCommon.swift @@ -0,0 +1,88 @@ +import Foundation +import SQLite3 + +enum CreateTable {} // used for CREATE TABLE statements + +extension SQLiteDatabase { + func initCommonScheme() { + try? run(sql: CreateTable.cache) + try? run(sql: CreateTable.filter) + } +} + + +// MARK: - transit + +extension CreateTable { + /// `ts`: Timestamp, `dns`: String, `opt`: Int + static var cache: String {""" + CREATE TABLE IF NOT EXISTS cache( + ts INTEGER DEFAULT (strftime('%s','now')), + dns TEXT NOT NULL, + opt INTEGER + ); + """} +} + +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)]) + } +} + + +// MARK: - filter + +extension CreateTable { + /// `domain`: String, `opt`: Int + static var filter: String {""" + CREATE TABLE IF NOT EXISTS filter( + domain TEXT UNIQUE NOT NULL, + opt INTEGER + ); + """} +} + +struct FilterOptions: OptionSet { + let rawValue: Int32 + static let none = FilterOptions([]) + static let blocked = FilterOptions(rawValue: 1 << 0) + static let ignored = FilterOptions(rawValue: 1 << 1) + static let any = FilterOptions(rawValue: 0b11) +} + +extension SQLiteDatabase { + func loadFilters(where matching: FilterOptions? = nil) -> [String : FilterOptions]? { + let rv = matching?.rawValue ?? 0 + return try? run(sql: "SELECT domain, opt FROM filter \(rv>0 ? "WHERE opt & ?" : "");", + bind: rv>0 ? [BindInt32(rv)] : []) { + allRowsKeyed($0) { + (key: readText($0, 0) ?? "", + value: FilterOptions(rawValue: sqlite3_column_int($0, 1))) + } + } + } + func setFilter(_ domain: String, _ value: FilterOptions?) { + if let rv = value?.rawValue, rv > 0 { + try? run(sql: "INSERT OR REPLACE INTO filter (domain, opt) VALUES (?, ?);", + bind: [BindText(domain), BindInt32(rv)]) { _ = sqlite3_step($0) } + } else { + try? run(sql: "DELETE FROM filter WHERE domain = ? LIMIT 1;", + bind: [BindText(domain)]) { _ = sqlite3_step($0) } + } + } +// func loadFilterCount() -> (blocked: Int32, ignored: Int32)? { +// try? run(sql: "SELECT SUM(opt&1), SUM(opt&2)/2 FROM filter;") { +// try ifStep($0, SQLITE_ROW) +// return (sqlite3_column_int($0, 0), sqlite3_column_int($0, 1)) +// } +// } +} diff --git a/main/DB/DBCore.swift b/main/DB/DBCore.swift new file mode 100644 index 0000000..47f5c69 --- /dev/null +++ b/main/DB/DBCore.swift @@ -0,0 +1,244 @@ +import Foundation +import SQLite3 + +// iOS 9.3 uses SQLite 3.8.10 + +enum SQLiteError: Error { + case OpenDatabase(message: String) + case Prepare(message: String) + case Step(message: String) + case Bind(message: String) +} + +/// `try? SQLiteDatabase.open()` +var AppDB: SQLiteDatabase? { get { try? SQLiteDatabase.open() } } +typealias SQLiteRowID = sqlite3_int64 +typealias SQLiteRowRange = (start: SQLiteRowID, end: SQLiteRowID) + +// MARK: - SQLiteDatabase + +class SQLiteDatabase { + fileprivate var functions = [String: [Int: Function]]() + private let dbPointer: OpaquePointer? + private init(dbPointer: OpaquePointer?) { + self.dbPointer = dbPointer + } + + fileprivate var errorMessage: String { + if let errorPointer = sqlite3_errmsg(dbPointer) { + let errorMessage = String(cString: errorPointer) + return errorMessage + } else { + return "No error message provided from sqlite." + } + } + + deinit { + sqlite3_close(dbPointer) + } + + static func destroyDatabase(path: String = URL.internalDB().relativePath) { + if FileManager.default.fileExists(atPath: path) { + do { try FileManager.default.removeItem(atPath: path) } + catch { print("Could not destroy database file: \(path)") } + } + } + + static func open(path: String = URL.internalDB().relativePath) throws -> SQLiteDatabase { + var db: OpaquePointer? + //sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_SHAREDCACHE, nil) + if sqlite3_open(path, &db) == SQLITE_OK { + return SQLiteDatabase(dbPointer: db) + } else { + defer { + if db != nil { + sqlite3_close(db) + } + } + if let errorPointer = sqlite3_errmsg(db) { + let message = String(cString: errorPointer) + throw SQLiteError.OpenDatabase(message: message) + } else { + throw SQLiteError.OpenDatabase(message: "No error message provided from sqlite.") + } + } + } + + func run(sql: String, bind: [DBBinding?] = [], step: (OpaquePointer) throws -> T) throws -> T { +// print("SQL run: \(sql)") +// for x in bind where x != nil { +// print(" -> \(x!)") +// } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(dbPointer, sql, -1, &statement, nil) == SQLITE_OK, + let stmt = statement else { + throw SQLiteError.Prepare(message: errorMessage) + } + defer { sqlite3_finalize(stmt) } + var col: Int32 = 0 + for b in bind.compactMap({$0}) { + col += 1 + guard b.bind(stmt, col) == SQLITE_OK else { + throw SQLiteError.Bind(message: errorMessage) + } + } + return try step(stmt) + } + + func run(sql: String) throws { +// print("SQL exec: \(sql)") + var err: UnsafeMutablePointer? = nil + if sqlite3_exec(dbPointer, sql, nil, nil, &err) != SQLITE_OK { + let errMsg = (err != nil) ? String(cString: err!) : "Unknown execution error" + sqlite3_free(err); + throw SQLiteError.Step(message: errMsg) + } + } + + func ifStep(_ stmt: OpaquePointer, _ expected: Int32) throws { + guard sqlite3_step(stmt) == expected else { + throw SQLiteError.Step(message: errorMessage) + } + } + + func vacuum() { + try? run(sql: "VACUUM;") + } +} + + +// MARK: - Custom Functions + +// let SQLITE_STATIC = unsafeBitCast(0, sqlite3_destructor_type.self) +let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + +public struct Blob { + public let bytes: [UInt8] + public init(bytes: [UInt8]) { self.bytes = bytes } + public init(bytes: UnsafeRawPointer, length: Int) { + let i8bufptr = UnsafeBufferPointer(start: bytes.assumingMemoryBound(to: UInt8.self), count: length) + self.init(bytes: [UInt8](i8bufptr)) + } +} + +extension SQLiteDatabase { + fileprivate typealias Function = @convention(block) (OpaquePointer?, Int32, UnsafeMutablePointer?) -> Void + + func createFunction(_ function: String, argumentCount: UInt? = nil, deterministic: Bool = false, _ block: @escaping (_ args: [Any?]) -> Any?) { + let argc = argumentCount.map { Int($0) } ?? -1 + let box: Function = { context, argc, argv in + let arguments: [Any?] = (0.. Int32 +} + +struct BindInt32 : DBBinding { + let raw: Int32 + init(_ value: Int32) { raw = value } + func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32 { sqlite3_bind_int(stmt, col, raw) } +} + +struct BindInt64 : DBBinding { + let raw: sqlite3_int64 + init(_ value: sqlite3_int64) { raw = value } + func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32 { sqlite3_bind_int64(stmt, col, raw) } +} + +struct BindText : DBBinding { + let raw: String + init(_ value: String) { raw = value } + func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32 { sqlite3_bind_text(stmt, col, (raw as NSString).utf8String, -1, nil) } +} + +struct BindTextOrNil : DBBinding { + let raw: String? + init(_ value: String?) { raw = value } + func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32 { sqlite3_bind_text(stmt, col, (raw == nil) ? nil : (raw! as NSString).utf8String, -1, nil) } +} + +// MARK: - Easy Access func + +extension SQLiteDatabase { + var numberOfChanges: Int32 { get { sqlite3_changes(dbPointer) } } + var lastInsertedRow: SQLiteRowID { get { sqlite3_last_insert_rowid(dbPointer) } } + + func readText(_ stmt: OpaquePointer, _ col: Int32) -> String? { + let val = sqlite3_column_text(stmt, col) + return (val != nil ? String(cString: val!) : nil) + } + + func allRows(_ stmt: OpaquePointer, _ fn: (OpaquePointer) -> T) -> [T] { + var r: [T] = [] + while (sqlite3_step(stmt) == SQLITE_ROW) { r.append(fn(stmt)) } + return r + } + + func allRowsKeyed(_ stmt: OpaquePointer, _ fn: (OpaquePointer) -> (key: T, value: U)) -> [T:U] { + var r: [T:U] = [:] + while (sqlite3_step(stmt) == SQLITE_ROW) { let (k,v) = fn(stmt); r[k] = v } + return r + } +} + + +// MARK: - Prepared Statement + +extension SQLiteDatabase { + func prepare(sql: String) throws -> OpaquePointer { + var pStmt: OpaquePointer? + guard sqlite3_prepare_v2(dbPointer, sql, -1, &pStmt, nil) == SQLITE_OK, let S = pStmt else { + throw SQLiteError.Prepare(message: errorMessage) + } + return S + } + + @discardableResult func prepared(run pStmt: OpaquePointer!, bind: [DBBinding?] = []) throws -> Int32 { + defer { sqlite3_reset(pStmt) } + var col: Int32 = 0 + for b in bind.compactMap({$0}) { + col += 1 + guard b.bind(pStmt, col) == SQLITE_OK else { + throw SQLiteError.Bind(message: errorMessage) + } + } + return sqlite3_step(pStmt) + } + + func prepared(finalize pStmt: OpaquePointer!) { + sqlite3_finalize(pStmt) + } +} diff --git a/main/Extensions/DBExtensions.swift b/main/DB/DBExtensions.swift similarity index 55% rename from main/Extensions/DBExtensions.swift rename to main/DB/DBExtensions.swift index d9878c2..347524d 100644 --- a/main/Extensions/DBExtensions.swift +++ b/main/DB/DBExtensions.swift @@ -1,4 +1,4 @@ -import Foundation +import UIKit extension GroupedDomain { /// Return new `GroupedDomain` by adding `total` and `blocked` counts. Set `lastModified` to the maximum of the two. @@ -13,15 +13,22 @@ extension GroupedDomain { } } -extension Array where Element == GroupedDomain { - func merge(_ domain: String, options opt: FilterOptions? = nil) -> GroupedDomain { - var b: Int32 = 0, t: Int32 = 0, m: Timestamp = 0 - for x in self { - b += x.blocked - t += x.total - m = Swift.max(m, x.lastModified) +extension GroupedDomain { + var detailCellText: String { get { + return blocked > 0 + ? "\(lastModified.asDateTime()) — \(blocked)/\(total) blocked" + : "\(lastModified.asDateTime()) — \(total)" } - return GroupedDomain(domain: domain, total: t, blocked: b, lastModified: m, options: opt) + } +} + +extension FilterOptions { + func tableRowImage() -> UIImage? { + let blocked = contains(.blocked) + let ignored = contains(.ignored) + if blocked { return UIImage(named: ignored ? "block_ignore" : "shield-x") } + if ignored { return UIImage(named: "quicklook-not") } + return nil } } @@ -31,10 +38,3 @@ extension Recording { var durationString: String? { get { stop == nil ? nil : TimeFormat.from(duration!) } } } -extension Timestamp { - /// - Returns: Time string with format `yyyy-MM-dd HH:mm:ss` - func asDateTime() -> String { dateTimeFormat.string(from: self) } - func toDate() -> Date { Date(timeIntervalSince1970: Double(self)) } - static func now() -> Timestamp { Timestamp(Date().timeIntervalSince1970) } - static func past(minutes: Int) -> Timestamp { now() - Timestamp(minutes * 60) } -} diff --git a/main/DB/DBWrapper.swift b/main/DB/DBWrapper.swift deleted file mode 100644 index 764c6e5..0000000 --- a/main/DB/DBWrapper.swift +++ /dev/null @@ -1,375 +0,0 @@ -import UIKit - -let DBWrp = DBWrapper() -fileprivate var AppDB: SQLiteDatabase? { get { try? SQLiteDatabase.open() } } - -class DBWrapper { - private var earliestEntry: Timestamp = 0 - private var latestModification: Timestamp = 0 - private var dataA: [GroupedDomain] = [] // Domains - private var dataB: [[GroupedDomain]] = [] // Hosts - private var dataF: [String : FilterOptions] = [:] // Filters - private let Q = DispatchQueue(label: "de.uni-bamberg.psi.AppCheck.db-wrapper-queue", attributes: .concurrent) - - // auto update rows callback - var currentlyOpenParent: String? - weak var dataA_delegate: IncrementalDataSourceUpdate? - weak var dataB_delegate: IncrementalDataSourceUpdate? - func dataB_delegate(_ parent: String) -> IncrementalDataSourceUpdate? { - (currentlyOpenParent == parent) ? dataB_delegate : nil - } - - - // MARK: - Data Source Getter - - func listOfDomains() -> [GroupedDomain] { - Q.sync() { dataA } - } - - func listOfHosts(_ parent: String) -> [GroupedDomain] { - Q.sync() { dataB[ifExist: dataA_index(of: parent)] ?? [] } - } - - func dataF_list(_ filter: FilterOptions) -> [String] { - Q.sync() { dataF.compactMap { $1.contains(filter) ? $0 : nil } }.sorted() - } - - func dataF_counts() -> (blocked: Int, ignored: Int) { - Q.sync() { dataF.reduce((0, 0)) { - ($0.0 + ($1.1.contains(.blocked) ? 1 : 0), - $0.1 + ($1.1.contains(.ignored) ? 1 : 0)) }} - } - - func listOfTimes(_ domain: String) -> [GroupedTsOccurrence] { - return AppDB?.timesForDomain(domain, since: earliestEntry)?.reversed() ?? [] - } - - - // MARK: - Init - - func initContentOfDB() { - QLog.Debug("SQLite path: \(URL.internalDB())") - DispatchQueue.global().async { -#if IOS_SIMULATOR - self.generateTestData() - DispatchQueue.main.async { - // dont know why main queue is needed, wont start otherwise - Timer.repeating(2, call: #selector(self.insertRandomEntry), on: self) - } -#endif - self.dataF_init() - self.dataAB_init() - self.autoSyncTimer_init() - } - } - - func reloadAfterDateFilterHasChanged() { - DispatchQueue.global().async { - self.dataAB_init() - } - } - - private func dataF_init() { - let list = AppDB?.loadFilters() ?? [:] - Q.async(flags: .barrier) { - self.dataF = list - NotifyDNSFilterChanged.postAsyncMain() - } - } - - private func dataAB_init() { - let earliest = Pref.DateFilter.lastXMinTimestamp() ?? 0 - let list = AppDB?.domainList(since: earliest) - Q.async(flags: .barrier) { - self.dataA = [] - self.dataB = [] - self.earliestEntry = earliest - self.latestModification = earliest - if let allDomains = list { - for (parent, parts) in self.groupBySubdomains(allDomains) { - self.dataA.append(parent) - self.dataB.append(parts) - self.latestModification = max(parent.lastModified, self.latestModification) - } - } - NotifyLogHistoryReset.postAsyncMain() - } - } - - /// Auto sync new logs every 7 seconds. - private func autoSyncTimer_init() { - Q.async() { // using Q to start timer only after init data A,B,F - DispatchQueue.main.async { - // dont know why main queue is needed, wont start otherwise - Timer.repeating(7, call: #selector(self.syncNewestLogs), on: self) - } - } - } - - - // MARK: - Partial Update History - - @objc private func syncNewestLogs() { - dataA_mergeInsert(); - if let lastXFilter = Pref.DateFilter.lastXMinTimestamp() { - if earliestEntry < lastXFilter { - dataA_mergeDelete(between: earliestEntry, and: lastXFilter) - earliestEntry = lastXFilter - } - } - } - - private func dataA_mergeInsert() { -#if !IOS_SIMULATOR - guard currentVPNState == .on else { return } -#endif - guard let res = AppDB?.domainList(since: latestModification + 1), res.count > 0 else { - return - } - QLog.Info("auto sync \(res.count) new logs") - Q.async(flags: .barrier) { - var c = 0 - for (parent, parts) in self.groupBySubdomains(res) { - if let i = self.dataA_index(of: parent.domain) { - self.dataB_mergeInsert(parent.domain, at: i, newChildren: parts) - - let merged = parent + self.dataA.remove(at: i) - self.dataA.insert(merged, at: c) - self.dataB.insert(self.dataB.remove(at: i), at: c) - self.dataA_delegate?.moveRow(merged, from: i, to: c) - } else { - self.dataA.insert(parent, at: c) - self.dataB.insert(parts, at: c) - self.dataA_delegate?.insertRow(parent, at: c) - self.dataB_delegate(parent.domain)?.replaceData(with: parts); - } - c += 1 - self.latestModification = max(parent.lastModified, self.latestModification) - } - } - } - - private func dataB_mergeInsert(_ dom: String, at index: Int, newChildren: [GroupedDomain]) { - let tvc = dataB_delegate(dom) - var i = 0 - for child in newChildren { - if let u = dataB[index].firstIndex(where: { $0.domain == child.domain }) { - let merged = child + dataB[index].remove(at: u) - dataB[index].insert(merged, at: i) - tvc?.moveRow(merged, from: u, to: i) - } else { - dataB[index].insert(child, at: i) - tvc?.insertRow(child, at: i) - } - i += 1 - } - } - - - // MARK: - Soft Delete History - // Will delete appearance only. DB still contains a copy. - - /// Technically not really deleting existing logs. Rather query DB for selected range and decrement whatever count is returned. - /// This means you should only run the delete operation once. As running multiple times will distrort the data. - /// - Parameters: - /// - between: Starting with and including - /// - and: Up until, exculding - private func dataA_mergeDelete(between: Timestamp, and: Timestamp) { - guard let res = AppDB?.domainList(between: between, and: and), res.count > 0 else { - return - } - QLog.Info("deleting \(res.count) old logs (soft delete)") - Q.async(flags: .barrier) { - for (parent, parts) in self.groupBySubdomains(res) { - guard let i = self.dataA_index(of: parent.domain) else { - continue // should never happen anyway - } - if parent.total < self.dataA[i].total { - self.dataB_mergeDelete(parent.domain, at: i, oldChildren: parts) - self.dataA[i] = self.dataA[i] - parent - self.dataA_delegate?.replaceRow(self.dataA[i], at: i) - } else { - self.dataA_delete(at: i, parentDomain: parent.domain) - } - } - } - } - - private func dataB_mergeDelete(_ dom: String, at index: Int, oldChildren: [GroupedDomain]) { - let tvc = dataB_delegate(dom) - for child in oldChildren { - guard let u = dataB[index].firstIndex(where: { $0.domain == child.domain }) else { - continue // should never happen anyway - } - if child.total < dataB[index][u].total { - dataB[index][u] = dataB[index][u] - child - tvc?.replaceRow(dataB[index][u], at: u) - } else { - dataB[index].remove(at: u) - tvc?.deleteRow(at: u) - } - } - } - - private func dataA_delete(at index: Int, parentDomain: String) { - dataA.remove(at: index) - dataB.remove(at: index) - dataA_delegate?.deleteRow(at: index) - dataB_delegate(parentDomain)?.replaceData(with: []) - } - - - // MARK: - Hard Delete History - // will delete DB content. No restore. - - func deleteHistory() { - DispatchQueue.global().async { - try? AppDB?.destroyContent() - AppDB?.vacuum() - self.dataAB_init() - } - } - - func deleteHistory(domain: String, since ts: Timestamp) { - DispatchQueue.global().async { - let modified = (try? AppDB?.deleteRows(matching: domain, since: max(ts, self.earliestEntry))) ?? 0 - guard modified > 0 else { - return // nothing has changed - } - QLog.Info("deleting \(modified) old logs (hard delete)") - AppDB?.vacuum() - self.Q.async(flags: .barrier) { - guard let index = self.dataA_index(of: domain) else { - return // nothing has changed - } - let parentDom = self.dataA[index].domain - guard let list = AppDB?.domainList(matching: parentDom, since: self.earliestEntry), - list.count > 0 else { - self.dataA_delete(at: index, parentDomain: parentDom) - return // nothing left, after deleting matching rows - } - // else: incremental update, replace whole list - self.dataA[index] = list.merge(parentDom, options: self.dataF[parentDom]) - self.dataA_delegate?.replaceRow(self.dataA[index], at: index) - self.dataB[index].removeAll() - for var child in list { - child.options = self.dataF[child.domain] - self.dataB[index].append(child) - } - self.dataB_delegate(parentDom)?.replaceData(with: self.dataB[index]) - } - } - } - - - // MARK: - Partial Update Filter - - func updateFilter(_ domain: String, add: FilterOptions) { - updateFilter(domain, set: (dataF[domain] ?? FilterOptions()).union(add)) - } - - func updateFilter(_ domain: String, remove: FilterOptions) { - updateFilter(domain, set: dataF[domain]?.subtracting(remove)) - } - - /// - Parameters: - /// - set: Remove a filter with `nil` or `.none` - private func updateFilter(_ domain: String, set: FilterOptions?) { - AppDB?.setFilter(domain, set) - Q.async(flags: .barrier) { - self.dataF[domain] = set - if let i = self.dataA_index(of: domain) { - if domain == self.dataA[i].domain { - self.dataA[i].options = (set == FilterOptions.none) ? nil : set - self.dataA_delegate?.replaceRow(self.dataA[i], at: i) - } - if let u = self.dataB[i].firstIndex(where: { $0.domain == domain }) { - self.dataB[i][u].options = (set == FilterOptions.none) ? nil : set - self.dataB_delegate(self.dataA[i].domain)?.replaceRow(self.dataB[i][u], at: u) - } - } - NotifyDNSFilterChanged.postAsyncMain() - } - } - - - // MARK: - Recordings - - func listOfRecordings() -> [Recording] { AppDB?.allRecordings() ?? [] } - func recordingGetCurrent() -> Recording? { AppDB?.ongoingRecording() } - func recordingStartNew() -> Recording? { try? AppDB?.startNewRecording() } - - func recordingStop(_ r: inout Recording) { AppDB?.stopRecording(&r) } - func recordingPersist(_ r: Recording) { AppDB?.persistRecordingLogs(r) } - func recordingDetails(_ r: Recording) -> [RecordLog] { AppDB?.getRecordingsLogs(r) ?? [] } - - func recordingUpdate(_ r: Recording) { - AppDB?.updateRecording(r) - NotifyRecordingChanged.post((r, false)) - } - - func recordingDelete(_ r: Recording) { - if (try? AppDB?.deleteRecording(r)) == true { - NotifyRecordingChanged.post((r, true)) - } - } - - func recordingDeleteDetails(_ r: Recording, domain: String?) -> Bool { - ((try? AppDB?.deleteRecordingLogs(r.id, matchingDomain: domain)) ?? 0) > 0 - } - - - // MARK: - Helper methods - - private func dataA_index(of domain: String) -> Int? { - dataA.firstIndex { domain.isSubdomain(of: $0.domain) } - } - - private func groupBySubdomains(_ allDomains: [GroupedDomain]) -> [(parent: GroupedDomain, parts: [GroupedDomain])] { - var i: Int = 0 - var indexOf: [String: Int] = [:] - var res: [(domain: String, list: [GroupedDomain])] = [] - for var x in allDomains { - let domain = x.domain.splitDomainAndHost().domain - x.options = dataF[x.domain] - if let y = indexOf[domain] { - res[y].list.append(x) - } else { - res.append((domain, [x])) - indexOf[domain] = i - i += 1 - } - } - return res.map { ($1.merge($0, options: self.dataF[$0]), $1) } - } -} - - -// MARK: - Test Data - -extension DBWrapper { - private func generateTestData() { - guard let db = AppDB else { return } - let deleted = (try? db.deleteRows(matching: "test.com")) ?? 0 - QLog.Debug("Deleting \(deleted) rows matching 'test.com'") - - QLog.Debug("Writing 33 test logs") - try? db.insertDNSQuery("keeptest.com", blocked: false) - for _ in 1...4 { try? db.insertDNSQuery("test.com", blocked: false) } - for _ in 1...7 { try? db.insertDNSQuery("i.test.com", blocked: false) } - for i in 1...8 { try? db.insertDNSQuery("b.test.com", blocked: i>5) } - for i in 1...13 { try? db.insertDNSQuery("bi.test.com", blocked: i%2==0) } - - QLog.Debug("Creating 4 filters") - db.setFilter("b.test.com", .blocked) - db.setFilter("i.test.com", .ignored) - db.setFilter("bi.test.com", [.blocked, .ignored]) - - QLog.Debug("Done") - } - - @objc private func insertRandomEntry() { - //QLog.Debug("Inserting 1 periodic log entry") - try? AppDB?.insertDNSQuery("\(arc4random() % 5).count.test.com", blocked: true) - } -} diff --git a/main/DB/SQDB.swift b/main/DB/SQDB.swift deleted file mode 100644 index 14af579..0000000 --- a/main/DB/SQDB.swift +++ /dev/null @@ -1,469 +0,0 @@ -import Foundation -import SQLite3 - -typealias Timestamp = Int64 - -struct FilterOptions: OptionSet { - let rawValue: Int32 - static let none = FilterOptions([]) - static let blocked = FilterOptions(rawValue: 1 << 0) - static let ignored = FilterOptions(rawValue: 1 << 1) - static let any = FilterOptions(rawValue: 0b11) -} - -enum SQLiteError: Error { - case OpenDatabase(message: String) - case Prepare(message: String) - case Step(message: String) - case Bind(message: String) -} - - -// MARK: - SQLiteDatabase - -class SQLiteDatabase { - private let dbPointer: OpaquePointer? - private init(dbPointer: OpaquePointer?) { - self.dbPointer = dbPointer - } - - fileprivate var errorMessage: String { - if let errorPointer = sqlite3_errmsg(dbPointer) { - let errorMessage = String(cString: errorPointer) - return errorMessage - } else { - return "No error message provided from sqlite." - } - } - - deinit { - sqlite3_close(dbPointer) - } - - static func destroyDatabase(path: String = URL.internalDB().relativePath) { - if FileManager.default.fileExists(atPath: path) { - do { try FileManager.default.removeItem(atPath: path) } - catch { print("Could not destroy database file: \(path)") } - } - } - -// static func export() throws -> URL { -// let fmt = DateFormatter() -// fmt.dateFormat = "yyyy-MM-dd" -// let dest = FileManager.default.exportDir().appendingPathComponent("\(fmt.string(from: Date()))-dns-log.sqlite") -// try? FileManager.default.removeItem(at: dest) -// try FileManager.default.copyItem(at: FileManager.default.internalDB(), to: dest) -// return dest -// } - - static func open(path: String = URL.internalDB().relativePath) throws -> SQLiteDatabase { - var db: OpaquePointer? - //sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_SHAREDCACHE, nil) - if sqlite3_open(path, &db) == SQLITE_OK { - return SQLiteDatabase(dbPointer: db) - } else { - defer { - if db != nil { - sqlite3_close(db) - } - } - if let errorPointer = sqlite3_errmsg(db) { - let message = String(cString: errorPointer) - throw SQLiteError.OpenDatabase(message: message) - } else { - throw SQLiteError.OpenDatabase(message: "No error message provided from sqlite.") - } - } - } - - func run(sql: String, bind: [DBBinding?] = [], step: (OpaquePointer) throws -> T) throws -> T { - var statement: OpaquePointer? - guard sqlite3_prepare_v2(dbPointer, sql, -1, &statement, nil) == SQLITE_OK, - let stmt = statement else { - throw SQLiteError.Prepare(message: errorMessage) - } - defer { sqlite3_finalize(stmt) } - var col: Int32 = 0 - for b in bind.compactMap({$0}) { - col += 1 - guard b.bind(stmt, col) == SQLITE_OK else { - throw SQLiteError.Bind(message: errorMessage) - } - } - return try step(stmt) - } - - func ifStep(_ stmt: OpaquePointer, _ expected: Int32) throws { - guard sqlite3_step(stmt) == expected else { - throw SQLiteError.Step(message: errorMessage) - } - } - - func createTable(table: SQLTable.Type) throws { - try run(sql: table.createStatement) { try ifStep($0, SQLITE_DONE) } - } - - func vacuum() { - try? run(sql: "VACUUM;") { try ifStep($0, SQLITE_DONE) } - } -} - -protocol SQLTable { - static var createStatement: String { get } -} - - -// MARK: - Bindings - -protocol DBBinding { - func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32 -} - -struct BindInt32 : DBBinding { - let raw: Int32 - init(_ value: Int32) { raw = value } - func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32 { sqlite3_bind_int(stmt, col, raw) } -} - -struct BindInt64 : DBBinding { - let raw: sqlite3_int64 - init(_ value: sqlite3_int64) { raw = value } - func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32 { sqlite3_bind_int64(stmt, col, raw) } -} - -struct BindText : DBBinding { - let raw: String - init(_ value: String) { raw = value } - func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32 { sqlite3_bind_text(stmt, col, (raw as NSString).utf8String, -1, nil) } -} - -struct BindTextOrNil : DBBinding { - let raw: String? - init(_ value: String?) { raw = value } - func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32 { sqlite3_bind_text(stmt, col, (raw == nil) ? nil : (raw! as NSString).utf8String, -1, nil) } -} - -// MARK: - Easy Access func - -private extension SQLiteDatabase { - func readText(_ stmt: OpaquePointer, _ col: Int32) -> String? { - let val = sqlite3_column_text(stmt, col) - return (val != nil ? String(cString: val!) : nil) - } - - func allRows(_ stmt: OpaquePointer, _ fn: (OpaquePointer) -> T) -> [T] { - var r: [T] = [] - while (sqlite3_step(stmt) == SQLITE_ROW) { r.append(fn(stmt)) } - return r - } - - func allRowsKeyed(_ stmt: OpaquePointer, _ fn: (OpaquePointer) -> (key: T, value: U)) -> [T:U] { - var r: [T:U] = [:] - while (sqlite3_step(stmt) == SQLITE_ROW) { let (k,v) = fn(stmt); r[k] = v } - return r - } -} - -extension SQLiteDatabase { - func initScheme() { - try? self.createTable(table: DNSQueryT.self) - try? self.createTable(table: DNSFilterT.self) - try? self.createTable(table: Recording.self) - try? self.createTable(table: RecordingLog.self) - } -} - - -// MARK: - DNSQueryT - -private struct DNSQueryT: SQLTable { - let ts: Timestamp - let domain: String - let wasBlocked: Bool - let options: FilterOptions - static var createStatement: String { - return """ - CREATE TABLE IF NOT EXISTS req( - ts INTEGER DEFAULT (strftime('%s','now')), - domain TEXT NOT NULL, - logOpt INTEGER DEFAULT 0 - ); - """ - } -} - -struct GroupedDomain { - let domain: String, total: Int32, blocked: Int32, lastModified: Timestamp - var options: FilterOptions? = nil -} - -extension SQLiteDatabase { - - // MARK: insert - - func insertDNSQuery(_ domain: String, blocked: Bool) throws { - try? run(sql: "INSERT INTO req (domain, logOpt) VALUES (?, ?);", - bind: [BindText(domain), BindInt32(blocked ? 1 : 0)]) { - try ifStep($0, SQLITE_DONE) - } - } - - // MARK: delete - - func destroyContent() throws { - try? run(sql: "DROP TABLE IF EXISTS req;") { try ifStep($0, SQLITE_DONE) } - try? createTable(table: DNSQueryT.self) - } - - /// Delete rows matching `ts >= ? AND "domain" OR "*.domain"` - @discardableResult func deleteRows(matching domain: String, since ts: Timestamp = 0) throws -> Int32 { - try run(sql: "DELETE FROM req WHERE ts >= ? AND (domain = ? OR domain LIKE '%.' || ?);", - bind: [BindInt64(ts), BindText(domain), BindText(domain)]) { stmt -> Int32 in - try ifStep(stmt, SQLITE_DONE) - return sqlite3_changes(dbPointer) - } - } - - // MARK: read - - private func allDomainsGrouped(_ clause: String = "", bind: [DBBinding?] = []) -> [GroupedDomain]? { - try? run(sql: "SELECT domain, COUNT(*), SUM(logOpt&1), MAX(ts) FROM req \(clause) GROUP BY domain ORDER BY 4 DESC;", bind: bind) { - allRows($0) { - GroupedDomain(domain: readText($0, 0) ?? "", - total: sqlite3_column_int($0, 1), - blocked: sqlite3_column_int($0, 2), - lastModified: sqlite3_column_int64($0, 3)) - } - } - } - - func domainList(since ts: Timestamp = 0) -> [GroupedDomain]? { - ts==0 ? allDomainsGrouped() : allDomainsGrouped("WHERE ts >= ?", bind: [BindInt64(ts)]) - } - - /// Get grouped domains matching `ts >= ? AND "domain" OR "*.domain"` - func domainList(matching domain: String, since ts: Timestamp = 0) -> [GroupedDomain]? { - allDomainsGrouped("WHERE ts >= ? AND (domain = ? OR domain LIKE '%.' || ?)", - bind: [BindInt64(ts), BindText(domain), BindText(domain)]) - } - - /// From `ts1` (including) and up to `ts2` (excluding). `ts1 >= X < ts2` - func domainList(between ts1: Timestamp, and ts2: Timestamp) -> [GroupedDomain]? { - allDomainsGrouped("WHERE ts >= ? AND ts < ?", bind: [BindInt64(ts1), BindInt64(ts2)]) - } - - func timesForDomain(_ fullDomain: String, since ts: Timestamp = 0) -> [GroupedTsOccurrence]? { - try? run(sql: "SELECT ts, COUNT(ts), SUM(logOpt>0) FROM req WHERE ts >= ? AND domain = ? GROUP BY ts;", - bind: [BindInt64(ts), BindText(fullDomain)]) { - allRows($0) { - (sqlite3_column_int64($0, 0), sqlite3_column_int($0, 1), sqlite3_column_int($0, 2)) - } - } - } -} - -typealias GroupedTsOccurrence = (ts: Timestamp, total: Int32, blocked: Int32) - - -// MARK: - DNSFilterT - -private struct DNSFilterT: SQLTable { - let domain: String - let options: FilterOptions - static var createStatement: String { - return """ - CREATE TABLE IF NOT EXISTS filter( - domain TEXT UNIQUE NOT NULL, - opt INTEGER DEFAULT 0 - ); - """ - } -} - -extension SQLiteDatabase { - - // MARK: read - - func loadFilters() -> [String : FilterOptions]? { - try? run(sql: "SELECT domain, opt FROM filter;") { - allRowsKeyed($0) { - (key: readText($0, 0) ?? "", - value: FilterOptions(rawValue: sqlite3_column_int($0, 1))) - } - } - } - - // MARK: write - - func setFilter(_ domain: String, _ value: FilterOptions?) { - func removeFilter() { - try? run(sql: "DELETE FROM filter WHERE domain = ? LIMIT 1;", - bind: [BindText(domain)]) { stmt -> Void in - sqlite3_step(stmt) - } - } - guard let rv = value?.rawValue, rv > 0 else { - removeFilter() - return - } - func createFilter() throws { - try run(sql: "INSERT OR FAIL INTO filter (domain, opt) VALUES (?, ?);", - bind: [BindText(domain), BindInt32(rv)]) { - try ifStep($0, SQLITE_DONE) - } - } - func updateFilter() { - try? run(sql: "UPDATE filter SET opt = ? WHERE domain = ? LIMIT 1;", - bind: [BindInt32(rv), BindText(domain)]) { stmt -> Void in - sqlite3_step(stmt) - } - } - do { try createFilter() } catch { updateFilter() } - } -} - - -// MARK: - Recordings - -struct Recording: SQLTable { - let id: sqlite3_int64 - let start: Timestamp - let stop: Timestamp? - var appId: String? = nil - var title: String? = nil - var notes: String? = nil - static var createStatement: String { - return """ - CREATE TABLE IF NOT EXISTS rec( - id INTEGER PRIMARY KEY, - start INTEGER DEFAULT (strftime('%s','now')), - stop INTEGER, - appid TEXT, - title TEXT, - notes TEXT - ); - """ - } -} - -extension SQLiteDatabase { - - // MARK: write - - func startNewRecording() throws -> Recording { - try run(sql: "INSERT INTO rec (stop) VALUES (NULL);") { stmt -> Recording in - try ifStep(stmt, SQLITE_DONE) - return try getRecording(withID: sqlite3_last_insert_rowid(dbPointer)) - } - } - - func stopRecording(_ r: inout Recording) { - guard r.stop == nil else { return } - let theID = r.id - try? run(sql: "UPDATE rec SET stop = (strftime('%s','now')) WHERE id = ? LIMIT 1;", - bind: [BindInt64(theID)]) { stmt -> Void in - try ifStep(stmt, SQLITE_DONE) - r = try getRecording(withID: theID) - } - } - - func updateRecording(_ r: Recording) { - try? run(sql: "UPDATE rec SET title = ?, appid = ?, notes = ? WHERE id = ? LIMIT 1;", - bind: [BindTextOrNil(r.title), BindTextOrNil(r.appId), BindTextOrNil(r.notes), BindInt64(r.id)]) { stmt -> Void in - sqlite3_step(stmt) - } - } - - func deleteRecording(_ r: Recording) throws -> Bool { - _ = try? deleteRecordingLogs(r.id) - return try run(sql: "DELETE FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(r.id)]) { - try ifStep($0, SQLITE_DONE) - return sqlite3_changes(dbPointer) > 0 - } - } - - // MARK: read - - func readRecording(_ stmt: OpaquePointer) -> Recording { - let end = sqlite3_column_int64(stmt, 2) - return Recording(id: sqlite3_column_int64(stmt, 0), - start: sqlite3_column_int64(stmt, 1), - stop: end == 0 ? nil : end, - appId: readText(stmt, 3), - title: readText(stmt, 4), - notes: readText(stmt, 5)) - } - - func ongoingRecording() -> Recording? { - try? run(sql: "SELECT * FROM rec WHERE stop IS NULL LIMIT 1;") { - try ifStep($0, SQLITE_ROW) - return readRecording($0) - } - } - - func allRecordings() -> [Recording]? { - try? run(sql: "SELECT * FROM rec WHERE stop IS NOT NULL;") { - allRows($0) { readRecording($0) } - } - } - - func getRecording(withID: sqlite3_int64) throws -> Recording { - try run(sql: "SELECT * FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(withID)]) { - try ifStep($0, SQLITE_ROW) - return readRecording($0) - } - } -} - -// MARK: - -private struct RecordingLog: SQLTable { - let rID: Int32 - let ts: Timestamp - let domain: String - static var createStatement: String { - return """ - CREATE TABLE IF NOT EXISTS recLog( - rid INTEGER REFERENCES rec(id) ON DELETE CASCADE, - ts INTEGER, - domain TEXT - ); - """ - } -} - -extension SQLiteDatabase { - - // MARK: write - - func persistRecordingLogs(_ r: Recording) { - guard let end = r.stop else { - return - } - try? run(sql: """ - INSERT INTO recLog (rid, ts, domain) SELECT ?, ts, domain FROM req - WHERE req.ts >= ? AND req.ts <= ? - """, bind: [BindInt64(r.id), BindInt64(r.start), BindInt64(end)]) { - try ifStep($0, SQLITE_DONE) - } - } - - func deleteRecordingLogs(_ recId: sqlite3_int64, matchingDomain d: String? = nil) throws -> Int32 { - try run(sql: "DELETE FROM recLog WHERE rid = ? \(d==nil ? "" : "AND domain = ?");", - bind: [BindInt64(recId), d==nil ? nil : BindText(d!)]) { - try ifStep($0, SQLITE_DONE) - return sqlite3_changes(dbPointer) - } - } - - // MARK: read - - func getRecordingsLogs(_ r: Recording) -> [RecordLog]? { - try? run(sql: "SELECT domain, COUNT() FROM recLog WHERE rid = ? GROUP BY domain;", - bind: [BindInt64(r.id)]) { - allRows($0) { (readText($0, 0), sqlite3_column_int($0, 1)) } - } - } -} - -typealias RecordLog = (domain: String?, count: Int32) diff --git a/main/Data Source/DomainFilter.swift b/main/Data Source/DomainFilter.swift new file mode 100644 index 0000000..dd37e1f --- /dev/null +++ b/main/Data Source/DomainFilter.swift @@ -0,0 +1,51 @@ +import Foundation + +enum DomainFilter { + static private var data: [String: FilterOptions] = { + AppDB?.loadFilters() ?? [:] + }() + + /// Get filter with given `domain` name + @inline(__always) static subscript(_ domain: String) -> FilterOptions? { + data[domain] + } + + /// Update local memory object by loading values from persistent db. + /// - Note: Will trigger `NotifyDNSFilterChanged` notification. + static func reload() { + data = AppDB?.loadFilters() ?? [:] + NotifyDNSFilterChanged.post() + } + + /// Get list of domains (sorted by name) which do contain the given filter + static func list(where matching: FilterOptions) -> [String] { + data.compactMap { $1.contains(matching) ? $0 : nil }.sorted() + } + + /// Get total number of blocked and ignored domains. Shown in settings overview. + static func counts() -> (blocked: Int, ignored: Int) { + data.reduce(into: (0, 0)) { + if $1.1.contains(.blocked) { $0.0 += 1 } + if $1.1.contains(.ignored) { $0.1 += 1 } } + } + + /// Union `filter` with set. + /// - Note: Will trigger `NotifyDNSFilterChanged` notification. + static func update(_ domain: String, add filter: FilterOptions) { + update(domain, set: (data[domain] ?? FilterOptions()).union(filter)) + } + + /// Subtract `filter` from set. + /// - Note: Will trigger `NotifyDNSFilterChanged` notification. + static func update(_ domain: String, remove filter: FilterOptions) { + update(domain, set: data[domain]?.subtracting(filter)) + } + + /// Update persistent db, local memory object, and post notification to subscribers + /// - Parameter set: Remove a filter with `nil` or `.none` + static private func update(_ domain: String, set: FilterOptions?) { + AppDB?.setFilter(domain, set) + data[domain] = (set == FilterOptions.none) ? nil : set + NotifyDNSFilterChanged.post(domain) + } +} diff --git a/main/Data Source/GroupedDomainDataSource.swift b/main/Data Source/GroupedDomainDataSource.swift new file mode 100644 index 0000000..b5383de --- /dev/null +++ b/main/Data Source/GroupedDomainDataSource.swift @@ -0,0 +1,250 @@ +import UIKit + +// ########################## +// # +// # MARK: DataSource +// # +// ########################## + +class GroupedDomainDataSource { + + private var tsLatest: Timestamp = 0 + + private let parent: String? + let pipeline: FilterPipeline + + init(withDelegate tvc: FilterPipelineDelegate, parent p: String?) { + parent = p + pipeline = .init(withDelegate: tvc) + pipeline.setDataSource { [unowned self] in self.dataSourceCallback() } + pipeline.setSorting { + $0.lastModified > $1.lastModified + } + if #available(iOS 10.0, *) { + tvc.tableView.refreshControl = UIRefreshControl(call: #selector(reloadFromSource), on: self) + } + NotifyLogHistoryReset.observe(call: #selector(reloadFromSource), on: self) + NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self) + NotifySyncInsert.observe(call: #selector(syncInsert), on: self) + NotifySyncRemove.observe(call: #selector(syncRemove), on: self) + } + + /// Callback fired only when pipeline resets data source + private func dataSourceCallback() -> [GroupedDomain] { + guard let db = AppDB else { return [] } + let earliest = sync.tsEarliest + tsLatest = earliest + var log = db.dnsLogsGrouped(since: earliest, parentDomain: parent) ?? [] + for (i, val) in log.enumerated() { + log[i].options = DomainFilter[val.domain] + tsLatest = max(tsLatest, val.lastModified) + } + return log + } + + /// Pause recurring background updates to force reload `dataSource`. + /// Callback fired on user action `pull-to-refresh`, or another background task triggered `NotifyLogHistoryReset`. + /// - Parameter sender: May be either `UIRefreshControl` or `Notification` + /// (optional: pass single domain as the notification object). + @objc func reloadFromSource(sender: Any? = nil) { + weak var refreshControl = sender as? UIRefreshControl + let notification = sender as? Notification + sync.pause() + if let affectedDomain = notification?.object as? String { + partiallyReloadFromSource(affectedDomain) + sync.start() + } else { + pipeline.reload(fromSource: true, whenDone: { + sync.start() + refreshControl?.endRefreshing() + }) + } + } + + /// Callback fired when user editslist of `blocked` or `ignored` domains in settings. (`NotifyDNSFilterChanged` notification) + @objc private func didChangeDomainFilter(_ notification: Notification) { + guard let domain = notification.object as? String else { + reloadFromSource() + return + } + if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == domain }) { + var y = obj + y.options = DomainFilter[domain] + pipeline.update(y, at: i) + } + } + + + // MARK: Table View Data Source + + @inline(__always) var numberOfRows: Int { get { pipeline.displayObjectCount() } } + + @inline(__always) subscript(_ row: Int) -> GroupedDomain { pipeline.displayObject(at: row) } + + + // MARK: partial updates + + /// Callback fired when background sync added new entries to the list. (`NotifySyncInsert` notification) + @objc private func syncInsert(_ notification: Notification) { + let range = notification.object as! SQLiteRowRange + guard let latest = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent) else { + assertionFailure("NotifySyncInsert fired with empty range") + return + } + for x in latest { + if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) { + pipeline.update(obj + x, at: i) + } else { + var y = x + y.options = DomainFilter[x.domain] + pipeline.addNew(y) + } + tsLatest = max(tsLatest, x.lastModified) + } + } + + /// Callback fired when background sync removed old entries from the list. (`NotifySyncRemove` notification) + @objc private func syncRemove(_ notification: Notification) { + let range = notification.object as! SQLiteRowRange + guard let outdated = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent), + outdated.count > 0 else { + assertionFailure("NotifySyncRemove fired with empty range") + return + } + var listOfDeletes: [Int] = [] + for x in outdated { + guard let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) else { + assertionFailure("Try to remove non-existent element") + continue // should never happen + } + if obj.total > x.total { + pipeline.update(obj - x, at: i) + } else { + listOfDeletes.append(i) + } + } + pipeline.remove(indices: listOfDeletes.sorted()) + } +} + + +// ################################ +// # +// # MARK: - Delete History +// # +// ################################ + +extension GroupedDomainDataSource { + + /// Callback fired when user performs row edit -> delete action + func deleteHistory(domain: String, since ts: Timestamp) { + let flag = (parent != nil) + DispatchQueue.global().async { + guard let db = AppDB, db.dnsLogsDelete(domain, strict: flag, since: ts) > 0 else { + return // nothing has changed + } + db.vacuum() + NotifyLogHistoryReset.postAsyncMain(domain) // calls deleteReloadFromSource(:) + } + } + + /// Reload a single data source entry. Callback fired by `reloadFromSource()` + private func partiallyReloadFromSource(_ affectedFQDN: String) { + let affectedParent = affectedFQDN.extractDomain() + guard parent == nil || parent == affectedParent else { + return // does not affect current table + } + let affected = (parent == nil ? affectedParent : affectedFQDN) + guard let old = pipeline.dataSourceGet(where: { $0.domain == affected }) else { + // can only happen if delete sheet is open while background sync removed the element + return + } + var removeOld = true + if let new = AppDB?.dnsLogsGrouped(since: sync.tsEarliest, upto: tsLatest, matchingDomain: affected, parentDomain: parent) { + assert(new.count < 2) + for var x in new { + x.options = DomainFilter[x.domain] + if old.object.domain == x.domain { + pipeline.update(x, at: old.index) + removeOld = false + } else { + pipeline.addNew(x) + } + } + } + if removeOld { pipeline.remove(indices: [old.index]) } + } +} + + +// ########################## +// # +// # MARK: - Edit Row +// # +// ########################## + +protocol GroupedDomainEditRow : EditableRows, FilterPipelineDelegate { + var source: GroupedDomainDataSource { get set } +} + +extension GroupedDomainEditRow { + + func editableRowActions(_ index: IndexPath) -> [(RowAction, String)] { + let x = source[index.row] + if x.domain.starts(with: "#") { + return [(.delete, "Delete")] + } + let b = x.options?.contains(.blocked) ?? false + let i = x.options?.contains(.ignored) ?? false + return [(.delete, "Delete"), (.block, b ? "Unblock" : "Block"), (.ignore, i ? "Unignore" : "Ignore")] + } + + func editableRowActionColor(_: IndexPath, _ action: RowAction) -> UIColor? { + action == .block ? .systemOrange : nil + } + + func editableRowUserInfo(_ index: IndexPath) -> Any? { source[index.row] } + + func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool { + let entry = userInfo as! GroupedDomain + switch action { + case .ignore: showFilterSheet(entry, .ignored) + case .block: showFilterSheet(entry, .blocked) + case .delete: + AlertDeleteLogs(entry.domain, latest: entry.lastModified) { + self.source.deleteHistory(domain: entry.domain, since: $0) + }.presentIn(self) + } + return true + } + + private func showFilterSheet(_ entry: GroupedDomain, _ filter: FilterOptions) { + if entry.options?.contains(filter) ?? false { + DomainFilter.update(entry.domain, remove: filter) + } else { + // TODO: alert sheet + DomainFilter.update(entry.domain, add: filter) + } + } +} + +// MARK: Extensions +extension TVCDomains : GroupedDomainEditRow { + override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { + getRowActionsIOS9(indexPath) + } + @available(iOS 11.0, *) + override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + getRowActionsIOS11(indexPath) + } +} + +extension TVCHosts : GroupedDomainEditRow { + override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { + getRowActionsIOS9(indexPath) + } + @available(iOS 11.0, *) + override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + getRowActionsIOS11(indexPath) + } +} diff --git a/main/Data Source/RecordingsDB.swift b/main/Data Source/RecordingsDB.swift new file mode 100644 index 0000000..2246033 --- /dev/null +++ b/main/Data Source/RecordingsDB.swift @@ -0,0 +1,43 @@ +import Foundation + +enum RecordingsDB { + /// Get last started recording (where `start` is set, but `stop` is not) + static func getCurrent() -> Recording? { AppDB?.recordingGetOngoing() } + + /// Create new recording and set `start` timestamp to `now()` + static func startNew() -> Recording? { try? AppDB?.recordingStartNew() } + + /// Finalize recording by setting the `stop` timestamp to `now()` + static func stop(_ r: inout Recording) { AppDB?.recordingStop(&r) } + + /// Get list of all recordings + static func list() -> [Recording] { AppDB?.recordingGetAll() ?? [] } + + /// Copy log entries from generic `heap` table to recording specific `recLog` table + static func persist(_ r: Recording) { AppDB?.recordingLogsPersist(r) } + + /// Get list of domains that occured during the recording + static func details(_ r: Recording) -> [RecordLog] { + AppDB?.recordingLogsGetGrouped(r) ?? [] + } + + /// Update `title`, `appid`, and `notes` and post `NotifyRecordingChanged` notification. + static func update(_ r: Recording) { + AppDB?.recordingUpdate(r) + NotifyRecordingChanged.post((r, false)) + } + + /// Delete whole recording including all entries and post `NotifyRecordingChanged` notification. + static func delete(_ r: Recording) { + if (try? AppDB?.recordingDelete(r)) == true { + NotifyRecordingChanged.post((r, true)) + } + } + + /// Delete individual entries from recording while keeping the recording alive. + /// - Returns: `true` if at least one row is deleted. + static func deleteDetails(_ r: Recording, domain: String) -> Bool { + ((try? AppDB?.recordingLogsDelete(r.id, matchingDomain: domain)) ?? 0) > 0 + } +} + diff --git a/main/Data Source/SyncUpdate.swift b/main/Data Source/SyncUpdate.swift new file mode 100644 index 0000000..ffbbf21 --- /dev/null +++ b/main/Data Source/SyncUpdate.swift @@ -0,0 +1,44 @@ +import Foundation + +class SyncUpdate { + private var timer: Timer! + private var paused: Int = 1 // first start() will decrement + private(set) var tsEarliest: Timestamp + + init(periodic interval: TimeInterval) { + tsEarliest = Pref.DateFilter.lastXMinTimestamp() ?? 0 + NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self) + timer = Timer.repeating(interval, call: #selector(periodicUpdate), on: self) + } + + @objc private func didChangeDateFilter() { + let lastXFilter = Pref.DateFilter.lastXMinTimestamp() ?? 0 + if tsEarliest < lastXFilter { + if let excess = AppDB?.dnsLogsRowRange(between: tsEarliest, and: lastXFilter) { + NotifySyncRemove.post(excess) + } + } else if tsEarliest > lastXFilter { + if let missing = AppDB?.dnsLogsRowRange(between: lastXFilter, and: tsEarliest) { + NotifySyncInsert.post(missing) + } + } + tsEarliest = lastXFilter + } + + func pause() { paused += 1 } + func start() { if paused > 0 { paused -= 1 } } + + @objc private func periodicUpdate() { + guard paused == 0, let db = AppDB else { return } + if let inserted = db.dnsLogsPersist() { // move cache -> heap + NotifySyncInsert.post(inserted) + } + if let lastXFilter = Pref.DateFilter.lastXMinTimestamp(), tsEarliest < lastXFilter { + if let removed = db.dnsLogsRowRange(between: tsEarliest, and: lastXFilter) { + NotifySyncRemove.post(removed) + } + tsEarliest = lastXFilter + } + // TODO: periodic hard delete old logs (will reset rowids!) + } +} diff --git a/main/Data Source/TestDataSource.swift b/main/Data Source/TestDataSource.swift new file mode 100644 index 0000000..cb0970b --- /dev/null +++ b/main/Data Source/TestDataSource.swift @@ -0,0 +1,41 @@ +import Foundation + +#if IOS_SIMULATOR + +private let db = AppDB! +private var pStmt: OpaquePointer? + +class TestDataSource { + + static func load() { + QLog.Debug("SQLite path: \(URL.internalDB())") + + let deleted = db.dnsLogsDelete("test.com", strict: false) + QLog.Debug("Deleting \(deleted) rows matching 'test.com'") + + QLog.Debug("Writing 33 test logs") + pStmt = try! db.logWritePrepare() + try? db.logWrite(pStmt, "keeptest.com", blocked: false) + for _ in 1...4 { try? db.logWrite(pStmt, "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(pStmt, "b.test.com", blocked: i>5) } + for i in 1...13 { try? db.logWrite(pStmt, "bi.test.com", blocked: i%2==0) } + + db.dnsLogsPersist() + + QLog.Debug("Creating 4 filters") + db.setFilter("b.test.com", .blocked) + db.setFilter("i.test.com", .ignored) + db.setFilter("bi.test.com", [.blocked, .ignored]) + + QLog.Debug("Done") + + Timer.repeating(2, call: #selector(insertRandom), on: self) + } + + @objc static func insertRandom() { + //QLog.Debug("Inserting 1 periodic log entry") + try? db.logWrite(pStmt, "\(arc4random() % 5).count.test.com", blocked: true) + } +} +#endif diff --git a/main/Extensions/AlertSheet.swift b/main/Extensions/AlertSheet.swift index 7282f10..fd5c300 100644 --- a/main/Extensions/AlertSheet.swift +++ b/main/Extensions/AlertSheet.swift @@ -1,8 +1,8 @@ import UIKit extension UIAlertController { - func presentIn(_ viewController: UIViewController?) { - viewController?.present(self, animated: true) + func presentIn(_ viewController: UIViewController) { + viewController.present(self, animated: true) } } diff --git a/main/Extensions/Array.swift b/main/Extensions/Array.swift new file mode 100644 index 0000000..cc87678 --- /dev/null +++ b/main/Extensions/Array.swift @@ -0,0 +1,77 @@ +import Foundation + +//extension Collection { +// subscript(ifExist i: Index?) -> Iterator.Element? { +// guard let i = i else { return nil } +// return indices.contains(i) ? self[i] : nil +// } +//} + +extension Range where Bound == Int { + @inline(__always) func arr() -> [Bound] { self.map { $0 } } +} + + +// MARK: - Sorted Array + +extension Array { + typealias CompareFn = (Element, Element) -> Bool + + /// Binary tree search operation. + /// - Warning: Array must be sorted already. + /// - Parameter mustExist: Determine whether to return low index or `nil` if element is missing. + /// - Returns: Index or `nil` (only if `mustExist = true` and element does not exist). + /// - Complexity: O(log *n*), where *n* is the length of the array. + func binTreeIndex(of element: Element, compare fn: CompareFn, mustExist: Bool = false) -> Int? { + var lo = 0, hi = self.count - 1 + while lo <= hi { + let mid = (lo + hi)/2 + if fn(self[mid], element) { + lo = mid + 1 + } else if fn(element, self[mid]) { + hi = mid - 1 + } else { + return mid + } + } + return mustExist ? nil : lo // not found, would be inserted at position lo + } + + /// Binary tree insert operation + /// - Warning: Array must be sorted already. + /// - Returns: Index at which `elem` was inserted + /// - Complexity: O(log *n*), where *n* is the length of the array. + @discardableResult mutating func binTreeInsert(_ elem: Element, compare fn: CompareFn) -> Int { + let newIndex = binTreeIndex(of: elem, compare: fn)! + insert(elem, at: newIndex) + return newIndex + } + + /// Binary tree remove operation + /// - Warning: Array must be sorted already. + /// - Returns: Index of removed `elem` or `nil` if it does not exist + /// - Complexity: O(log *n*), where *n* is the length of the array. + @discardableResult mutating func binTreeRemove(_ elem: Element, compare fn: CompareFn) -> Int? { + if let i = binTreeIndex(of: elem, compare: fn, mustExist: true) { + remove(at: i) + return i + } + return nil + } + + /// Sorted synchronous comparison between elements + /// - Parameter sortedSubset: Must be a strict subset of the sorted array. + /// - Returns: List of elements that are **not** present in `sortedSubset`. + /// - Complexity: O(*m*+*n*), where *n* is the length of the array and *m* the length of the `sortedSubset`. + /// If indices are found earlier, *n* may be significantly less (on average: `n/2`) + func difference(toSubset sortedSubset: [Element], compare fn: CompareFn) -> [Element] { + var result: [Element] = [] + var iter = makeIterator() + for rhs in sortedSubset { + while let lhs = iter.next(), fn(lhs, rhs) { + result.append(lhs) + } + } + return result + } +} diff --git a/main/Extensions/Generic.swift b/main/Extensions/Generic.swift index dec249a..14ae5c5 100644 --- a/main/Extensions/Generic.swift +++ b/main/Extensions/Generic.swift @@ -16,102 +16,6 @@ struct QLog { } } -extension Collection { - subscript(ifExist i: Index?) -> Iterator.Element? { - guard let i = i else { return nil } - return indices.contains(i) ? self[i] : nil - } -} - -var listOfSLDs: [String : [String : Bool]] = { - let path = Bundle.main.url(forResource: "third-level", withExtension: "txt") - let content = try! String(contentsOf: path!) - var res: [String : [String : Bool]] = [:] - content.enumerateLines { line, _ in - let dom = line.split(separator: ".") - let tld = String(dom.first!) - let sld = String(dom.last!) - if res[tld] == nil { res[tld] = [:] } - res[tld]![sld] = true - } - return res -}() - -extension String { - /// Check if string is equal to `domain` or ends with `.domain` - func isSubdomain(of domain: String) -> Bool { self == domain || self.hasSuffix("." + domain) } - /// Split string into top level domain part and host part - func splitDomainAndHost() -> (domain: String, host: String?) { - let lastChr = last?.asciiValue ?? 0 - guard lastChr > UInt8(ascii: "9") || lastChr < UInt8(ascii: "0") else { // IP address - return (domain: "# IP connection", host: self) - } - var parts = components(separatedBy: ".") - guard let tld = parts.popLast(), let sld = parts.popLast() else { - return (domain: self, host: nil) // no subdomains, just plain SLD - } - var ending = sld + "." + tld - if listOfSLDs[tld]?[sld] ?? false, let rld = parts.popLast() { - ending = rld + "." + ending - } - return (domain: ending, host: parts.joined(separator: ".")) - } - /// Returns `true` if String matches list of known second level domains (e.g., `co.uk`). - func isKnownSLD() -> Bool { - let parts = components(separatedBy: ".") - return parts.count == 2 && listOfSLDs[parts.last!]?[parts.first!] ?? false - } -} - -extension Timer { - @discardableResult static func repeating(_ interval: TimeInterval, call selector: Selector, on target: Any, userInfo: Any? = nil) -> Timer { - Timer.scheduledTimer(timeInterval: interval, target: target, selector: selector, - userInfo: userInfo, repeats: true) - } -} - -extension DateFormatter { - convenience init(withFormat: String) { - self.init() - dateFormat = withFormat - } - func with(format: String) -> Self { - dateFormat = format - return self - } - func string(from ts: Timestamp) -> String { - string(from: Date.init(timeIntervalSince1970: Double(ts))) - } -} - -struct TimeFormat { - static func from(_ duration: Timestamp) -> String { - String(format: "%02d:%02d", duration / 60, duration % 60) - } - static func from(_ duration: TimeInterval, millis: Bool = false) -> String { - let t = Int(duration) - if millis { - let mil = Int(duration * 1000) % 1000 - return String(format: "%02d:%02d.%03d", t / 60, t % 60, mil) - } - return String(format: "%02d:%02d", t / 60, t % 60) - } - static func since(_ date: Date, millis: Bool = false) -> String { - from(Date().timeIntervalSince(date), millis: millis) - } - /// Formatted duration string, e.g., `20 min` or `7 days` - /// - Parameters: - /// - minutes: Duration in minutes - /// - style: Default: `.short` - static func short(minutes: Int, style: DateComponentsFormatter.UnitsStyle = .short) -> String? { - let dcf = DateComponentsFormatter() - dcf.maximumUnitCount = 1 - dcf.allowedUnits = [.day, .hour, .minute] - dcf.unitsStyle = style - return dcf.string(from: DateComponents(minute: minutes)) - } -} - extension UIColor { static var sysBg: UIColor { get { if #available(iOS 13.0, *) { return .systemBackground } else { return .white } }} static var sysFg: UIColor { get { if #available(iOS 13.0, *) { return .label } else { return .black } }} diff --git a/main/Extensions/Notifications.swift b/main/Extensions/Notifications.swift index dc4c8da..7a9d43c 100644 --- a/main/Extensions/Notifications.swift +++ b/main/Extensions/Notifications.swift @@ -1,9 +1,11 @@ import Foundation let NotifyVPNStateChanged = NSNotification.Name("GlassVPNStateChanged") // VPNState! -let NotifyDNSFilterChanged = NSNotification.Name("PSIDNSFilterSettingsChanged") // nil! +let NotifyDNSFilterChanged = NSNotification.Name("PSIDNSFilterSettingsChanged") // domain: String? let NotifyDateFilterChanged = NSNotification.Name("PSIDateFilterSettingsChanged") // nil! -let NotifyLogHistoryReset = NSNotification.Name("PSILogHistoryReset") // nil! +let NotifyLogHistoryReset = NSNotification.Name("PSILogHistoryReset") // domain: String? +let NotifySyncInsert = NSNotification.Name("PSISyncInsert") // SQLiteRowRange! +let NotifySyncRemove = NSNotification.Name("PSISyncRemove") // SQLiteRowRange! let NotifyRecordingChanged = NSNotification.Name("PSIRecordingChanged") // (Recording, deleted: Bool)! diff --git a/main/Extensions/SharedState.swift b/main/Extensions/SharedState.swift index 0f4dba9..70d70ec 100644 --- a/main/Extensions/SharedState.swift +++ b/main/Extensions/SharedState.swift @@ -1,7 +1,7 @@ import Foundation -let dateTimeFormat = DateFormatter(withFormat: "yyyy-MM-dd HH:mm:ss") var currentVPNState: VPNState = .off +let sync = SyncUpdate(periodic: 7) public enum VPNState : Int { case on = 1, inbetween, off diff --git a/main/Extensions/String.swift b/main/Extensions/String.swift new file mode 100644 index 0000000..dd075a0 --- /dev/null +++ b/main/Extensions/String.swift @@ -0,0 +1,51 @@ +import UIKit + +extension NSMutableAttributedString { + func withColor(_ color: UIColor, fromBack: Int) -> Self { + let l = length - fromBack + let r = (l < 0) ? NSMakeRange(0, length) : NSMakeRange(l, fromBack) + self.addAttribute(.foregroundColor, value: color, range: r) + return self + } +} + +extension String { + /// Check if string is equal to `domain` or ends with `.domain` + func isSubdomain(of domain: String) -> Bool { self == domain || self.hasSuffix("." + domain) } + + /// Extract second or third level domain name + func extractDomain() -> String { + let lastChr = last?.asciiValue ?? 0 + guard lastChr > UInt8(ascii: "9") || lastChr < UInt8(ascii: "0") else { // IP address + return "# IP" + } + var parts = components(separatedBy: ".") + guard let tld = parts.popLast(), let sld = parts.popLast() else { + return self // no subdomains, just plain SLD + } + if listOfSLDs[tld]?[sld] ?? false, let rld = parts.popLast() { + return rld + "." + sld + "." + tld + } + return sld + "." + tld + } + + /// Returns `true` if String matches list of known second level domains (e.g., `co.uk`). + func isKnownSLD() -> Bool { + let parts = components(separatedBy: ".") + return parts.count == 2 && listOfSLDs[parts.last!]?[parts.first!] ?? false + } +} + +var listOfSLDs: [String : [String : Bool]] = { + let path = Bundle.main.url(forResource: "third-level", withExtension: "txt") + let content = try! String(contentsOf: path!) + var res: [String : [String : Bool]] = [:] + content.enumerateLines { line, _ in + let dom = line.split(separator: ".") + let tld = String(dom.first!) + let sld = String(dom.last!) + if res[tld] == nil { res[tld] = [:] } + res[tld]![sld] = true + } + return res +}() diff --git a/main/Extensions/TableView.swift b/main/Extensions/TableView.swift index 5aba71e..176b543 100644 --- a/main/Extensions/TableView.swift +++ b/main/Extensions/TableView.swift @@ -1,58 +1,27 @@ import UIKit -extension GroupedDomain { - var detailCellText: String { get { - return blocked > 0 - ? "\(lastModified.asDateTime()) — \(blocked)/\(total) blocked" - : "\(lastModified.asDateTime()) — \(total)" - } - } -} - -extension FilterOptions { - func tableRowImage() -> UIImage? { - let blocked = contains(.blocked) - let ignored = contains(.ignored) - if blocked { return UIImage(named: ignored ? "block_ignore" : "shield-x") } - if ignored { return UIImage(named: "quicklook-not") } - return nil - } -} - -extension NSMutableAttributedString { - func withColor(_ color: UIColor, fromBack: Int) -> Self { - let l = length - fromBack - let r = (l < 0) ? NSMakeRange(0, length) : NSMakeRange(l, fromBack) - self.addAttribute(.foregroundColor, value: color, range: r) - return self - } -} - -// MARK: Pull-to-Refresh - -extension UIRefreshControl { - convenience init(call: Selector, on: UITableViewController) { - self.init() - addTarget(on, action: call, for: .valueChanged) - addTarget(self, action: #selector(endRefreshing), for: .valueChanged) - } - -} - -// MARK: TableView extensions - extension IndexPath { /// Convenience init with `section: 0` public init(row: Int) { self.init(row: row, section: 0) } } +extension UIRefreshControl { + convenience init(call: Selector, on target: Any) { + self.init() + addTarget(target, action: call, for: .valueChanged) + } +} + + +// MARK: - UITableView + extension UITableView { /// Returns `true` if this `tableView` is the currently frontmost visible var isFrontmost: Bool { window?.isKeyWindow ?? false } /// If frontmost window, perform `deleteRows()`; If not, perform `reloadData()` - func safeDeleteRow(_ index: Int, with animation: UITableView.RowAnimation = .automatic) { - isFrontmost ? deleteRows(at: [IndexPath(row: index)], with: animation) : reloadData() + func safeDeleteRows(_ indices: [Int], with animation: UITableView.RowAnimation = .automatic) { + isFrontmost ? deleteRows(at: indices.map {IndexPath(row: $0)}, with: animation) : reloadData() } /// If frontmost window, perform `reloadRows()`; If not, perform `reloadData()` func safeReloadRow(_ index: Int, with animation: UITableView.RowAnimation = .automatic) { @@ -69,71 +38,44 @@ extension UITableView { } -// MARK: - Incremental Update Delegate +// MARK: - EditableRows -enum IncrementalDataSourceUpdateOperation { - case ReloadTable, Update, Insert, Delete, Move +public enum RowAction { + case ignore, block, delete } -protocol IncrementalDataSourceUpdate : UITableViewController { - var dataSource: [GroupedDomain] { get set } - func shouldLiveUpdateIncrementalDataSource() -> Bool - /// - Warning: Called on a background thread! - /// - Parameters: - /// - operation: Row update action - /// - row: Which row index is affected? `IndexPath(row: row)` - /// - moveTo: Only set for `Move` operation, otherwise `-1` - func didUpdateIncrementalDataSource(_ operation: IncrementalDataSourceUpdateOperation, row: Int, moveTo: Int) +protocol EditableRows { + func editableRowUserInfo(_ index: IndexPath) -> Any? + func editableRowActions(_ index: IndexPath) -> [(RowAction, String)] + func editableRowActionColor(_ index: IndexPath, _ action: RowAction) -> UIColor? + @discardableResult func editableRowCallback(_ atIndexPath: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool } -extension IncrementalDataSourceUpdate { - func shouldLiveUpdateIncrementalDataSource() -> Bool { true } - func didUpdateIncrementalDataSource(_: IncrementalDataSourceUpdateOperation, row: Int, moveTo: Int) {} - // TODO: custom handling if cell is being edited - - func insertRow(_ obj: GroupedDomain, at index: Int) { - dataSource.insert(obj, at: index) - if shouldLiveUpdateIncrementalDataSource() { - DispatchQueue.main.sync { tableView.safeInsertRow(index, with: .left) } - } - didUpdateIncrementalDataSource(.Insert, row: index, moveTo: -1) - } - func moveRow(_ obj: GroupedDomain, from: Int, to: Int) { - dataSource.remove(at: from) - dataSource.insert(obj, at: to) - if shouldLiveUpdateIncrementalDataSource() { - DispatchQueue.main.sync { - if tableView.isFrontmost { - let source = IndexPath(row: from) - let cell = tableView.cellForRow(at: source) - cell?.detailTextLabel?.text = obj.detailCellText - tableView.moveRow(at: source, to: IndexPath(row: to)) - } else { - tableView.reloadData() - } +extension EditableRows where Self: UITableViewDelegate { + func getRowActionsIOS9(_ index: IndexPath) -> [UITableViewRowAction]? { + let userInfo = editableRowUserInfo(index) + return editableRowActions(index).compactMap { a,t in + let x = UITableViewRowAction(style: a == .delete ? .destructive : .normal, title: t) { self.editableRowCallback($1, a, userInfo) } + if let color = editableRowActionColor(index, a) { + x.backgroundColor = color } + return x } - didUpdateIncrementalDataSource(.Move, row: from, moveTo: to) } - func replaceRow(_ obj: GroupedDomain, at index: Int) { - dataSource[index] = obj - if shouldLiveUpdateIncrementalDataSource() { - DispatchQueue.main.sync { tableView.safeReloadRow(index) } - } - didUpdateIncrementalDataSource(.Update, row: index, moveTo: -1) - } - func deleteRow(at index: Int) { - dataSource.remove(at: index) - if shouldLiveUpdateIncrementalDataSource() { - DispatchQueue.main.sync { tableView.safeDeleteRow(index) } - } - didUpdateIncrementalDataSource(.Delete, row: index, moveTo: -1) - } - func replaceData(with newData: [GroupedDomain]) { - dataSource = newData - if shouldLiveUpdateIncrementalDataSource() { - DispatchQueue.main.sync { tableView.reloadData() } - } - didUpdateIncrementalDataSource(.ReloadTable, row: -1, moveTo: -1) + @available(iOS 11.0, *) + func getRowActionsIOS11(_ index: IndexPath) -> UISwipeActionsConfiguration? { + let userInfo = editableRowUserInfo(index) + return UISwipeActionsConfiguration(actions: editableRowActions(index).compactMap { a,t in + let x = UIContextualAction(style: a == .delete ? .destructive : .normal, title: t) { $2(self.editableRowCallback(index, a, userInfo)) } + x.backgroundColor = editableRowActionColor(index, a) + return x + }) } + func editableRowUserInfo(_ index: IndexPath) -> Any? { nil } +} + +protocol EditActionsRemove : EditableRows {} +extension EditActionsRemove where Self: UITableViewController { + func editableRowActions(_: IndexPath) -> [(RowAction, String)] { [(.delete, "Remove")] } + func editableRowActionColor(_: IndexPath, _: RowAction) -> UIColor? { nil } } diff --git a/main/Extensions/Time.swift b/main/Extensions/Time.swift new file mode 100644 index 0000000..d613167 --- /dev/null +++ b/main/Extensions/Time.swift @@ -0,0 +1,74 @@ +import Foundation + +private let dateTimeFormat = DateFormatter(withFormat: "yyyy-MM-dd HH:mm:ss") + +extension DateFormatter { + convenience init(withFormat: String) { + self.init() + dateFormat = withFormat + } +} + +extension Timestamp { + /// 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` + func toDate() -> Date { + Date(timeIntervalSince1970: Double(self)) + } + + /// Current time as `Timestamp` (second accuracy) + static func now() -> Timestamp { + Timestamp(Date().timeIntervalSince1970) + } + + /// Create `Timestamp` with `now() - minutes * 60` + static func past(minutes: Int) -> Timestamp { + now() - Timestamp(minutes * 60) + } +} + +extension Timer { + /// Recurring timer maintains a strong reference to `target`. + @discardableResult static func repeating(_ interval: TimeInterval, call selector: Selector, on target: Any, userInfo: Any? = nil) -> Timer { + Timer.scheduledTimer(timeInterval: interval, target: target, selector: selector, + userInfo: userInfo, repeats: true) + } +} + +struct TimeFormat { + /// Time string with format `HH:mm` + static func from(_ duration: Timestamp) -> String { + String(format: "%02d:%02d", duration / 60, duration % 60) + } + + /// Duration string with format `HH:mm` or `HH:mm.sss` + static func from(_ duration: TimeInterval, millis: Bool = false) -> String { + let t = Int(duration) + if millis { + let mil = Int(duration * 1000) % 1000 + return String(format: "%02d:%02d.%03d", t / 60, t % 60, mil) + } + return String(format: "%02d:%02d", t / 60, t % 60) + } + + /// Duration string with format `HH:mm` or `HH:mm.sss` since reference date + static func since(_ date: Date, millis: Bool = false) -> String { + from(Date().timeIntervalSince(date), millis: millis) + } + + /// Formatted duration string, e.g., `20 min` or `7 days` + /// - Parameters: + /// - minutes: Duration in minutes + /// - style: Default: `.short` + static func short(minutes: Int, style: DateComponentsFormatter.UnitsStyle = .short) -> String? { + let dcf = DateComponentsFormatter() + dcf.maximumUnitCount = 1 + dcf.allowedUnits = [.day, .hour, .minute] + dcf.unitsStyle = style + return dcf.string(from: DateComponents(minute: minutes)) + } +} diff --git a/main/Extensions/URL.swift b/main/Extensions/URL.swift index 9f8f63e..1c0951a 100644 --- a/main/Extensions/URL.swift +++ b/main/Extensions/URL.swift @@ -1,9 +1,9 @@ import Foundation fileprivate extension FileManager { - func exportDir() -> URL { - try! url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - } +// func exportDir() -> URL { +// try! url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) +// } func appGroupDir() -> URL { containerURL(forSecurityApplicationGroupIdentifier: "group.de.uni-bamberg.psi.AppCheck")! } @@ -13,7 +13,7 @@ fileprivate extension FileManager { } extension URL { - static func exportDir() -> URL { FileManager.default.exportDir() } +// static func exportDir() -> URL { FileManager.default.exportDir() } static func appGroupDir() -> URL { FileManager.default.appGroupDir() } static func internalDB() -> URL { FileManager.default.internalDB() } } diff --git a/main/Recordings/TVCPreviousRecords.swift b/main/Recordings/TVCPreviousRecords.swift index fe7a459..086fbff 100644 --- a/main/Recordings/TVCPreviousRecords.swift +++ b/main/Recordings/TVCPreviousRecords.swift @@ -4,7 +4,7 @@ class TVCPreviousRecords: UITableViewController, EditActionsRemove { private var dataSource: [Recording] = [] override func viewDidLoad() { - dataSource = DBWrp.listOfRecordings().reversed() // newest on top + dataSource = RecordingsDB.list().reversed() // newest on top NotifyRecordingChanged.observe(call: #selector(recordingDidChange(_:)), on: self) } @@ -76,8 +76,16 @@ class TVCPreviousRecords: UITableViewController, EditActionsRemove { // MARK: - Editing + override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { + getRowActionsIOS9(indexPath) + } + @available(iOS 11.0, *) + override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + getRowActionsIOS11(indexPath) + } + func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool { - DBWrp.recordingDelete(self.dataSource[index.row]) + RecordingsDB.delete(self.dataSource[index.row]) return true } } diff --git a/main/Recordings/TVCRecordingDetails.swift b/main/Recordings/TVCRecordingDetails.swift index 2c8d88b..9ee61b6 100644 --- a/main/Recordings/TVCRecordingDetails.swift +++ b/main/Recordings/TVCRecordingDetails.swift @@ -6,15 +6,13 @@ class TVCRecordingDetails: UITableViewController, EditActionsRemove { override func viewDidLoad() { title = record.title ?? record.fallbackTitle - dataSource = DBWrp.recordingDetails(record) + dataSource = RecordingsDB.details(record) } // MARK: - Table View Data Source - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - dataSource.count - } + override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "PreviousRecordDetailCell")! @@ -27,10 +25,18 @@ class TVCRecordingDetails: UITableViewController, EditActionsRemove { // MARK: - Editing + override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { + getRowActionsIOS9(indexPath) + } + @available(iOS 11.0, *) + override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + getRowActionsIOS11(indexPath) + } + func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool { - if DBWrp.recordingDeleteDetails(record, domain: self.dataSource[index.row].domain) { - self.dataSource.remove(at: index.row) - self.tableView.deleteRows(at: [index], with: .automatic) + if RecordingsDB.deleteDetails(record, domain: dataSource[index.row].domain) { + dataSource.remove(at: index.row) + tableView.deleteRows(at: [index], with: .automatic) } return true } diff --git a/main/Recordings/VCEditRecording.swift b/main/Recordings/VCEditRecording.swift index 59f1382..74dee80 100644 --- a/main/Recordings/VCEditRecording.swift +++ b/main/Recordings/VCEditRecording.swift @@ -43,8 +43,8 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate record.title = (inputTitle.text == "") ? nil : inputTitle.text record.notes = (inputNotes.text == "") ? nil : inputNotes.text dismiss(animated: true) { - DBWrp.recordingUpdate(self.record) - DBWrp.recordingPersist(self.record) + RecordingsDB.update(self.record) + RecordingsDB.persist(self.record) } } @@ -56,7 +56,7 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate override func viewDidDisappear(_ animated: Bool) { if deleteOnCancel { QLog.Debug("deleting record #\(record.id)") - DBWrp.recordingDelete(record) + RecordingsDB.delete(record) deleteOnCancel = false } } diff --git a/main/Recordings/VCRecordings.swift b/main/Recordings/VCRecordings.swift index 1d55d4e..ee0c540 100644 --- a/main/Recordings/VCRecordings.swift +++ b/main/Recordings/VCRecordings.swift @@ -18,7 +18,7 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate { timeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: timeLabel.font.pointSize, weight: UIFont.Weight(rawValue: weight)) // hide timer if not running updateUI(setRecording: false, animated: false) - currentRecording = DBWrp.recordingGetCurrent() + currentRecording = RecordingsDB.getCurrent() if !Pref.DidShowTutorial.Recordings { self.perform(#selector(showTutorial), with: nil, afterDelay: 0.5) @@ -54,11 +54,11 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate { @IBAction private func startRecordingButtonTapped(_ sender: UIButton) { if recordingTimer == nil { - currentRecording = DBWrp.recordingStartNew() + currentRecording = RecordingsDB.startNew() startTimer(animate: true) } else { stopTimer(animate: true) - DBWrp.recordingStop(¤tRecording!) + RecordingsDB.stop(¤tRecording!) prevRecController.popToRootViewController(animated: true) let editVC = (prevRecController.topViewController as! TVCPreviousRecords) editVC.insertAndEditRecording(currentRecording!) diff --git a/main/Requests/TVCDomains.swift b/main/Requests/TVCDomains.swift index 86761e7..c7f6be2 100644 --- a/main/Requests/TVCDomains.swift +++ b/main/Requests/TVCDomains.swift @@ -1,13 +1,10 @@ import UIKit -class TVCDomains: UITableViewController, IncrementalDataSourceUpdate, UISearchBarDelegate { +class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDelegate { + + lazy var source = GroupedDomainDataSource(withDelegate: self, parent: nil) - internal var dataSource: [GroupedDomain] = [] - private func dataSource(at: Int) -> GroupedDomain { - dataSource[(searchActive ? searchIndices[at] : at)] - } private var searchActive: Bool = false - private var searchIndices: [Int] = [] private var searchTerm: String? private let searchBar: UISearchBar = { let x = UISearchBar(frame: CGRect.init(x: 0, y: 0, width: 20, height: 10)) @@ -22,48 +19,46 @@ class TVCDomains: UITableViewController, IncrementalDataSourceUpdate, UISearchBa override func viewDidLoad() { super.viewDidLoad() - if #available(iOS 10.0, *) { - tableView.refreshControl = UIRefreshControl(call: #selector(reloadDataSource), on: self) - } - NotifyLogHistoryReset.observe(call: #selector(reloadDataSource), on: self) - reloadDataSource() - DBWrp.dataA_delegate = self searchBar.delegate = self - NotifyDateFilterChanged.observe(call: #selector(dateFilterChanged), on: self) - dateFilterChanged() + NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self) + didChangeDateFilter() } - @objc func reloadDataSource() { - dataSource = DBWrp.listOfDomains() - if searchActive { - searchBar(searchBar, textDidChange: "") - } else { - tableView.reloadData() + private var didLoadAlready = false + override func viewDidAppear(_ animated: Bool) { + if !didLoadAlready { + didLoadAlready = true + source.reloadFromSource() } } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if let index = tableView.indexPathForSelectedRow?.row { - (segue.destination as? TVCHosts)?.parentDomain = dataSource(at: index).domain + (segue.destination as? TVCHosts)?.parentDomain = source[index].domain } } - // MARK: - Table View Delegate + // MARK: - Table View Data Source - override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { - searchActive ? searchIndices.count : dataSource.count - } + override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { source.numberOfRows } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "DomainCell")! - let entry = dataSource(at: indexPath.row) + let entry = source[indexPath.row] cell.textLabel?.text = entry.domain cell.detailTextLabel?.text = entry.detailCellText cell.imageView?.image = entry.options?.tableRowImage() return cell } + func rowNeedsUpdate(_ row: Int) { + let entry = source[row] + let cell = tableView.cellForRow(at: IndexPath(row: row)) + cell?.detailTextLabel?.text = entry.detailCellText + cell?.imageView?.image = entry.options?.tableRowImage() + } + // MARK: - Search @@ -77,55 +72,31 @@ class TVCDomains: UITableViewController, IncrementalDataSourceUpdate, UISearchBa private func setSearch(hidden: Bool) { searchActive = !hidden - searchIndices = [] searchTerm = nil searchBar.text = nil tableView.tableHeaderView = hidden ? nil : searchBar - if !hidden { searchBar.becomeFirstResponder() } + if searchActive { + source.pipeline.addFilter("search") { + $0.domain.lowercased().contains(self.searchTerm ?? "") + } + searchBar.becomeFirstResponder() + } else { + source.pipeline.removeFilter(withId: "search") + } tableView.reloadData() } func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(performSearch), object: nil) - perform(#selector(performSearch), with: nil, afterDelay: 0.3) + perform(#selector(performSearch), with: nil, afterDelay: 0.2) } @objc private func performSearch() { searchTerm = searchBar.text?.lowercased() ?? "" - searchIndices = dataSource.enumerated().compactMap { - if $1.domain.lowercased().contains(searchTerm!) { return $0 } - return nil - } + source.pipeline.reloadFilter(withId: "search") tableView.reloadData() } - func shouldLiveUpdateIncrementalDataSource() -> Bool { !searchActive } - - func didUpdateIncrementalDataSource(_ operation: IncrementalDataSourceUpdateOperation, row: Int, moveTo: Int) { - guard searchActive else { - return - } - switch operation { - case .ReloadTable: - DispatchQueue.main.sync { tableView.reloadData() } - case .Insert: - if dataSource[row].domain.lowercased().contains(searchTerm ?? "") { - searchIndices.insert(row, at: 0) - DispatchQueue.main.sync { tableView.safeInsertRow(0, with: .left) } - } - case .Delete: - if let idx = searchIndices.firstIndex(of: row) { - searchIndices.remove(at: idx) - DispatchQueue.main.sync { tableView.safeDeleteRow(idx) } - } - case .Update, .Move: - if let idx = searchIndices.firstIndex(of: row) { - if operation == .Move { searchIndices[idx] = moveTo } - DispatchQueue.main.sync { tableView.safeReloadRow(idx) } - } - } - } - // MARK: - Filter @@ -138,7 +109,7 @@ class TVCDomains: UITableViewController, IncrementalDataSourceUpdate, UISearchBa present(vc, animated: true) } - @objc private func dateFilterChanged() { + @objc private func didChangeDateFilter() { switch Pref.DateFilter.Kind { case .ABRange: // read start/end time self.filterButtonDetail.title = "A – B" diff --git a/main/Requests/TVCHostDetails.swift b/main/Requests/TVCHostDetails.swift index 89da0a5..01c8c9a 100644 --- a/main/Requests/TVCHostDetails.swift +++ b/main/Requests/TVCHostDetails.swift @@ -12,14 +12,55 @@ class TVCHostDetails: UITableViewController { tableView.refreshControl = UIRefreshControl(call: #selector(reloadDataSource), on: self) } NotifyLogHistoryReset.observe(call: #selector(reloadDataSource), on: self) + NotifySyncInsert.observe(call: #selector(syncInsert), on: self) + NotifySyncRemove.observe(call: #selector(syncRemove), on: self) reloadDataSource() } - @objc func reloadDataSource() { - dataSource = DBWrp.listOfTimes(fullDomain) - tableView.reloadData() + @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() + 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.. Int { dataSource.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { diff --git a/main/Requests/TVCHosts.swift b/main/Requests/TVCHosts.swift index bcffa02..6413e33 100644 --- a/main/Requests/TVCHosts.swift +++ b/main/Requests/TVCHosts.swift @@ -1,45 +1,32 @@ import UIKit -class TVCHosts: UITableViewController, IncrementalDataSourceUpdate { +class TVCHosts: UITableViewController, FilterPipelineDelegate { + + lazy var source = GroupedDomainDataSource(withDelegate: self, parent: parentDomain) public var parentDomain: String! - internal var dataSource: [GroupedDomain] = [] private var isSpecial: Bool = false override func viewDidLoad() { super.viewDidLoad() navigationItem.prompt = parentDomain isSpecial = (parentDomain.first == "#") // aka: "# IP address" - if #available(iOS 10.0, *) { - tableView.refreshControl = UIRefreshControl(call: #selector(reloadDataSource), on: self) - } - NotifyLogHistoryReset.observe(call: #selector(reloadDataSource), on: self) - reloadDataSource() - DBWrp.currentlyOpenParent = parentDomain - DBWrp.dataB_delegate = self - } - deinit { - DBWrp.currentlyOpenParent = nil - } - - @objc func reloadDataSource() { - dataSource = DBWrp.listOfHosts(parentDomain) - tableView.reloadData() + source.reloadFromSource() // init lazy var } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if let index = tableView.indexPathForSelectedRow?.row { - (segue.destination as? TVCHostDetails)?.fullDomain = dataSource[index].domain + (segue.destination as? TVCHostDetails)?.fullDomain = source[index].domain } } - // MARK: - Data Source + // MARK: - Table View Data Source - override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count } + override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { source.numberOfRows } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "HostCell")! - let entry = dataSource[indexPath.row] + let entry = source[indexPath.row] if isSpecial { // currently only used for IP addresses cell.textLabel?.text = entry.domain @@ -51,4 +38,11 @@ class TVCHosts: UITableViewController, IncrementalDataSourceUpdate { cell.imageView?.image = entry.options?.tableRowImage() return cell } + + func rowNeedsUpdate(_ row: Int) { + let entry = source[row] + let cell = tableView.cellForRow(at: IndexPath(row: row)) + cell?.detailTextLabel?.text = entry.detailCellText + cell?.imageView?.image = entry.options?.tableRowImage() + } } diff --git a/main/Requests/VCDateFilter.swift b/main/Requests/VCDateFilter.swift index 2af03f3..f715020 100644 --- a/main/Requests/VCDateFilter.swift +++ b/main/Requests/VCDateFilter.swift @@ -76,7 +76,6 @@ class VCDateFilter: UIViewController, UIGestureRecognizerDelegate { if Pref.DateFilter.Kind != newKind || Pref.DateFilter.LastXMin != newXMin { Pref.DateFilter.Kind = newKind Pref.DateFilter.LastXMin = newXMin - DBWrp.reloadAfterDateFilterHasChanged() NotifyDateFilterChanged.post() } dismiss(animated: true) diff --git a/main/Settings/TVCFilter.swift b/main/Settings/TVCFilter.swift index f6aa9e6..511cd29 100644 --- a/main/Settings/TVCFilter.swift +++ b/main/Settings/TVCFilter.swift @@ -1,23 +1,36 @@ import UIKit class TVCFilter: UITableViewController, EditActionsRemove { - var currentFilter: FilterOptions = .none + var currentFilter: FilterOptions = .none // set by segue private var dataSource: [String] = [] override func viewDidLoad() { super.viewDidLoad() -// if #available(iOS 10.0, *) { -// tableView.refreshControl = UIRefreshControl(call: #selector(reloadDataSource), on: self) -// } - NotifyDNSFilterChanged.observe(call: #selector(reloadDataSource), on: self) + NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self) reloadDataSource() } - @objc func reloadDataSource() { - dataSource = DBWrp.dataF_list(currentFilter) + func reloadDataSource() { + dataSource = DomainFilter.list(where: currentFilter) tableView.reloadData() } + @objc func didChangeDomainFilter(_ notification: Notification) { + guard let domain = notification.object as? String else { + reloadDataSource() + return + } + if DomainFilter[domain]?.contains(currentFilter) ?? false { + let i = dataSource.binTreeIndex(of: domain, compare: (<))! + if i >= dataSource.count || dataSource[i] != domain { + dataSource.insert(domain, at: i) + tableView.safeInsertRow(i) + } + } else if let i = dataSource.binTreeRemove(domain, compare: (<)) { + tableView.safeDeleteRows([i]) + } + } + @IBAction private func addNewFilter() { let desc: String switch currentFilter { @@ -33,7 +46,7 @@ class TVCFilter: UITableViewController, EditActionsRemove { ErrorAlert("Entered domain is not valid. Filter can't match country TLD only.").presentIn(self) return } - DBWrp.updateFilter(dom, add: self.currentFilter) + DomainFilter.update(dom, add: self.currentFilter) } alert.addTextField { $0.placeholder = "cdn.domain.tld" @@ -42,7 +55,7 @@ class TVCFilter: UITableViewController, EditActionsRemove { alert.presentIn(self) } - // MARK: - Table View Delegate + // MARK: - Table View Data Source override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { dataSource.count } @@ -57,11 +70,17 @@ class TVCFilter: UITableViewController, EditActionsRemove { // MARK: - Editing + override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { + getRowActionsIOS9(indexPath) + } + @available(iOS 11.0, *) + override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + getRowActionsIOS11(indexPath) + } + func editableRowCallback(_ index: IndexPath, _ action: RowAction, _ userInfo: Any?) -> Bool { let domain = dataSource[index.row] - DBWrp.updateFilter(domain, remove: currentFilter) - dataSource.remove(at: index.row) - tableView.deleteRows(at: [index], with: .automatic) + DomainFilter.update(domain, remove: currentFilter) return true } diff --git a/main/Settings/TVCSettings.swift b/main/Settings/TVCSettings.swift index 95ba379..04b8306 100644 --- a/main/Settings/TVCSettings.swift +++ b/main/Settings/TVCSettings.swift @@ -16,11 +16,9 @@ class TVCSettings: UITableViewController { } @objc func reloadDataSource() { - let (blocked, ignored) = DBWrp.dataF_counts() - DispatchQueue.main.async { - self.cellDomainsIgnored.detailTextLabel?.text = "\(ignored) Domains" - self.cellDomainsBlocked.detailTextLabel?.text = "\(blocked) Domains" - } + let (blocked, ignored) = DomainFilter.counts() + cellDomainsIgnored.detailTextLabel?.text = "\(ignored) Domains" + cellDomainsBlocked.detailTextLabel?.text = "\(blocked) Domains" } @IBAction func toggleVPNProxy(_ sender: UISwitch) { @@ -28,28 +26,8 @@ class TVCSettings: UITableViewController { } @IBAction func exportDB(_ sender: Any) { - // TODO: export partly? - // TODO: show header-banner of success - // Share Sheet let sheet = UIActivityViewController(activityItems: [URL.internalDB()], applicationActivities: nil) self.present(sheet, animated: true) - // Save to Files app -// self.present(UIDocumentPickerViewController(url: FileManager.default.internalDB(), in: .exportToService), animated: true) - // Shows Alert and exports to Documents directory -// AskAlert(title: "Export results?", text: """ -// This action will copy the internal database to the app's local Documents directory. You can use the Files app to access the database file. -// -// Note: This will make your DNS requests available to other apps! -// """, buttonText: "Export") { -// do { -// let dest = try SQLiteDatabase.export() -// let folder = dest.deletingLastPathComponent() -// let out = folder.lastPathComponent + "/" + dest.lastPathComponent -// Alert(title: "Successful", text: "File exported to '\(out)'", buttonText: "OK").presentIn(self) -// } catch { -// ErrorAlert(error).presentIn(self) -// } -// }.presentIn(self) } @IBAction func resetTutorialAlerts(_ sender: UIButton) { @@ -64,7 +42,10 @@ class TVCSettings: UITableViewController { "You are about to delete all results that have been logged in the past. " + "Your preferences for blocked and ignored domains are preserved.\n" + "Continue?", buttonText: "Delete", buttonStyle: .destructive) { _ in - DBWrp.deleteHistory() + DispatchQueue.global().async { + try? AppDB?.dnsLogsDeleteAll() + NotifyLogHistoryReset.postAsyncMain() + } }.presentIn(self) }