From a2b0f311d533846d32855699dac85dec92217305 Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 26 Jul 2020 22:32:11 +0200 Subject: [PATCH] First version with app notifications --- AppCheck.xcodeproj/project.pbxproj | 120 +++ GlassVPN/PacketTunnelProvider.swift | 270 ++++-- main/AppDelegate.swift | 3 + main/Base.lproj/Main.storyboard | 269 +----- main/Base.lproj/Settings.storyboard | 837 ++++++++++++++++++ main/Common Classes/Prefs.swift | 97 +- main/Common Classes/PrefsShared.swift | 75 +- main/Common Classes/PushNotification.swift | 153 ++++ .../PushNotificationAppOnly.swift | 28 + main/Common Classes/ThrottledBatchQueue.swift | 31 + main/DB/DBAppOnly.swift | 8 + main/DB/DBCommon.swift | 4 +- main/Data Source/DomainFilter.swift | 9 +- main/Data Source/RecordingsDB.swift | 3 + main/Extensions/AlertSheet.swift | 7 + main/Extensions/Time.swift | 2 + main/Extensions/UNNotification.swift | 83 ++ main/GlassVPN.swift | 4 + main/Recordings/VCEditRecording.swift | 3 + main/Requests/Analysis/VCCoOccurrence.swift | 2 +- main/Settings/TVCChooseAlertTone.swift | 97 ++ main/Settings/TVCConnectionAlerts.swift | 135 +++ main/Settings/TVCFilter.swift | 12 +- main/Settings/TVCReminderAlerts.swift | 154 ++++ main/Settings/TVCSettings.swift | 192 ++-- main/TBCMain.swift | 63 ++ media/sounds/clock.caf | Bin 0 -> 11712 bytes media/sounds/drum1.caf | Bin 0 -> 8550 bytes media/sounds/drum2.caf | Bin 0 -> 12800 bytes media/sounds/plop1.caf | Bin 0 -> 4538 bytes media/sounds/plop2.caf | Bin 0 -> 4606 bytes media/sounds/snap1.caf | Bin 0 -> 11304 bytes media/sounds/snap2.caf | Bin 0 -> 11780 bytes media/sounds/typewriter1.caf | Bin 0 -> 12528 bytes media/sounds/typewriter2.caf | Bin 0 -> 12868 bytes media/sounds/wood1.caf | Bin 0 -> 11440 bytes media/sounds/wood2.caf | Bin 0 -> 11780 bytes 37 files changed, 2192 insertions(+), 469 deletions(-) create mode 100644 main/Base.lproj/Settings.storyboard create mode 100644 main/Common Classes/PushNotification.swift create mode 100644 main/Common Classes/PushNotificationAppOnly.swift create mode 100644 main/Common Classes/ThrottledBatchQueue.swift create mode 100644 main/Extensions/UNNotification.swift create mode 100644 main/Settings/TVCChooseAlertTone.swift create mode 100644 main/Settings/TVCConnectionAlerts.swift create mode 100644 main/Settings/TVCReminderAlerts.swift create mode 100644 media/sounds/clock.caf create mode 100644 media/sounds/drum1.caf create mode 100644 media/sounds/drum2.caf create mode 100644 media/sounds/plop1.caf create mode 100644 media/sounds/plop2.caf create mode 100644 media/sounds/snap1.caf create mode 100644 media/sounds/snap2.caf create mode 100644 media/sounds/typewriter1.caf create mode 100644 media/sounds/typewriter2.caf create mode 100644 media/sounds/wood1.caf create mode 100644 media/sounds/wood2.caf diff --git a/AppCheck.xcodeproj/project.pbxproj b/AppCheck.xcodeproj/project.pbxproj index 750fd81..37b60aa 100644 --- a/AppCheck.xcodeproj/project.pbxproj +++ b/AppCheck.xcodeproj/project.pbxproj @@ -13,7 +13,14 @@ 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 */; }; + 541075CE24C9D43A00D6F1BF /* UNNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541075CD24C9D43A00D6F1BF /* UNNotification.swift */; }; + 541075CF24C9D43A00D6F1BF /* UNNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541075CD24C9D43A00D6F1BF /* UNNotification.swift */; }; + 541075D124CDBA0000D6F1BF /* ThrottledBatchQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541075D024CDBA0000D6F1BF /* ThrottledBatchQueue.swift */; }; + 541075D224CDBA0000D6F1BF /* ThrottledBatchQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541075D024CDBA0000D6F1BF /* ThrottledBatchQueue.swift */; }; 5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5412F8ED24571B8100A63D7A /* VCDateFilter.swift */; }; + 5412FCC224C628FA000DE429 /* TVCReminderAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5412FCBF24C628F9000DE429 /* TVCReminderAlerts.swift */; }; + 5412FCC324C628FA000DE429 /* TVCChooseAlertTone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5412FCC024C628F9000DE429 /* TVCChooseAlertTone.swift */; }; + 5412FCC424C628FA000DE429 /* TVCConnectionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5412FCC124C628FA000DE429 /* TVCConnectionAlerts.swift */; }; 541A957623E602DF00C09C19 /* LaunchIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 541A957523E602DF00C09C19 /* LaunchIcon.png */; }; 541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541AC5D72399498A00A769D7 /* AppDelegate.swift */; }; 541AC5DD2399498A00A769D7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DB2399498A00A769D7 /* Main.storyboard */; }; @@ -24,6 +31,30 @@ 541FC47824A1453F009154D8 /* VCCoOccurrence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541FC47724A1453F009154D8 /* VCCoOccurrence.swift */; }; 541FC9872497D81C00962623 /* TheGreatDestroyer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541FC9862497D81C00962623 /* TheGreatDestroyer.swift */; }; 542E2A982404973F001462DC /* TBCMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A972404973F001462DC /* TBCMain.swift */; }; + 543078AA24B5E12500278F2D /* snap2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 5430789F24B5E12200278F2D /* snap2.caf */; }; + 543078AB24B5E12500278F2D /* snap2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 5430789F24B5E12200278F2D /* snap2.caf */; }; + 543078AC24B5E12500278F2D /* typewriter2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A024B5E12200278F2D /* typewriter2.caf */; }; + 543078AD24B5E12500278F2D /* typewriter2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A024B5E12200278F2D /* typewriter2.caf */; }; + 543078AE24B5E12500278F2D /* wood1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A124B5E12300278F2D /* wood1.caf */; }; + 543078AF24B5E12500278F2D /* wood1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A124B5E12300278F2D /* wood1.caf */; }; + 543078B024B5E12500278F2D /* plop2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A224B5E12300278F2D /* plop2.caf */; }; + 543078B124B5E12500278F2D /* plop2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A224B5E12300278F2D /* plop2.caf */; }; + 543078B224B5E12500278F2D /* plop1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A324B5E12300278F2D /* plop1.caf */; }; + 543078B324B5E12500278F2D /* plop1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A324B5E12300278F2D /* plop1.caf */; }; + 543078B424B5E12500278F2D /* snap1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A424B5E12300278F2D /* snap1.caf */; }; + 543078B524B5E12500278F2D /* snap1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A424B5E12300278F2D /* snap1.caf */; }; + 543078B624B5E12500278F2D /* drum1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A524B5E12300278F2D /* drum1.caf */; }; + 543078B724B5E12500278F2D /* drum1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A524B5E12300278F2D /* drum1.caf */; }; + 543078B824B5E12500278F2D /* wood2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A624B5E12400278F2D /* wood2.caf */; }; + 543078B924B5E12500278F2D /* wood2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A624B5E12400278F2D /* wood2.caf */; }; + 543078BA24B5E12500278F2D /* typewriter1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A724B5E12400278F2D /* typewriter1.caf */; }; + 543078BB24B5E12500278F2D /* typewriter1.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A724B5E12400278F2D /* typewriter1.caf */; }; + 543078BC24B5E12500278F2D /* clock.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A824B5E12400278F2D /* clock.caf */; }; + 543078BD24B5E12500278F2D /* clock.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A824B5E12400278F2D /* clock.caf */; }; + 543078BE24B5E12500278F2D /* drum2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A924B5E12500278F2D /* drum2.caf */; }; + 543078BF24B5E12500278F2D /* drum2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 543078A924B5E12500278F2D /* drum2.caf */; }; + 543078C324B60F3B00278F2D /* Settings.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 543078C124B60F3B00278F2D /* Settings.storyboard */; }; + 543078C924B75CEA00278F2D /* PushNotificationAppOnly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543078C824B75CD100278F2D /* PushNotificationAppOnly.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 */; }; @@ -132,6 +163,8 @@ 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 */; }; + 54CE8BC424B1ED2100CC1756 /* PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CE8BC324B1ED2100CC1756 /* PushNotification.swift */; }; + 54CE8BC524B1ED2100CC1756 /* PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CE8BC324B1ED2100CC1756 /* PushNotification.swift */; }; 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 */; }; @@ -181,7 +214,12 @@ 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 = ""; }; + 541075CD24C9D43A00D6F1BF /* UNNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNNotification.swift; sourceTree = ""; }; + 541075D024CDBA0000D6F1BF /* ThrottledBatchQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThrottledBatchQueue.swift; sourceTree = ""; }; 5412F8ED24571B8100A63D7A /* VCDateFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCDateFilter.swift; sourceTree = ""; }; + 5412FCBF24C628F9000DE429 /* TVCReminderAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TVCReminderAlerts.swift; sourceTree = ""; }; + 5412FCC024C628F9000DE429 /* TVCChooseAlertTone.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TVCChooseAlertTone.swift; sourceTree = ""; }; + 5412FCC124C628FA000DE429 /* TVCConnectionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TVCConnectionAlerts.swift; sourceTree = ""; }; 541A957523E602DF00C09C19 /* LaunchIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = LaunchIcon.png; sourceTree = ""; }; 541AC5D42399498A00A769D7 /* AppCheck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppCheck.app; sourceTree = BUILT_PRODUCTS_DIR; }; 541AC5D72399498A00A769D7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -195,6 +233,19 @@ 541FC9862497D81C00962623 /* TheGreatDestroyer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TheGreatDestroyer.swift; sourceTree = ""; }; 542E2A972404973F001462DC /* TBCMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBCMain.swift; sourceTree = ""; }; 542E2A9924051556001462DC /* TVCSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCSettings.swift; sourceTree = ""; }; + 5430789F24B5E12200278F2D /* snap2.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = snap2.caf; sourceTree = ""; }; + 543078A024B5E12200278F2D /* typewriter2.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = typewriter2.caf; sourceTree = ""; }; + 543078A124B5E12300278F2D /* wood1.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = wood1.caf; sourceTree = ""; }; + 543078A224B5E12300278F2D /* plop2.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = plop2.caf; sourceTree = ""; }; + 543078A324B5E12300278F2D /* plop1.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = plop1.caf; sourceTree = ""; }; + 543078A424B5E12300278F2D /* snap1.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = snap1.caf; sourceTree = ""; }; + 543078A524B5E12300278F2D /* drum1.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = drum1.caf; sourceTree = ""; }; + 543078A624B5E12400278F2D /* wood2.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = wood2.caf; sourceTree = ""; }; + 543078A724B5E12400278F2D /* typewriter1.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = typewriter1.caf; sourceTree = ""; }; + 543078A824B5E12400278F2D /* clock.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = clock.caf; sourceTree = ""; }; + 543078A924B5E12500278F2D /* drum2.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = drum2.caf; sourceTree = ""; }; + 543078C224B60F3B00278F2D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Settings.storyboard; sourceTree = ""; }; + 543078C824B75CD100278F2D /* PushNotificationAppOnly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationAppOnly.swift; sourceTree = ""; }; 543CDB1D23EEE61900B7F323 /* GlassVPN.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GlassVPN.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = ""; }; @@ -307,6 +358,7 @@ 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 = ""; }; + 54CE8BC324B1ED2100CC1756 /* PushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotification.swift; 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 = ""; }; @@ -360,6 +412,9 @@ children = ( 542E2A9924051556001462DC /* TVCSettings.swift */, 54B34593240E6343004C53CC /* TVCFilter.swift */, + 5412FCBF24C628F9000DE429 /* TVCReminderAlerts.swift */, + 5412FCC124C628FA000DE429 /* TVCConnectionAlerts.swift */, + 5412FCC024C628F9000DE429 /* TVCChooseAlertTone.swift */, ); path = Settings; sourceTree = ""; @@ -411,6 +466,7 @@ 54B345B12422E029004C53CC /* unused */, 541AC5E02399498B00A769D7 /* LaunchScreen.storyboard */, 541AC5DB2399498A00A769D7 /* Main.storyboard */, + 543078C124B60F3B00278F2D /* Settings.storyboard */, 541AC5DE2399498B00A769D7 /* Assets.xcassets */, 541AC5E32399498B00A769D7 /* Info.plist */, 54953E7023E473F10054345C /* Settings.bundle */, @@ -430,12 +486,31 @@ 542E2A9B24051F79001462DC /* media */ = { isa = PBXGroup; children = ( + 5430789E24B5E10E00278F2D /* sounds */, 541A957523E602DF00C09C19 /* LaunchIcon.png */, 54B345AF242264F8004C53CC /* third-level.txt */, ); path = media; sourceTree = ""; }; + 5430789E24B5E10E00278F2D /* sounds */ = { + isa = PBXGroup; + children = ( + 543078A824B5E12400278F2D /* clock.caf */, + 543078A524B5E12300278F2D /* drum1.caf */, + 543078A924B5E12500278F2D /* drum2.caf */, + 543078A324B5E12300278F2D /* plop1.caf */, + 543078A224B5E12300278F2D /* plop2.caf */, + 543078A424B5E12300278F2D /* snap1.caf */, + 5430789F24B5E12200278F2D /* snap2.caf */, + 543078A724B5E12400278F2D /* typewriter1.caf */, + 543078A024B5E12200278F2D /* typewriter2.caf */, + 543078A124B5E12300278F2D /* wood1.caf */, + 543078A624B5E12400278F2D /* wood2.caf */, + ); + path = sounds; + sourceTree = ""; + }; 543CDB1E23EEE61900B7F323 /* GlassVPN */ = { isa = PBXGroup; children = ( @@ -462,6 +537,9 @@ 549ECD9C24A7AD550097571C /* CustomAlert.swift */, 541FC47524A12D01009154D8 /* IBViews.swift */, 5404AEEC24A95F3F003B2F54 /* SlideInAnimation.swift */, + 541075D024CDBA0000D6F1BF /* ThrottledBatchQueue.swift */, + 54CE8BC324B1ED2100CC1756 /* PushNotification.swift */, + 543078C824B75CD100278F2D /* PushNotificationAppOnly.swift */, ); path = "Common Classes"; sourceTree = ""; @@ -484,6 +562,7 @@ 54B345A8241BBA0B004C53CC /* Logging.swift */, 54E67E4E24A8E2910025D261 /* Equatable.swift */, 54B345A5241BB982004C53CC /* Notifications.swift */, + 541075CD24C9D43A00D6F1BF /* UNNotification.swift */, 54B345AA241BBA5B004C53CC /* AlertSheet.swift */, 54E67E5024A8E8820025D261 /* View.swift */, 541DCA6024A6B0F6005F1A4B /* Color.swift */, @@ -836,11 +915,23 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 543078AC24B5E12500278F2D /* typewriter2.caf in Resources */, 54953E7123E473F10054345C /* Settings.bundle in Resources */, + 543078B024B5E12500278F2D /* plop2.caf in Resources */, 541AC5E22399498B00A769D7 /* LaunchScreen.storyboard in Resources */, + 543078B824B5E12500278F2D /* wood2.caf in Resources */, + 543078BE24B5E12500278F2D /* drum2.caf in Resources */, + 543078B424B5E12500278F2D /* snap1.caf in Resources */, 541AC5DF2399498B00A769D7 /* Assets.xcassets in Resources */, + 543078AE24B5E12500278F2D /* wood1.caf in Resources */, 541AC5DD2399498A00A769D7 /* Main.storyboard in Resources */, 54B345B0242264F8004C53CC /* third-level.txt in Resources */, + 543078BA24B5E12500278F2D /* typewriter1.caf in Resources */, + 543078B224B5E12500278F2D /* plop1.caf in Resources */, + 543078B624B5E12500278F2D /* drum1.caf in Resources */, + 543078BC24B5E12500278F2D /* clock.caf in Resources */, + 543078C324B60F3B00278F2D /* Settings.storyboard in Resources */, + 543078AA24B5E12500278F2D /* snap2.caf in Resources */, 541A957623E602DF00C09C19 /* LaunchIcon.png in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -849,6 +940,17 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 543078AD24B5E12500278F2D /* typewriter2.caf in Resources */, + 543078BB24B5E12500278F2D /* typewriter1.caf in Resources */, + 543078BF24B5E12500278F2D /* drum2.caf in Resources */, + 543078AF24B5E12500278F2D /* wood1.caf in Resources */, + 543078B124B5E12500278F2D /* plop2.caf in Resources */, + 543078AB24B5E12500278F2D /* snap2.caf in Resources */, + 543078B924B5E12500278F2D /* wood2.caf in Resources */, + 543078B724B5E12500278F2D /* drum1.caf in Resources */, + 543078B524B5E12500278F2D /* snap1.caf in Resources */, + 543078B324B5E12500278F2D /* plop1.caf in Resources */, + 543078BD24B5E12500278F2D /* clock.caf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -869,6 +971,7 @@ 54E540F2247C423200F7C34A /* DomainFilter.swift in Sources */, 54D8B97E2471B88900EB2414 /* DBCommon.swift in Sources */, 54D8B9832471BD8100EB2414 /* DBAppOnly.swift in Sources */, + 5412FCC224C628FA000DE429 /* TVCReminderAlerts.swift in Sources */, 54E67E4D24A8E20D0025D261 /* TVCSettings.swift in Sources */, 540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */, 54B345A6241BB982004C53CC /* Notifications.swift in Sources */, @@ -881,8 +984,10 @@ 54B345A9241BBA0B004C53CC /* Logging.swift in Sources */, 54B34596240F0513004C53CC /* TableView.swift in Sources */, 540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */, + 541075D124CDBA0000D6F1BF /* ThrottledBatchQueue.swift in Sources */, 54953E3323DC752E0054345C /* DBCore.swift in Sources */, 54E67E4624A8B0FE0025D261 /* PrefsShared.swift in Sources */, + 5412FCC324C628FA000DE429 /* TVCChooseAlertTone.swift in Sources */, 544F912024A67EC5001D4B00 /* TVCOccurrenceContext.swift in Sources */, 54448A30248647D900771C96 /* Time.swift in Sources */, 54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */, @@ -890,6 +995,7 @@ 54953E5F23DEBE840054345C /* TVCDomains.swift in Sources */, 54C056DB23E9E36E00214A3F /* AppInfoType.swift in Sources */, 54D8B98624796E9900EB2414 /* GroupedDomainDataSource.swift in Sources */, + 541075CE24C9D43A00D6F1BF /* UNNotification.swift in Sources */, 54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */, 54D8B97C2471A7E000EB2414 /* String.swift in Sources */, 54E67E5124A8E8820025D261 /* View.swift in Sources */, @@ -899,6 +1005,8 @@ 5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */, 541FC9872497D81C00962623 /* TheGreatDestroyer.swift in Sources */, 5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */, + 5412FCC424C628FA000DE429 /* TVCConnectionAlerts.swift in Sources */, + 543078C924B75CEA00278F2D /* PushNotificationAppOnly.swift in Sources */, 545DDDD124436983003B6544 /* QuickUI.swift in Sources */, 541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */, 541FC47624A12D01009154D8 /* IBViews.swift in Sources */, @@ -907,6 +1015,7 @@ 54D8B97A246C9F2000EB2414 /* FilterPipeline.swift in Sources */, 54B34594240E6343004C53CC /* TVCFilter.swift in Sources */, 549ECD9D24A7AD550097571C /* CustomAlert.swift in Sources */, + 54CE8BC424B1ED2100CC1756 /* PushNotification.swift in Sources */, 54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */, 5404AEED24A95F3F003B2F54 /* SlideInAnimation.swift in Sources */, ); @@ -950,6 +1059,7 @@ 54CA02A82426B2FD003A5E04 /* SOCKS5Adapter.swift in Sources */, 54CA02792426B2FD003A5E04 /* Checksum.swift in Sources */, 54CA02AD2426B2FD003A5E04 /* RejectAdapterFactory.swift in Sources */, + 541075D224CDBA0000D6F1BF /* ThrottledBatchQueue.swift in Sources */, 54CA02672426B2FD003A5E04 /* RawTCPSocketProtocol.swift in Sources */, 54CA02602426B2FD003A5E04 /* GCDProxyServer.swift in Sources */, 54CA026B2426B2FD003A5E04 /* GCDTCPSocket.swift in Sources */, @@ -960,12 +1070,14 @@ 54CA02B02426B2FD003A5E04 /* SecureHTTPAdapterFactory.swift in Sources */, 54CA02A62426B2FD003A5E04 /* AdapterSocket.swift in Sources */, 54CA02742426B2FD003A5E04 /* IPMask.swift in Sources */, + 541075CF24C9D43A00D6F1BF /* UNNotification.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 */, 54CA02842426B2FD003A5E04 /* Rule.swift in Sources */, + 54CE8BC524B1ED2100CC1756 /* PushNotification.swift in Sources */, 54CA02B92426B2FD003A5E04 /* DirectProxySocket.swift in Sources */, 54751E522423955100168273 /* URL.swift in Sources */, 54CA02A92426B2FD003A5E04 /* RejectAdapter.swift in Sources */, @@ -1031,6 +1143,14 @@ name = LaunchScreen.storyboard; sourceTree = ""; }; + 543078C124B60F3B00278F2D /* Settings.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 543078C224B60F3B00278F2D /* Base */, + ); + name = Settings.storyboard; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ diff --git a/GlassVPN/PacketTunnelProvider.swift b/GlassVPN/PacketTunnelProvider.swift index dfd7468..072a84e 100644 --- a/GlassVPN/PacketTunnelProvider.swift +++ b/GlassVPN/PacketTunnelProvider.swift @@ -1,51 +1,8 @@ import NetworkExtension - -fileprivate var filterDomains: [String]! -fileprivate var filterOptions: [(block: Bool, ignore: Bool)]! - - -// MARK: Backward DNS Binary Tree Lookup - -fileprivate func reloadDomainFilter() { - let tmp = AppDB?.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 -} +import UserNotifications private let queue = DispatchQueue.init(label: "PSIGlassDNSQueue", qos: .userInteractive, target: .main) -private func logAsync(_ domain: String, blocked: Bool) { - queue.async { - do { - try AppDB?.logWrite(domain, blocked: blocked) - } catch { - DDLogWarn("Couldn't write: \(error)") - } - } -} - - // MARK: ObserverFactory class LDObserverFactory: ObserverFactory { @@ -60,14 +17,10 @@ class LDObserverFactory: ObserverFactory { switch event { case .receivedRequest(let session, let socket): let i = filterIndex(for: session.host) - if i >= 0 { - let (block, ignore) = filterOptions[i] - if !ignore { logAsync(session.host, blocked: block) } - if block { socket.forceDisconnect() } - } else { - // TODO: disable filter during recordings - logAsync(session.host, blocked: false) - } + let (block, ignore, cA, cB) = (i<0) ? (false, false, false, false) : filterOptions[i] + let kill = ignore ? block : procRequest(session.host, blck: block, custA: cA, custB: cB) + // TODO: disable ignore & block during recordings + if kill { socket.forceDisconnect() } default: break } @@ -86,58 +39,35 @@ class PacketTunnelProvider: NEPacketTunnelProvider { private var autoDeleteTimer: Timer? = nil - private func reloadSettings() { - reloadDomainFilter() - setAutoDelete(PrefsShared.AutoDeleteLogsDays) - } + // MARK: Delegate override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { DDLogVerbose("startTunnel with with options: \(String(describing: options))") + PrefsShared.registerDefaults() do { try SQLiteDatabase.open().initCommonScheme() } catch { - completionHandler(error) + completionHandler(error) // if we cant open db, fail immediately return } - reloadSettings() - - if proxyServer != nil { - proxyServer.stop() - } + // stop previous if any + if proxyServer != nil { proxyServer.stop() } proxyServer = nil - // Create proxy - let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: proxyServerAddress) - settings.mtu = NSNumber(value: 1500) + willInitProxy() - 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() - - self.setTunnelNetworkSettings(settings) { error in + self.setTunnelNetworkSettings(createProxy()) { error in guard error == nil else { - DDLogError("setTunnelNetworkSettings error: \(String(describing: error))") + DDLogError("setTunnelNetworkSettings error: \(error!)") completionHandler(error) return } - completionHandler(nil) - self.proxyServer = GCDHTTPProxyServer(address: IPAddress(fromString: self.proxyServerAddress), port: Port(port: self.proxyServerPort)) do { try self.proxyServer.start() + self.didInitProxy() completionHandler(nil) - } - catch let proxyError { + } catch let proxyError { DDLogError("Error starting proxy server \(proxyError)") completionHandler(proxyError) } @@ -146,15 +76,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { DDLogVerbose("stopTunnel with reason: \(reason)") - DNSServer.currentServer = nil - RawSocketFactory.TunnelProvider = nil - ObserverFactory.currentFactory = nil - proxyServer.stop() - proxyServer = nil - filterDomains = nil - filterOptions = nil - autoDeleteTimer?.fire() // one last time before we quit - autoDeleteTimer?.invalidate() + shutdown() completionHandler() exit(EXIT_SUCCESS) } @@ -171,12 +93,117 @@ class PacketTunnelProvider: NEPacketTunnelProvider { case "auto-delete": setAutoDelete(Int(value) ?? PrefsShared.AutoDeleteLogsDays) return + case "notify-prefs-change": + reloadNotificationSettings() + return default: break } } DDLogWarn("This should never happen! Received unknown handleAppMessage: \(message ?? messageData.base64EncodedString())") reloadSettings() // just in case we fallback to do everything } + + // MARK: Helper + + private func willInitProxy() { + reloadSettings() + } + + private func createProxy() -> NEPacketTunnelNetworkSettings { + 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() + return settings + } + + private func didInitProxy() { + if PrefsShared.RestartReminder.Enabled { + PushNotification.scheduleRestartReminderBadge(on: false) + } + } + + private func shutdown() { + // proxy + DNSServer.currentServer = nil + RawSocketFactory.TunnelProvider = nil + ObserverFactory.currentFactory = nil + proxyServer.stop() + proxyServer = nil + // custom + filterDomains = nil + filterOptions = nil + autoDeleteTimer?.fire() // one last time before we quit + autoDeleteTimer?.invalidate() + notifyTone = nil + if PrefsShared.RestartReminder.Enabled { + PushNotification.scheduleRestartReminderBadge(on: true) + PushNotification.scheduleRestartReminderBanner() + } + } + + private func reloadSettings() { + reloadDomainFilter() + setAutoDelete(PrefsShared.AutoDeleteLogsDays) + reloadNotificationSettings() + } +} + + +// ################################################################ +// # +// # MARK: - Domain Filter +// # +// ################################################################ + +fileprivate var filterDomains: [String]! +fileprivate var filterOptions: [(block: Bool, ignore: Bool, customA: Bool, customB: Bool)]! + +extension PacketTunnelProvider { + fileprivate func reloadDomainFilter() { + let tmp = AppDB?.loadFilters()?.map({ + (String($0.reversed()), $1) + }).sorted(by: { $0.0 < $1.0 }) ?? [] + let t1 = tmp.map { $0.0 } + let t2 = tmp.map { ($1.contains(.blocked), + $1.contains(.ignored), + $1.contains(.customA), + $1.contains(.customB)) } + filterDomains = t1 + filterOptions = t2 + } +} + +/// Backward DNS Binary Tree Lookup +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 } @@ -213,3 +240,58 @@ extension PacketTunnelProvider { } } } + + +// ################################################################ +// # +// # MARK: - Notifications +// # +// ################################################################ + +fileprivate var notifyEnabled: Bool = false +fileprivate var notifyIvertMode: Bool = false +fileprivate var notifyListBlocked: Bool = false +fileprivate var notifyListCustomA: Bool = false +fileprivate var notifyListCustomB: Bool = false +fileprivate var notifyListElse: Bool = false +fileprivate var notifyTone: AnyObject? + +extension PacketTunnelProvider { + func reloadNotificationSettings() { + notifyEnabled = PrefsShared.ConnectionAlerts.Enabled + guard #available(iOS 10.0, *), notifyEnabled else { + notifyTone = nil + return + } + notifyIvertMode = PrefsShared.ConnectionAlerts.ExcludeMode + notifyListBlocked = PrefsShared.ConnectionAlerts.Lists.Blocked + notifyListCustomA = PrefsShared.ConnectionAlerts.Lists.CustomA + notifyListCustomB = PrefsShared.ConnectionAlerts.Lists.CustomB + notifyListElse = PrefsShared.ConnectionAlerts.Lists.Else + notifyTone = UNNotificationSound.from(string: PrefsShared.ConnectionAlerts.Sound) + } +} + + +// ################################################################ +// # +// # MARK: - Process DNS Request +// # +// ################################################################ + +/// Log domain request and post notification if wanted. +/// - Returns: `true` if the request shoud be blocked +fileprivate func procRequest(_ domain: String, blck: Bool, custA: Bool, custB: Bool) -> Bool { + queue.async { + do { try AppDB?.logWrite(domain, blocked: blck) } + catch { DDLogWarn("Couldn't write: \(error)") } + } + if #available(iOS 10.0, *), notifyEnabled { + let onAnyList = notifyListBlocked && blck || notifyListCustomA && custA || notifyListCustomB && custB || notifyListElse + if notifyIvertMode ? !onAnyList : onAnyList { + // TODO: wait for response to block or allow connection + PushNotification.scheduleConnectionAlert(domain, sound: notifyTone as! UNNotificationSound?) + } + } + return blck +} diff --git a/main/AppDelegate.swift b/main/AppDelegate.swift index 57b41a2..9c50024 100644 --- a/main/AppDelegate.swift +++ b/main/AppDelegate.swift @@ -15,6 +15,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { db.initAppOnlyScheme() } + Prefs.registerDefaults() + PrefsShared.registerDefaults() + #if IOS_SIMULATOR TestDataSource.load() #endif diff --git a/main/Base.lproj/Main.storyboard b/main/Base.lproj/Main.storyboard index 24805c1..3a95315 100644 --- a/main/Base.lproj/Main.storyboard +++ b/main/Base.lproj/Main.storyboard @@ -1130,7 +1130,7 @@ Duration: 60:00 - + @@ -1138,274 +1138,17 @@ Duration: 60:00 - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/main/Base.lproj/Settings.storyboard b/main/Base.lproj/Settings.storyboard new file mode 100644 index 0000000..51ed395 --- /dev/null +++ b/main/Base.lproj/Settings.storyboard @@ -0,0 +1,837 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + If VPN stops accidentally, show a notification 5 minutes later. It will remind you to re-enable the VPN after system reboot. + +if Notification is enabled, show a notification banner once, stating the VPN has stopped. + +If App Badge is enabled, display the letter "1" on the homescreen app icon, as long as the VPN is not running. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/main/Common Classes/Prefs.swift b/main/Common Classes/Prefs.swift index 23da221..80b25e0 100644 --- a/main/Common Classes/Prefs.swift +++ b/main/Common Classes/Prefs.swift @@ -1,58 +1,81 @@ import Foundation enum Prefs { - private static func Int(_ key: String) -> Int { UserDefaults.standard.integer(forKey: key) } - private static func Int(_ val: Int, _ key: String) { UserDefaults.standard.set(val, forKey: key) } - private static func Bool(_ key: String) -> Bool { UserDefaults.standard.bool(forKey: key) } - private static func Bool(_ val: Bool, _ key: String) { UserDefaults.standard.set(val, forKey: key) } - private static func `Any`(_ key: String) -> Any? { UserDefaults.standard.object(forKey: key) } - private static func `Any`(_ val: Any?, _ key: String) { UserDefaults.standard.set(val, forKey: key) } + private static var suite: UserDefaults { UserDefaults.standard } + private static func Int(_ key: String) -> Int { suite.integer(forKey: key) } + private static func Int(_ key: String, _ val: Int) { suite.set(val, forKey: key) } + private static func Bool(_ key: String) -> Bool { suite.bool(forKey: key) } + private static func Bool(_ key: String, _ val: Bool) { suite.set(val, forKey: key) } + private static func Str(_ key: String) -> String? { suite.string(forKey: key) } + private static func Str(_ key: String, _ val: String?) { suite.set(val, forKey: key) } + private static func Obj(_ key: String) -> Any? { suite.object(forKey: key) } + private static func Obj(_ key: String, _ val: Any?) { suite.set(val, forKey: key) } + + static func registerDefaults() { + suite.register(defaults: [ + "RecordingReminderEnabled" : true, + "contextAnalyisCoOccurrenceTime" : 5, + ]) + } +} + + +// MARK: - Tutorial + +extension Prefs { enum DidShowTutorial { static var Welcome: Bool { get { Prefs.Bool("didShowTutorialAppWelcome") } - set { Prefs.Bool(newValue, "didShowTutorialAppWelcome") } + set { Prefs.Bool("didShowTutorialAppWelcome", newValue) } } static var Recordings: Bool { get { Prefs.Bool("didShowTutorialRecordings") } - set { Prefs.Bool(newValue, "didShowTutorialRecordings") } - } - } - enum ContextAnalyis { - static var CoOccurrenceTime: Int? { - get { Prefs.Any("contextAnalyisCoOccurrenceTime") as? Int } - set { Prefs.Any(newValue, "contextAnalyisCoOccurrenceTime") } + set { Prefs.Bool("didShowTutorialRecordings", newValue) } } } +} + + +// MARK: - Date Filter + +enum DateFilterKind: Int { + case Off = 0, LastXMin = 1, ABRange = 2; +} +enum DateFilterOrderBy: Int { + case Date = 0, Name = 1, Count = 2; +} + +extension Prefs { enum DateFilter { static var Kind: DateFilterKind { get { DateFilterKind(rawValue: Prefs.Int("dateFilterType"))! } - set { Prefs.Int(newValue.rawValue, "dateFilterType") } + set { Prefs.Int("dateFilterType", newValue.rawValue) } } /// Default: `0` (disabled) static var LastXMin: Int { get { Prefs.Int("dateFilterLastXMin") } - set { Prefs.Int(newValue, "dateFilterLastXMin") } + set { Prefs.Int("dateFilterLastXMin", newValue) } } /// Default: `nil` (disabled) static var RangeA: Timestamp? { - get { Prefs.Any("dateFilterRangeA") as? Timestamp } - set { Prefs.Any(newValue, "dateFilterRangeA") } + get { Prefs.Obj("dateFilterRangeA") as? Timestamp } + set { Prefs.Obj("dateFilterRangeA", newValue) } } /// Default: `nil` (disabled) static var RangeB: Timestamp? { - get { Prefs.Any("dateFilterRangeB") as? Timestamp } - set { Prefs.Any(newValue, "dateFilterRangeB") } + get { Prefs.Obj("dateFilterRangeB") as? Timestamp } + set { Prefs.Obj("dateFilterRangeB", newValue) } } /// default: `.Date` static var OrderBy: DateFilterOrderBy { get { DateFilterOrderBy(rawValue: Prefs.Int("dateFilterOderType"))! } - set { Prefs.Int(newValue.rawValue, "dateFilterOderType") } + set { Prefs.Int("dateFilterOderType", newValue.rawValue) } } /// default: `false` (Desc) static var OrderAsc: Bool { get { Prefs.Bool("dateFilterOderAsc") } - set { Prefs.Bool(newValue, "dateFilterOderAsc") } + set { Prefs.Bool("dateFilterOderAsc", newValue) } } /// - Returns: Timestamp restriction depending on current selected date filter. @@ -69,9 +92,31 @@ enum Prefs { } } } -enum DateFilterKind: Int { - case Off = 0, LastXMin = 1, ABRange = 2; + + +// MARK: - ContextAnalyis + +extension Prefs { + enum ContextAnalyis { + static var CoOccurrenceTime: Int { + get { Prefs.Int("contextAnalyisCoOccurrenceTime") } + set { Prefs.Int("contextAnalyisCoOccurrenceTime", newValue) } + } + } } -enum DateFilterOrderBy: Int { - case Date = 0, Name = 1, Count = 2; + + +// MARK: - Notifications + +extension Prefs { + enum RecordingReminder { + static var Enabled: Bool { + get { Prefs.Bool("RecordingReminderEnabled") } + set { Prefs.Bool("RecordingReminderEnabled", newValue) } + } + static var Sound: String { + get { Prefs.Str("RecordingReminderSound") ?? "#default" } + set { Prefs.Str("RecordingReminderSound", newValue) } + } + } } diff --git a/main/Common Classes/PrefsShared.swift b/main/Common Classes/PrefsShared.swift index 016fc98..d6aec32 100644 --- a/main/Common Classes/PrefsShared.swift +++ b/main/Common Classes/PrefsShared.swift @@ -4,12 +4,79 @@ enum PrefsShared { private static var suite: UserDefaults { UserDefaults(suiteName: "group.de.uni-bamberg.psi.AppCheck")! } private static func Int(_ key: String) -> Int { suite.integer(forKey: key) } - private static func Int(_ val: Int, _ key: String) { suite.set(val, forKey: key) } -// private static func Obj(_ key: String) -> Any? { suite.object(forKey: key) } -// private static func Obj(_ val: Any?, _ key: String) { suite.set(val, forKey: key) } + private static func Int(_ key: String, _ val: Int) { suite.set(val, forKey: key); suite.synchronize() } + private static func Bool(_ key: String) -> Bool { suite.bool(forKey: key) } + private static func Bool(_ key: String, _ val: Bool) { suite.set(val, forKey: key); suite.synchronize() } + private static func Str(_ key: String) -> String? { suite.string(forKey: key) } + private static func Str(_ key: String, _ val: String?) { suite.set(val, forKey: key); suite.synchronize() } + + static func registerDefaults() { + suite.register(defaults: [ + "RestartReminderEnabled" : true, + "RestartReminderWithText" : true, + "RestartReminderWithBadge" : true, + "ConnectionAlertsListsElse" : true, + ]) + } static var AutoDeleteLogsDays: Int { get { Int("AutoDeleteLogsDays") } - set { Int(newValue, "AutoDeleteLogsDays"); suite.synchronize() } + set { Int("AutoDeleteLogsDays", newValue) } + } +} + + +// MARK: - Notifications + +extension PrefsShared { + enum RestartReminder { + static var Enabled: Bool { + get { PrefsShared.Bool("RestartReminderEnabled") } + set { PrefsShared.Bool("RestartReminderEnabled", newValue) } + } + static var WithText: Bool { + get { PrefsShared.Bool("RestartReminderWithText") } + set { PrefsShared.Bool("RestartReminderWithText", newValue) } + } + static var WithBadge: Bool { + get { PrefsShared.Bool("RestartReminderWithBadge") } + set { PrefsShared.Bool("RestartReminderWithBadge", newValue) } + } + static var Sound: String { + get { PrefsShared.Str("RestartReminderSound") ?? "#default" } + set { PrefsShared.Str("RestartReminderSound", newValue) } + } + } + enum ConnectionAlerts { + static var Enabled: Bool { + get { PrefsShared.Bool("ConnectionAlertsEnabled") } + set { PrefsShared.Bool("ConnectionAlertsEnabled", newValue) } + } + static var Sound: String { + get { PrefsShared.Str("ConnectionAlertsSound") ?? "#default" } + set { PrefsShared.Str("ConnectionAlertsSound", newValue) } + } + static var ExcludeMode: Bool { + get { PrefsShared.Bool("ConnectionAlertsExcludeMode") } + set { PrefsShared.Bool("ConnectionAlertsExcludeMode", newValue) } + } + enum Lists { + static var CustomA: Bool { + get { PrefsShared.Bool("ConnectionAlertsListsCustomA") } + set { PrefsShared.Bool("ConnectionAlertsListsCustomA", newValue) } + } + static var CustomB: Bool { + get { PrefsShared.Bool("ConnectionAlertsListsCustomB") } + set { PrefsShared.Bool("ConnectionAlertsListsCustomB", newValue) } + } + static var Blocked: Bool { + get { PrefsShared.Bool("ConnectionAlertsListsBlocked") } + set { PrefsShared.Bool("ConnectionAlertsListsBlocked", newValue) } + } + static var Else: Bool { + get { PrefsShared.Bool("ConnectionAlertsListsElse") } + set { PrefsShared.Bool("ConnectionAlertsListsElse", newValue) } + } + } } } diff --git a/main/Common Classes/PushNotification.swift b/main/Common Classes/PushNotification.swift new file mode 100644 index 0000000..672a578 --- /dev/null +++ b/main/Common Classes/PushNotification.swift @@ -0,0 +1,153 @@ +import UserNotifications + +struct PushNotification { + + enum Identifier: String { + case YouShallRecordMoreReminder + case CantStopMeNowReminder + case RestInPeaceTombstone + case AllConnectionAlertNotifications + } + + static func allowed(_ closure: @escaping (NotificationRequestState) -> Void) { + guard #available(iOS 10, *) else { return } + + UNUserNotificationCenter.current().getNotificationSettings { settings in + let state = NotificationRequestState(settings.authorizationStatus) + DispatchQueue.main.async { + closure(state) + } + } + } + + /// Available in iOS 12+ + static func requestProvisionalOrDoNothing(_ closure: @escaping (Bool) -> Void) { + guard #available(iOS 12, *) else { return closure(false) } + + let opt: UNAuthorizationOptions = [.alert, .sound, .badge, .provisional, .providesAppNotificationSettings] + UNUserNotificationCenter.current().requestAuthorization(options: opt) { granted, _ in + DispatchQueue.main.async { + closure(granted) + } + } + } + + static func requestAuthorization(_ closure: @escaping (Bool) -> Void) { + guard #available(iOS 10, *) else { return } + + var opt: UNAuthorizationOptions = [.alert, .sound, .badge] + if #available(iOS 12, *) { + opt.formUnion(.providesAppNotificationSettings) + } + UNUserNotificationCenter.current().requestAuthorization(options: opt) { granted, _ in + DispatchQueue.main.async { + closure(granted) + } + } + } + + static func hasPending(_ ident: Identifier, _ closure: @escaping (Bool) -> Void) { + guard #available(iOS 10, *) else { return } + + UNUserNotificationCenter.current().getPendingNotificationRequests { + let hasIt = $0.contains { $0.identifier == ident.rawValue } + DispatchQueue.main.async { + closure(hasIt) + } + } + } + + static func cancel(_ ident: Identifier, keepDelivered: Bool = false) { + guard #available(iOS 10, *) else { return } + + let center = UNUserNotificationCenter.current() + guard ident != .AllConnectionAlertNotifications else { + // remove all connection alert notifications while + // keeping general purpose reminder notifications + center.getDeliveredNotifications { + var list = $0.map { $0.request.identifier } + list.removeAll { !$0.contains(".") } // each domain (or IP) has a dot + center.removeDeliveredNotifications(withIdentifiers: list) + // no need to do the same for pending since con-alerts are always immediate + } + return + } + center.removePendingNotificationRequests(withIdentifiers: [ident.rawValue]) + if !keepDelivered { + center.removeDeliveredNotifications(withIdentifiers: [ident.rawValue]) + } + } + + @available(iOS 10.0, *) + static func schedule(_ ident: Identifier, content: UNNotificationContent, trigger: UNNotificationTrigger? = nil, waitUntilDone: Bool = false) { + schedule(ident.rawValue, content: content, trigger: trigger, waitUntilDone: waitUntilDone) + } + + @available(iOS 10.0, *) + static func schedule(_ ident: String, content: UNNotificationContent, trigger: UNNotificationTrigger? = nil, waitUntilDone: Bool = false) { + let req = UNNotificationRequest(identifier: ident, content: content, trigger: trigger) + waitUntilDone ? req.pushAndWait() : req.push() + } +} + + +// MARK: - Reminder Alerts + +extension PushNotification { + /// Auto-check preferences whether `withText` is set, then schedule notification to 5 min in the future. + static func scheduleRestartReminderBanner() { + guard #available(iOS 10, *), PrefsShared.RestartReminder.WithText else { return } + + schedule(.CantStopMeNowReminder, + content: .make("AppCheck disabled", + body: "AppCheck can't monitor network traffic because VPN has stopped.", + sound: .from(string: PrefsShared.RestartReminder.Sound)), + trigger: .make(Date(timeIntervalSinceNow: 5 * 60)), + waitUntilDone: true) + } + + /// Auto-check preferences whether `withBadge` is set, then post badge immediatelly. + /// - Parameter on: If `true`, set `1` on app icon. If `false`, remove badge on app icon. + static func scheduleRestartReminderBadge(on: Bool) { + guard #available(iOS 10, *), PrefsShared.RestartReminder.WithBadge else { return } + + schedule(.RestInPeaceTombstone, content: .makeBadge(on ? 1 : 0), waitUntilDone: true) + } +} + + +// MARK: - Connection Alerts + +extension PushNotification { + static private let queue = ThrottledBatchQueue(0.5, using: .init(label: "PSINotificationQueue", qos: .default, target: .global())) + + /// Post new notification with given domain name. If notification already exists, increase occurrence count. + /// - Parameter domain: Used in the description and as notification identifier. + @available(iOS 10.0, *) + static func scheduleConnectionAlert(_ domain: String, sound: UNNotificationSound?) { + queue.addDelayed(domain) { batch in + let groupSum = batch.reduce(into: [:]) { $0[$1] = ($0[$1] ?? 0) + 1 } + scheduleConnectionAlertMulti(groupSum, sound: sound) + } + } + + /// Internal method to post a batch of counted domains. + @available(iOS 10.0, *) + static private func scheduleConnectionAlertMulti(_ group: [String: Int], sound: UNNotificationSound?) { + UNUserNotificationCenter.current().getDeliveredNotifications { delivered in + for (dom, count) in group { + let num: Int + if let prev = delivered.first(where: { $0.request.identifier == dom })?.request.content { + if let p = prev.body.split(separator: "×").first, let i = Int(p) { + num = count + i + } else { + num = count + 1 + } + } else { + num = count + } + schedule(dom, content: .make("DNS connection", body: num > 1 ? "\(num)× \(dom)" : dom, sound: sound)) + } + } + } +} diff --git a/main/Common Classes/PushNotificationAppOnly.swift b/main/Common Classes/PushNotificationAppOnly.swift new file mode 100644 index 0000000..b48ff91 --- /dev/null +++ b/main/Common Classes/PushNotificationAppOnly.swift @@ -0,0 +1,28 @@ +import UserNotifications + +extension PushNotification { + static func scheduleRecordingReminder(force: Bool) { + if force { + scheduleRecordingReminder() + } else { + hasPending(.YouShallRecordMoreReminder) { + if !$0 { scheduleRecordingReminder() } + } + } + } + + private static func scheduleRecordingReminder() { + guard #available(iOS 10, *) else { return } + + let now = Timestamp.now() + var next = RecordingsDB.lastTimestamp() ?? (now - 1) + while next < now { + next += .days(14) + } + schedule(.YouShallRecordMoreReminder, + content: .make("Start new recording", + body: "It's been a while since your last recording …", + sound: .from(string: Prefs.RecordingReminder.Sound)), + trigger: .make(Date(next))) + } +} diff --git a/main/Common Classes/ThrottledBatchQueue.swift b/main/Common Classes/ThrottledBatchQueue.swift new file mode 100644 index 0000000..c54a96e --- /dev/null +++ b/main/Common Classes/ThrottledBatchQueue.swift @@ -0,0 +1,31 @@ +import Foundation + +class ThrottledBatchQueue { + private var cache: [T] = [] + private var scheduled: Bool = false + private let queue: DispatchQueue + private let delay: Double + + init(_ delay: Double, using queue: DispatchQueue) { + self.queue = queue + self.delay = delay + } + + func addDelayed(_ elem: T, afterDelay closure: @escaping ([T]) -> Void) { + queue.sync { + cache.append(elem) + guard !scheduled else { + return + } + scheduled = true + queue.asyncAfter(deadline: .now() + delay) { + let aCopy = self.cache + self.cache.removeAll(keepingCapacity: true) + self.scheduled = false + DispatchQueue.main.async { + closure(aCopy) + } + } + } + } +} diff --git a/main/DB/DBAppOnly.swift b/main/DB/DBAppOnly.swift index 682de2c..240af6f 100644 --- a/main/DB/DBAppOnly.swift +++ b/main/DB/DBAppOnly.swift @@ -382,6 +382,14 @@ extension SQLiteDatabase { } } + /// Get `Timestamp` of last recording. + func recordingLastTimestamp() -> Timestamp? { + try? run(sql: "SELECT stop FROM rec WHERE stop IS NOT NULL ORDER BY rowid DESC LIMIT 1;") { + try ifStep($0, SQLITE_ROW) + return col_ts($0, 0) + } + } + /// `WHERE stop IS NOT NULL` func recordingGetAll() -> [Recording]? { try? run(sql: "SELECT * FROM rec WHERE stop IS NOT NULL;") { diff --git a/main/DB/DBCommon.swift b/main/DB/DBCommon.swift index 68f361d..8e7d6bc 100644 --- a/main/DB/DBCommon.swift +++ b/main/DB/DBCommon.swift @@ -74,7 +74,9 @@ struct FilterOptions: OptionSet { static let none = FilterOptions([]) static let blocked = FilterOptions(rawValue: 1 << 0) static let ignored = FilterOptions(rawValue: 1 << 1) - static let any = FilterOptions(rawValue: 0b11) + static let customA = FilterOptions(rawValue: 1 << 2) + static let customB = FilterOptions(rawValue: 1 << 3) + static let any = FilterOptions(rawValue: 0b1111) } extension SQLiteDatabase { diff --git a/main/Data Source/DomainFilter.swift b/main/Data Source/DomainFilter.swift index 18260fd..db52716 100644 --- a/main/Data Source/DomainFilter.swift +++ b/main/Data Source/DomainFilter.swift @@ -21,10 +21,13 @@ enum DomainFilter { } /// Get total number of blocked and ignored domains. Shown in settings overview. - static func counts() -> (blocked: Int, ignored: Int) { - data.reduce(into: (0, 0)) { + static func counts() -> (blocked: Int, ignored: Int, listCustomA: Int, listCustomB: Int) { + data.reduce(into: (0, 0, 0, 0)) { if $1.1.contains(.blocked) { $0.0 += 1 } - if $1.1.contains(.ignored) { $0.1 += 1 } } + if $1.1.contains(.ignored) { $0.1 += 1 } + if $1.1.contains(.customA) { $0.2 += 1 } + if $1.1.contains(.customB) { $0.3 += 1 } + } } /// Union `filter` with set. diff --git a/main/Data Source/RecordingsDB.swift b/main/Data Source/RecordingsDB.swift index 40b276c..8a06467 100644 --- a/main/Data Source/RecordingsDB.swift +++ b/main/Data Source/RecordingsDB.swift @@ -13,6 +13,9 @@ enum RecordingsDB { /// Get list of all recordings static func list() -> [Recording] { AppDB?.recordingGetAll() ?? [] } + /// Get `Timestamp` of latest recording + static func lastTimestamp() -> Timestamp? { AppDB?.recordingLastTimestamp() } + /// Copy log entries from generic `heap` table to recording specific `recLog` table static func persist(_ r: Recording) { sync.syncNow { // persist changes in cache before copying recording details diff --git a/main/Extensions/AlertSheet.swift b/main/Extensions/AlertSheet.swift index 8cd4af6..bc0b1c0 100644 --- a/main/Extensions/AlertSheet.swift +++ b/main/Extensions/AlertSheet.swift @@ -37,6 +37,13 @@ func AskAlert(title: String?, text: String?, buttonText: String = "Continue", bu return alert } +/// Show alert hinting the user to go to system settings and re-enable notifications. +func NotificationsDisabledAlert(presentIn viewController: UIViewController) { + Alert(title: "Notifications Disabled", + text: "Go to System Settings > Notifications > AppCheck to re-enable notifications." + ).presentIn(viewController) +} + // MARK: Alert with multiple options /// - Parameters: diff --git a/main/Extensions/Time.swift b/main/Extensions/Time.swift index 1df1449..feb3cd1 100644 --- a/main/Extensions/Time.swift +++ b/main/Extensions/Time.swift @@ -23,6 +23,8 @@ extension Timestamp { static func minutes(_ m: Int) -> Timestamp { Timestamp(m * 60) } /// Create `Timestamp` with `h * 3600` seconds static func hours(_ h: Int) -> Timestamp { Timestamp(h * 3600) } + /// Create `Timestamp` with `d * 86400` seconds + static func days(_ d: Int) -> Timestamp { Timestamp(d * 86400) } } extension Timer { diff --git a/main/Extensions/UNNotification.swift b/main/Extensions/UNNotification.swift new file mode 100644 index 0000000..ebdfdb5 --- /dev/null +++ b/main/Extensions/UNNotification.swift @@ -0,0 +1,83 @@ +import UserNotifications + +enum NotificationRequestState { + case NotDetermined, Denied, Authorized, Provisional + @available(iOS 10.0, *) + init(_ from: UNAuthorizationStatus) { + switch from { + case .notDetermined: self = .NotDetermined + case .denied: self = .Denied + case .authorized: self = .Authorized + case .provisional: self = .Provisional + @unknown default: fatalError() + } + } +} + +@available(iOS 10.0, *) +extension UNNotificationRequest { + func push() { + UNUserNotificationCenter.current().add(self) { error in + if let e = error { + NSLog("[ERROR] Can't add push notification: \(e)") + } + } + } + func pushAndWait() { + let semaphore = DispatchSemaphore(value: 0) + UNUserNotificationCenter.current().add(self) { error in + if let e = error { + NSLog("[ERROR] Can't add push notification: \(e)") + } + semaphore.signal() + } + _ = semaphore.wait(wallTimeout: .distantFuture) + } +} + +@available(iOS 10.0, *) +extension UNNotificationContent { + /// - Parameter sound: Use `#default` or `nil` to play the default tone. Use `#mute` to play no tone at all. Else use an `UNNotificationSoundName`. + static func make(_ title: String, body: String, sound: UNNotificationSound? = .default) -> UNNotificationContent { + let x = UNMutableNotificationContent() + // use NSString.localizedUserNotificationString(forKey:arguments:) + x.title = title + x.body = body + x.sound = sound + return x + } + /// - Parameter value: `0` will remove the badge + static func makeBadge(_ value: Int) -> UNNotificationContent { + let x = UNMutableNotificationContent() + x.badge = value as NSNumber? + return x + } +} + +@available(iOS 10.0, *) +extension UNNotificationTrigger { + /// Calls `(dateMatching: components, repeats: repeats)` + static func make(_ components: DateComponents, repeats: Bool) -> UNCalendarNotificationTrigger { + UNCalendarNotificationTrigger(dateMatching: components, repeats: repeats) + } + /// Calls `(dateMatching: components(second-year), repeats: false)` + static func make(_ date: Date) -> UNCalendarNotificationTrigger { + let components = Calendar.current.dateComponents([.year,.month,.day,.hour,.minute,.second], from: date) + return UNCalendarNotificationTrigger(dateMatching: components, repeats: false) + } + /// Calls `(timeInterval: time, repeats: repeats)` + static func make(_ time: TimeInterval, repeats: Bool) -> UNTimeIntervalNotificationTrigger { + UNTimeIntervalNotificationTrigger(timeInterval: time, repeats: repeats) + } +} + +@available(iOS 10.0, *) +extension UNNotificationSound { + static func from(string: String) -> UNNotificationSound? { + switch string { + case "#mute": return nil + case "#default": return .default + case let name: return .init(named: UNNotificationSoundName(name + ".caf")) + } + } +} diff --git a/main/GlassVPN.swift b/main/GlassVPN.swift index 10a68bb..60ee23e 100644 --- a/main/GlassVPN.swift +++ b/main/GlassVPN.swift @@ -136,4 +136,8 @@ struct VPNAppMessage { static func autoDelete(after interval: Int) -> Self { .init("auto-delete:\(interval)") } + /// Only used for connection alert notifications + static func notificationSettingsChanged() -> Self { + .init("notify-prefs-change:1") + } } diff --git a/main/Recordings/VCEditRecording.swift b/main/Recordings/VCEditRecording.swift index a684c62..3a5a2d5 100644 --- a/main/Recordings/VCEditRecording.swift +++ b/main/Recordings/VCEditRecording.swift @@ -47,6 +47,9 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate RecordingsDB.update(self.record) if newlyCreated { RecordingsDB.persist(self.record) + if Prefs.RecordingReminder.Enabled { + PushNotification.scheduleRecordingReminder(force: true) + } } } } diff --git a/main/Requests/Analysis/VCCoOccurrence.swift b/main/Requests/Analysis/VCCoOccurrence.swift index 0e55184..63408d4 100644 --- a/main/Requests/Analysis/VCCoOccurrence.swift +++ b/main/Requests/Analysis/VCCoOccurrence.swift @@ -16,7 +16,7 @@ class VCCoOccurrence: UIViewController, UITableViewDataSource { override func viewDidLoad() { super.viewDidLoad() - selectedTime = Prefs.ContextAnalyis.CoOccurrenceTime ?? 5 // calls `didSet` and `logTimeDelta` + selectedTime = Prefs.ContextAnalyis.CoOccurrenceTime // calls `didSet` and `logTimeDelta` timeSegment.removeAllSegments() // clear IB values for (i, time) in availableTimes.enumerated() { timeSegment.insertSegment(withTitle: TimeFormat(.abbreviated).from(seconds: time), at: i, animated: false) diff --git a/main/Settings/TVCChooseAlertTone.swift b/main/Settings/TVCChooseAlertTone.swift new file mode 100644 index 0000000..43f020e --- /dev/null +++ b/main/Settings/TVCChooseAlertTone.swift @@ -0,0 +1,97 @@ +import UIKit +import AudioToolbox + +protocol NotificationSoundChangedDelegate { + /// Use `#mute` to disable sounds and `#default` to use default notification sound. + func notificationSoundCurrent() -> String + /// Called every time the user changes selection + func notificationSoundChanged(filename: String, title: String) +} + +class TVCChooseAlertTone: UITableViewController { + + var delegate: NotificationSoundChangedDelegate! + private lazy var selected: String = delegate.notificationSoundCurrent() + + private func playTone(_ name: String) { + switch name { + case "#mute": return // No Sound + case "#default": AudioServicesPlayAlertSound(1315) // Default sound + default: + guard let url = Bundle.main.url(forResource: name, withExtension: "caf") else { + preconditionFailure("Something went wrong. Sound file \(name).caf does not exist.") + } + var soundId: SystemSoundID = 0 + AudioServicesCreateSystemSoundID(url as CFURL, &soundId) + AudioServicesAddSystemSoundCompletion(soundId, nil, nil, { id, _ -> Void in + AudioServicesDisposeSystemSoundID(id) + }, nil) + AudioServicesPlayAlertSound(soundId) + } + } + + + // MARK: Table View Delegate + + override func numberOfSections(in _: UITableView) -> Int { + AvailableSounds.count + } + + override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { + AvailableSounds[section].count + } + + override func tableView(_: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 1 ? "AppCheck" : nil + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAlertToneCell")! + let src = AvailableSounds[indexPath.section][indexPath.row] + cell.textLabel?.text = src.title + cell.accessoryType = (src.file == selected) ? .checkmark : .none + return cell + } + + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + let src = AvailableSounds[indexPath.section][indexPath.row] + selected = src.file + tableView.reloadData() // re-apply checkmarks + playTone(selected) + delegate.notificationSoundChanged(filename: src.file, title: src.title) + return nil + } +} + +// MARK: - Sounds Data Source +// afconvert input.aiff output.caf -d ima4 -f caff -v + +fileprivate let AvailableSounds: [[(title: String, file: String)]] = [ + [ // System sounds + ("None", "#mute"), + ("Default", "#default") + ], [ // AppCheck sounds + ("Clock", "clock"), + ("Drum 1", "drum1"), + ("Drum 2", "drum2"), + ("Plop 1", "plop1"), + ("Plop 2", "plop2"), + ("Snap 1", "snap1"), + ("Snap 2", "snap2"), + ("Typewriter 1", "typewriter1"), + ("Typewriter 2", "typewriter2"), + ("Wood 1", "wood1"), + ("Wood 2", "wood2") + ] +] + +func AlertSoundTitle(for filename: String) -> String { + for section in AvailableSounds { + for row in section { + if row.file == filename { + return row.title + } + } + } + return "" +} diff --git a/main/Settings/TVCConnectionAlerts.swift b/main/Settings/TVCConnectionAlerts.swift new file mode 100644 index 0000000..f88c9a8 --- /dev/null +++ b/main/Settings/TVCConnectionAlerts.swift @@ -0,0 +1,135 @@ +import UIKit + +class TVCConnectionAlerts: UITableViewController { + + @IBOutlet var showNotifications: UISwitch! + @IBOutlet var cellSound: UITableViewCell! + + @IBOutlet var listsCustomA: UITableViewCell! + @IBOutlet var listsCustomB: UITableViewCell! + + override func viewDidLoad() { + super.viewDidLoad() + cascadeEnableConnAlert(PrefsShared.ConnectionAlerts.Enabled) + cellSound.detailTextLabel?.text = AlertSoundTitle(for: PrefsShared.ConnectionAlerts.Sound) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + let (_, _, custA, custB) = DomainFilter.counts() + listsCustomA.detailTextLabel?.text = "\(custA) Domains" + listsCustomB.detailTextLabel?.text = "\(custB) Domains" + } + + private func cascadeEnableConnAlert(_ flag: Bool) { + showNotifications.isOn = flag + // en/disable related controls + } + + private func getListSelected(_ index: Int) -> Bool { + switch index { + case 0: return PrefsShared.ConnectionAlerts.Lists.Blocked + case 1: return PrefsShared.ConnectionAlerts.Lists.CustomA + case 2: return PrefsShared.ConnectionAlerts.Lists.CustomB + case 3: return PrefsShared.ConnectionAlerts.Lists.Else + default: preconditionFailure() + } + } + + private func setListSelected(_ index: Int, _ value: Bool) { + switch index { + case 0: PrefsShared.ConnectionAlerts.Lists.Blocked = value + case 1: PrefsShared.ConnectionAlerts.Lists.CustomA = value + case 2: PrefsShared.ConnectionAlerts.Lists.CustomB = value + case 3: PrefsShared.ConnectionAlerts.Lists.Else = value + default: preconditionFailure() + } + } + + // MARK: - Toggles + + @IBAction private func toggleShowNotifications(_ sender: UISwitch) { + PrefsShared.ConnectionAlerts.Enabled = sender.isOn + cascadeEnableConnAlert(sender.isOn) + GlassVPN.send(.notificationSettingsChanged()) + if sender.isOn { + PushNotification.requestAuthorization { granted in + if !granted { + NotificationsDisabledAlert(presentIn: self) + self.cascadeEnableConnAlert(false) + } + } + } else { + PushNotification.cancel(.AllConnectionAlertNotifications) + } + } + + // MARK: - Table View Delegate + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = super.tableView(tableView, cellForRowAt: indexPath) + let checked: Bool + switch indexPath.section { + case 1: // mode selection + checked = (indexPath.row == (PrefsShared.ConnectionAlerts.ExcludeMode ? 1 : 0)) + case 2: // include & exclude lists + checked = getListSelected(indexPath.row) + default: return cell // process only checkmarked cells + } + cell.accessoryType = checked ? .checkmark : .none + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch indexPath.section { + case 1: // mode selection + PrefsShared.ConnectionAlerts.ExcludeMode = indexPath.row == 1 + tableView.reloadSections(.init(integer: 2), with: .none) + case 2: // include & exclude lists + let prev = tableView.cellForRow(at: indexPath)?.accessoryType == .checkmark + setListSelected(indexPath.row, !prev) + default: return // process only checkmarked cells + } + tableView.deselectRow(at: indexPath, animated: true) + tableView.reloadSections(.init(integer: indexPath.section), with: .none) + GlassVPN.send(.notificationSettingsChanged()) + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + if section == 2 { + return PrefsShared.ConnectionAlerts.ExcludeMode ? "Exclude All" : "Include All" + } + return super.tableView(tableView, titleForHeaderInSection: section) + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if let dest = segue.destination as? TVCFilter { + switch segue.identifier { + case "segueFilterListCustomA": + dest.navigationItem.title = "Custom List A" + dest.currentFilter = .customA + case "segueFilterListCustomB": + dest.navigationItem.title = "Custom List B" + dest.currentFilter = .customB + default: + break + } + } else if let tvc = segue.destination as? TVCChooseAlertTone { + tvc.delegate = self + } + } +} + +// MARK: - Sound Selection + +extension TVCConnectionAlerts: NotificationSoundChangedDelegate { + func notificationSoundCurrent() -> String { + PrefsShared.ConnectionAlerts.Sound + } + + func notificationSoundChanged(filename: String, title: String) { + cellSound.detailTextLabel?.text = title + PrefsShared.ConnectionAlerts.Sound = filename + GlassVPN.send(.notificationSettingsChanged()) + } +} diff --git a/main/Settings/TVCFilter.swift b/main/Settings/TVCFilter.swift index 2078da1..f5e7ee9 100644 --- a/main/Settings/TVCFilter.swift +++ b/main/Settings/TVCFilter.swift @@ -25,14 +25,10 @@ class TVCFilter: UITableViewController, EditActionsRemove { } @IBAction private func addNewFilter() { - let desc: String - switch currentFilter { - case .blocked: desc = "Enter the domain name you wish to block." - case .ignored: desc = "Enter the domain name you wish to ignore." - default: return - } - let alert = AskAlert(title: "Create new filter", text: desc, buttonText: "Add") { - guard let dom = $0.textFields?.first?.text else { + let alert = AskAlert(title: "Create new filter", + text: "Enter the domain name you wish to add.", + buttonText: "Add") { + guard let dom = $0.textFields?.first?.text?.lowercased() else { return } guard dom.contains("."), !dom.isKnownSLD() else { diff --git a/main/Settings/TVCReminderAlerts.swift b/main/Settings/TVCReminderAlerts.swift new file mode 100644 index 0000000..2d0bdd1 --- /dev/null +++ b/main/Settings/TVCReminderAlerts.swift @@ -0,0 +1,154 @@ +import UIKit + +class TVCReminderAlerts: UITableViewController { + + @IBOutlet var restartAllow: UISwitch! + @IBOutlet var restartAllowNotify: UISwitch! + @IBOutlet var restartAllowBadge: UISwitch! + @IBOutlet var restartSound: UITableViewCell! + + @IBOutlet var recordingAllow: UISwitch! + @IBOutlet var recordingSound: UITableViewCell! + + private enum ReminderCellType { case Restart, Recording } + private var selectedSound: ReminderCellType = .Restart + + override func viewDidLoad() { + super.viewDidLoad() + restartAllowNotify.isOn = PrefsShared.RestartReminder.WithText + restartAllowBadge.isOn = PrefsShared.RestartReminder.WithBadge + restartSound.detailTextLabel?.text = AlertSoundTitle(for: PrefsShared.RestartReminder.Sound) + recordingSound.detailTextLabel?.text = AlertSoundTitle(for: Prefs.RecordingReminder.Sound) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + readNotificationState { (allowStart, allowRecord, isProvisional) in + self.cascadeEnableRestart(allowStart && !isProvisional) + self.recordingAllow.isOn = (allowRecord && !isProvisional) + self.setIndicateProvisional(isProvisional) + } + } + + private func readNotificationState(_ closure: @escaping (Bool, Bool, Bool) -> Void) { + let en1 = PrefsShared.RestartReminder.Enabled + let en2 = Prefs.RecordingReminder.Enabled + closure(en1, en2, false) + guard en1 || en2 else { return } + PushNotification.allowed { state in + switch state { + case .NotDetermined, .Denied: closure(false, false, false) + case .Authorized, .Provisional: closure(en1, en2, state == .Provisional) + } + } + } + + private func cascadeEnableRestart(_ flag: Bool) { + restartAllow.isOn = flag + restartAllowNotify.isEnabled = flag + restartAllowBadge.isEnabled = flag + } + + private func setIndicateProvisional(_ flag: Bool) { + if flag { + restartAllow.thumbTintColor = .systemGreen + recordingAllow.thumbTintColor = .systemGreen + } else { + // thumb tint is only set in provisional mode + if restartAllow.thumbTintColor <-? nil { restartAllow.isOn = true } + if recordingAllow.thumbTintColor <-? nil { recordingAllow.isOn = true } + } + } + + private func updateBadge() { + let flag = (restartAllow.isOn && restartAllowBadge.isOn && GlassVPN.state != .on) + UIApplication.shared.applicationIconBadgeNumber = flag ? 1 : 0 + } +} + + +// MARK: - Toggles + +extension TVCReminderAlerts { + @IBAction private func toggleAllowRestartReminder(_ sender: UISwitch) { + PrefsShared.RestartReminder.Enabled = sender.isOn + cascadeEnableRestart(sender.isOn) + updateBadge() + if sender.isOn { + askAuthorization {} + } else { + PushNotification.cancel(.CantStopMeNowReminder) + } + } + + @IBAction private func toggleAllowRestartNotify(_ sender: UISwitch) { + PrefsShared.RestartReminder.WithText = sender.isOn + if !sender.isOn { + PushNotification.cancel(.CantStopMeNowReminder) + } + } + + @IBAction private func toggleAllowRestartBadge(_ sender: UISwitch) { + PrefsShared.RestartReminder.WithBadge = sender.isOn + updateBadge() + } + + @IBAction private func toggleAllowRecordingReminder(_ sender: UISwitch) { + Prefs.RecordingReminder.Enabled = sender.isOn + if sender.isOn { + askAuthorization { PushNotification.scheduleRecordingReminder(force: false) } + } else { + PushNotification.cancel(.YouShallRecordMoreReminder) + } + } + + private func askAuthorization(_ closure: @escaping () -> Void) { + setIndicateProvisional(false) + PushNotification.requestAuthorization { granted in + if granted { + closure() + } else { + NotificationsDisabledAlert(presentIn: self) + self.cascadeEnableRestart(false) + self.recordingAllow.isOn = false + } + } + } +} + + +// MARK: - Sound Selection + +extension TVCReminderAlerts: NotificationSoundChangedDelegate { + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if let tvc = segue.destination as? TVCChooseAlertTone { + switch segue.identifier { + case "segueSoundRestartReminder": selectedSound = .Restart + case "segueSoundRecordingReminder": selectedSound = .Recording + default: preconditionFailure() + } + tvc.delegate = self + } + } + + func notificationSoundCurrent() -> String { + switch selectedSound { + case .Restart: return PrefsShared.RestartReminder.Sound + case .Recording: return Prefs.RecordingReminder.Sound + } + } + + func notificationSoundChanged(filename: String, title: String) { + switch selectedSound { + case .Restart: + restartSound.detailTextLabel?.text = title + PrefsShared.RestartReminder.Sound = filename + case .Recording: + recordingSound.detailTextLabel?.text = title + Prefs.RecordingReminder.Sound = filename + if Prefs.RecordingReminder.Enabled { + PushNotification.scheduleRecordingReminder(force: true) + } + } + } +} diff --git a/main/Settings/TVCSettings.swift b/main/Settings/TVCSettings.swift index ba3c25f..d7888c7 100644 --- a/main/Settings/TVCSettings.swift +++ b/main/Settings/TVCSettings.swift @@ -6,36 +6,63 @@ class TVCSettings: UITableViewController { @IBOutlet var cellDomainsIgnored: UITableViewCell! @IBOutlet var cellDomainsBlocked: UITableViewCell! @IBOutlet var cellPrivacyAutoDelete: UITableViewCell! + @IBOutlet var cellNotificationReminder: UITableViewCell! + @IBOutlet var cellNotificationConnectionAlert: UITableViewCell! override func viewDidLoad() { super.viewDidLoad() - reloadToggleState() - reloadDataSource() - NotifyVPNStateChanged.observe(call: #selector(reloadToggleState), on: self) - NotifyDNSFilterChanged.observe(call: #selector(reloadDataSource), on: self) + reloadVPNState() + reloadLoggingFilterUI() + reloadPrivacyUI() + NotifyVPNStateChanged.observe(call: #selector(reloadVPNState), on: self) + NotifyDNSFilterChanged.observe(call: #selector(reloadLoggingFilterUI), on: self) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + reloadNotificationState() + } - // MARK: - VPN Proxy Settings + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + // FIXME: there is a lag between tap and open when run on device + if let cell = tableView.cellForRow(at: indexPath), cell === cellPrivacyAutoDelete { + openAutoDeletePicker() + } + } + + func openRestartVPNSettings() { scrollToSection(0, animated: false) } + func openNotificationSettings() { scrollToSection(2, animated: false) } + private func scrollToSection(_ section: Int, animated: Bool) { + tableView.scrollToRow(at: .init(row: 0, section: section), at: .top, animated: animated) + } +} + + +// MARK: - VPN Proxy Settings + +extension TVCSettings { + @objc private func reloadVPNState() { + vpnToggle.isOn = (GlassVPN.state != .off) + vpnToggle.onTintColor = (GlassVPN.state == .inbetween ? .systemYellow : nil) + UIApplication.shared.applicationIconBadgeNumber = + !vpnToggle.isOn && + PrefsShared.RestartReminder.Enabled && + PrefsShared.RestartReminder.WithBadge ? 1 : 0 + } @IBAction private func toggleVPNProxy(_ sender: UISwitch) { GlassVPN.setEnabled(sender.isOn) } - - @objc private func reloadToggleState() { - vpnToggle.isOn = (GlassVPN.state != .off) - vpnToggle.onTintColor = (GlassVPN.state == .inbetween ? .systemYellow : nil) - } - - - // MARK: - Logging Filter - - @objc private func reloadDataSource() { - let (blocked, ignored) = DomainFilter.counts() +} + + +// MARK: - Logging Filter + +extension TVCSettings { + @objc private func reloadLoggingFilterUI() { + let (blocked, ignored, _, _) = DomainFilter.counts() cellDomainsIgnored.detailTextLabel?.text = "\(ignored) Domains" cellDomainsBlocked.detailTextLabel?.text = "\(blocked) Domains" - let (one, two) = autoDeleteSelection([1, 7, 31]) - cellPrivacyAutoDelete.detailTextLabel?.text = autoDeleteString(one, unit: two) } override func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) { @@ -65,38 +92,84 @@ class TVCSettings: UITableViewController { break } } +} + + +// MARK: - Privacy + +extension TVCSettings { + private func reloadPrivacyUI() { + let (num, unit) = getAutoDeleteSelection([1, 7, 31]) + let str: String + switch num { + case 0: str = "Never" + case 1: str = "1 \(["Day", "Week", "Month"][unit])" + default: str = "\(num) \(["Days", "Weeks", "Months"][unit])" + } + cellPrivacyAutoDelete.detailTextLabel?.text = str + } + private func getAutoDeleteSelection(_ multiplier: [Int]) -> (Int, Int) { + let current = PrefsShared.AutoDeleteLogsDays + let snd = multiplier.lastIndex { current % $0 == 0 }! // make sure 1 is in list + return (current / multiplier[snd], snd) + } - // MARK: - Privacy - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - // FIXME: there is a lag between tap and open when run on device - if let cell = tableView.cellForRow(at: indexPath), cell === cellPrivacyAutoDelete { - let multiplier = [1, 7, 31] - let (one, two) = autoDeleteSelection(multiplier) - - let picker = DurationPickerAlert( - title: "Auto-delete logs", - detail: "Warning: Logs older than the selected interval are deleted immediately! " + - "Logs are also deleted on each app launch, and periodically in the background as long as the VPN is running.", - options: [(0...30).map{"\($0)"}, ["Days", "Weeks", "Months"]], - widths: [0.4, 0.6]) - picker.pickerView.setSelection([min(30, one), two]) - picker.present(in: self) { _, idx in - cell.detailTextLabel?.text = autoDeleteString(idx[0], unit: idx[1]) - let asDays = idx[0] * multiplier[idx[1]] - PrefsShared.AutoDeleteLogsDays = asDays - if !GlassVPN.send(.autoDelete(after: asDays)) { - // if VPN isn't active, fallback to immediate local delete - TheGreatDestroyer.deleteLogs(olderThan: asDays) - } + private func openAutoDeletePicker() { + let multiplier = [1, 7, 31] + let (one, two) = getAutoDeleteSelection(multiplier) + + let picker = DurationPickerAlert( + title: "Auto-delete logs", + detail: "Warning: Logs older than the selected interval are deleted immediately! " + + "Logs are also deleted on each app launch, and periodically in the background as long as the VPN is running.", + options: [(0...30).map{"\($0)"}, ["Days", "Weeks", "Months"]], + widths: [0.4, 0.6]) + picker.pickerView.setSelection([min(30, one), two]) + picker.present(in: self) { _, idx in + let asDays = idx[0] * multiplier[idx[1]] + PrefsShared.AutoDeleteLogsDays = asDays + self.reloadPrivacyUI() + if !GlassVPN.send(.autoDelete(after: asDays)) { + // if VPN isn't active, fallback to immediate local delete + TheGreatDestroyer.deleteLogs(olderThan: asDays) } } } +} + + +// MARK: - Notification Settings + +extension TVCSettings { + private func reloadNotificationState() { + let lbl1 = cellNotificationReminder.detailTextLabel + let lbl2 = cellNotificationConnectionAlert.detailTextLabel + readNotificationState { (realAllowed, provisional) in + lbl1?.text = provisional ? "Enabled" : "Disabled" + lbl2?.text = realAllowed ? "Enabled" : "Disabled" + } + } - - // MARK: - Reset Settings - + private func readNotificationState(_ closure: @escaping (_ all: Bool, _ prov: Bool) -> Void) { + let en1 = PrefsShared.ConnectionAlerts.Enabled + let en2 = Prefs.RecordingReminder.Enabled || PrefsShared.RestartReminder.Enabled + closure(en1, en2) + guard en1 || en2 else { return } + PushNotification.allowed { state in + switch state { + case .NotDetermined, .Denied: closure(false, false) + case .Authorized: closure(en1, en2) + case .Provisional: closure(false, en2) + } + } + } +} + + +// MARK: - Reset Settings + +extension TVCSettings { @IBAction private func resetTutorialAlerts(_ sender: UIButton) { Prefs.DidShowTutorial.Welcome = false Prefs.DidShowTutorial.Recordings = false @@ -112,10 +185,12 @@ class TVCSettings: UITableViewController { TheGreatDestroyer.deleteAllLogs() }.presentIn(self) } - - - // MARK: - Advanced - +} + + +// MARK: - Advanced + +extension TVCSettings { @IBAction private func exportDB() { AppDB?.vacuum() let sheet = UIActivityViewController(activityItems: [URL.internalDB()], applicationActivities: nil) @@ -130,24 +205,3 @@ class TVCSettings: UITableViewController { return nil } } - - -// ------------------------------- -// | -// | MARK: - Helper methods -// | -// ------------------------------- - -private func autoDeleteSelection(_ multiplier: [Int]) -> (Int, Int) { - let current = PrefsShared.AutoDeleteLogsDays - let snd = multiplier.lastIndex { current % $0 == 0 }! // make sure 1 is in list - return (current / multiplier[snd], snd) -} - -private func autoDeleteString(_ num: Int, unit: Int) -> String { - switch num { - case 0: return "Never" - case 1: return "1 \(["Day", "Week", "Month"][unit])" - default: return "\(num) \(["Days", "Weeks", "Months"][unit])" - } -} diff --git a/main/TBCMain.swift b/main/TBCMain.swift index a5c9845..40ef274 100644 --- a/main/TBCMain.swift +++ b/main/TBCMain.swift @@ -11,6 +11,9 @@ class TBCMain: UITabBarController { if !Prefs.DidShowTutorial.Welcome { self.perform(#selector(showWelcomeMessage), with: nil, afterDelay: 0.5) } + if #available(iOS 10.0, *) { + initNotifications() + } } @objc private func reloadTabBarBadge() { @@ -57,3 +60,63 @@ class TBCMain: UITabBarController { } } } + +extension TBCMain { + @discardableResult func openTab(_ index: Int) -> UIViewController? { + selectedIndex = index + guard let nav = selectedViewController as? UINavigationController else { + return selectedViewController + } + nav.popToRootViewController(animated: false) + return nav.topViewController + } +} + +// MARK: - Push Notifications + +@available(iOS 10.0, *) +extension TBCMain: UNUserNotificationCenterDelegate { + + func initNotifications() { + UNUserNotificationCenter.current().delegate = self + guard Prefs.RecordingReminder.Enabled else { + return + } + PushNotification.allowed { + switch $0 { + case .NotDetermined: + PushNotification.requestProvisionalOrDoNothing { success in + guard success else { return } + PushNotification.scheduleRecordingReminder(force: false) + } + case .Denied: + break + case .Authorized, .Provisional: + PushNotification.scheduleRecordingReminder(force: false) + } + } + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.alert, .badge, .sound]) // in-app notifications + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + switch response.notification.request.identifier { + case PushNotification.Identifier.YouShallRecordMoreReminder.rawValue: + selectedIndex = 1 // open recordings tab + case PushNotification.Identifier.CantStopMeNowReminder.rawValue: + (openTab(2) as! TVCSettings).openRestartVPNSettings() + //case PushNotification.Identifier.RestInPeaceTombstoneReminder // only badge + default: // domain notification + // TODO: open specific domain? + openTab(0) // open Requests tab + } + completionHandler() + } + + @available(iOS 12.0, *) + func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { + (openTab(2) as! TVCSettings).openNotificationSettings() + } +} diff --git a/media/sounds/clock.caf b/media/sounds/clock.caf new file mode 100644 index 0000000000000000000000000000000000000000..d25cbf6ea0e1d48195f66b91e7b5075f6eef70f0 GIT binary patch literal 11712 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 literal 0 HcmV?d00001 diff --git a/media/sounds/drum1.caf b/media/sounds/drum1.caf new file mode 100644 index 0000000000000000000000000000000000000000..17676133fe2652fead60fc683f990942caf68066 GIT binary patch literal 8550 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 Jn?P!?U;xizPTc?i literal 0 HcmV?d00001 diff --git a/media/sounds/drum2.caf b/media/sounds/drum2.caf new file mode 100644 index 0000000000000000000000000000000000000000..037b1f1a130db8d4a1c13d969fc0aaaa908acf57 GIT binary patch literal 12800 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 literal 0 HcmV?d00001 diff --git a/media/sounds/plop1.caf b/media/sounds/plop1.caf new file mode 100644 index 0000000000000000000000000000000000000000..1a11127ddcf5aa18d7dff8d035c76d98e52762ad GIT binary patch literal 4538 zcmYdJOiN>6WMD{1Ely^D00oDq9StC6W^SShh-P45P-0+UaA05nsbpZt%uCCMirX_V zFfb(+6cpuGWacK8q$;G87A2Ns=I1G7<|!1XCg&Y9hN_w3r^ zuzNL!lbf@%v$7J?gj0L>?m4|{7Kfp$iHWhX(8OKm_Uw6Z{Pe6Y0Vfw{V`HJlQ)h1P z-o2Vr5hSm~xbn=I)4OJME;^{_V&ZHp)VS-;>C>yaIE+k9jg$pv-oCwi_iPR$GZPgh z#XSrwZ|^z1YNmp-i?Oj#!|5}7cCF$tbTLsDZrpii_vtQ%Pa6zfjg6RBp1!+!XP1#v zsFEVn%2Q`{ubw5~Y^q|!IN{Xo|9Y!ecPb_us|Yo$JhNu?ECypUXC=W2yY}wh-N|93 zYOK`wUv$Mxfgl$Zp^0zy?A<*}AlTGJiD|+ukTV2aT%3&*8<^yFo;kgH_e?=&6BA>> z#+iFg?_NC<XlkM&wBXF`U9&p{U7VGa6ejN6J$vWwHUAqY%u-MY7FIG53U6p= N;NalkghU&h1OUlK--rMJ literal 0 HcmV?d00001 diff --git a/media/sounds/plop2.caf b/media/sounds/plop2.caf new file mode 100644 index 0000000000000000000000000000000000000000..555c7a652be37f7169c4583ec2e8ef36b17fe618 GIT binary patch literal 4606 zcmYdJOiN>6WMD{1Ely^D00oDq9StC6W^SShh-P45P-0+UaA05nsbpZt%uCCMirX(ol+dK9r%DR+O3wVe>md=uvVs1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLjZpWq$HLkLR2s@LHhIa8yFfSCe9R?$-uzLIgx>pL4b4S z%n6(`1sG;B&g7aP`M=@u#EA?mCveT2DX??phM7CHZ%WRR+{wwkL0D?`F3n9Wmj4^( ztz2licUR!7J-=74+w*SK@BjZ-?w(nw;;iVbB-C*7>>jqmYj>~a401JA5pFznX3y@~ z0xmAbN{u`BtzF%vXsW6#xHA0MnbWJg6hn-a8F%j6vx?K%#fWLa=`*W34U>(8ChXd~ zyYq^@p{o(oj5E7eNd!A9Gw#~Erc1%ZSa`*m)13mr%1kTo%>HIJOQ~2`XvOU{vm~61 zl^ag)StSu{EVSVE?%4`1#=;A3@BX`VCWn!-PzRTytMY>LXJ$z_n<`JZeP$JhlZi3Y zja4`LYR{Tk0>-976aG&;xqJ6a$6#aO zg{yY0(J)aHT6uOCr-F&H(8S%VZm(Wo8Th|N#Yt)6uHCDZjE$KDC7C8N%-+p4Q)=c+ zLqQ=yLt|5CMFuA3o##*Qo~`KO;w&`b)NPQTffs~+7+mg7 literal 0 HcmV?d00001 diff --git a/media/sounds/snap1.caf b/media/sounds/snap1.caf new file mode 100644 index 0000000000000000000000000000000000000000..53bb09b9689089c4b540bbc07803da2c455f7a34 GIT binary patch literal 11304 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%0AUzBWpri&4 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 literal 0 HcmV?d00001 diff --git a/media/sounds/typewriter1.caf b/media/sounds/typewriter1.caf new file mode 100644 index 0000000000000000000000000000000000000000..d9bb933d62215938e44b130c224ad07463bddd32 GIT binary patch literal 12528 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 LEpwT)8W=bL<(S9o literal 0 HcmV?d00001 diff --git a/media/sounds/typewriter2.caf b/media/sounds/typewriter2.caf new file mode 100644 index 0000000000000000000000000000000000000000..40f265abdfe45fdf08e065c19b1c2331828c972b GIT binary patch literal 12868 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 F006*;hC2WN literal 0 HcmV?d00001 diff --git a/media/sounds/wood1.caf b/media/sounds/wood1.caf new file mode 100644 index 0000000000000000000000000000000000000000..06da4a4f3c9195a36148624cd0cb04153a4941a5 GIT binary patch literal 11440 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 literal 0 HcmV?d00001 diff --git a/media/sounds/wood2.caf b/media/sounds/wood2.caf new file mode 100644 index 0000000000000000000000000000000000000000..fd28b1d7db7eb114a9aeb9122592ff9a5c054137 GIT binary patch literal 11780 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 Q3}})TG&Tv9WME(b0M@?t=>Px# literal 0 HcmV?d00001