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 0000000..d25cbf6 Binary files /dev/null and b/media/sounds/clock.caf differ diff --git a/media/sounds/drum1.caf b/media/sounds/drum1.caf new file mode 100644 index 0000000..1767613 Binary files /dev/null and b/media/sounds/drum1.caf differ diff --git a/media/sounds/drum2.caf b/media/sounds/drum2.caf new file mode 100644 index 0000000..037b1f1 Binary files /dev/null and b/media/sounds/drum2.caf differ diff --git a/media/sounds/plop1.caf b/media/sounds/plop1.caf new file mode 100644 index 0000000..1a11127 Binary files /dev/null and b/media/sounds/plop1.caf differ diff --git a/media/sounds/plop2.caf b/media/sounds/plop2.caf new file mode 100644 index 0000000..555c7a6 Binary files /dev/null and b/media/sounds/plop2.caf differ diff --git a/media/sounds/snap1.caf b/media/sounds/snap1.caf new file mode 100644 index 0000000..53bb09b Binary files /dev/null and b/media/sounds/snap1.caf differ diff --git a/media/sounds/snap2.caf b/media/sounds/snap2.caf new file mode 100644 index 0000000..89780b7 Binary files /dev/null and b/media/sounds/snap2.caf differ diff --git a/media/sounds/typewriter1.caf b/media/sounds/typewriter1.caf new file mode 100644 index 0000000..d9bb933 Binary files /dev/null and b/media/sounds/typewriter1.caf differ diff --git a/media/sounds/typewriter2.caf b/media/sounds/typewriter2.caf new file mode 100644 index 0000000..40f265a Binary files /dev/null and b/media/sounds/typewriter2.caf differ diff --git a/media/sounds/wood1.caf b/media/sounds/wood1.caf new file mode 100644 index 0000000..06da4a4 Binary files /dev/null and b/media/sounds/wood1.caf differ diff --git a/media/sounds/wood2.caf b/media/sounds/wood2.caf new file mode 100644 index 0000000..fd28b1d Binary files /dev/null and b/media/sounds/wood2.caf differ