diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2020-09-15 13:24:42 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2021-02-24 14:28:01 +0100 |
| commit | 1a789a006da5bbf788d8ca956fbcb96a0c884144 (patch) | |
| tree | edb539421099207a046c03e531213fe12b993265 | |
| parent | c25245302974e7534fbc665420e294e0effcaf80 (diff) | |
| download | mullvadvpn-1a789a006da5bbf788d8ca956fbcb96a0c884144.tar.xz mullvadvpn-1a789a006da5bbf788d8ca956fbcb96a0c884144.zip | |
Implement problem report UI and log consolidation
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 30 | ||||
| -rw-r--r-- | ios/MullvadVPN/Account.swift | 2 | ||||
| -rw-r--r-- | ios/MullvadVPN/AccountViewController.xib | 2 | ||||
| -rw-r--r-- | ios/MullvadVPN/ConsolidatedApplicationLog.swift | 200 | ||||
| -rw-r--r-- | ios/MullvadVPN/LoginViewController.swift | 6 | ||||
| -rw-r--r-- | ios/MullvadVPN/LoginViewController.xib | 19 | ||||
| -rw-r--r-- | ios/MullvadVPN/NSRegularExpression+IPAddress.swift | 52 | ||||
| -rw-r--r-- | ios/MullvadVPN/ProblemReportReviewViewController.swift | 63 | ||||
| -rw-r--r-- | ios/MullvadVPN/ProblemReportSubmissionOverlayView.swift | 194 | ||||
| -rw-r--r-- | ios/MullvadVPN/ProblemReportViewController.swift | 653 | ||||
| -rw-r--r-- | ios/MullvadVPN/SettingsViewController.swift | 18 | ||||
| -rw-r--r-- | ios/MullvadVPN/StatusImageView.swift | 50 | ||||
| -rw-r--r-- | ios/MullvadVPN/UIBarButtonItem+KeyboardNavigation.swift | 61 |
13 files changed, 1338 insertions, 12 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 6f4e0914b3..3b9addc9c1 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ 58293FB125124117005D0BB5 /* CustomTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58293FB025124117005D0BB5 /* CustomTextField.swift */; }; 58293FB3251241B4005D0BB5 /* CustomTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58293FB2251241B3005D0BB5 /* CustomTextView.swift */; }; 58293FB725138B88005D0BB5 /* CustomNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58293FB625138B88005D0BB5 /* CustomNavigationController.swift */; }; + 58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58293FAC2510CA58005D0BB5 /* ProblemReportViewController.swift */; }; 582AE3102440A6CA00E6733A /* AccountTokenInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582AE30F2440A6CA00E6733A /* AccountTokenInput.swift */; }; 582AE3122440CA0D00E6733A /* AccountTokenInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582AE3112440CA0D00E6733A /* AccountTokenInputTests.swift */; }; 582AE3132440CA2700E6733A /* AccountTokenInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582AE30F2440A6CA00E6733A /* AccountTokenInput.swift */; }; @@ -102,6 +103,8 @@ 586BD68322B7BBD800BB7F9F /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 586BD68222B7BBD800BB7F9F /* NetworkExtension.framework */; }; 586BD68422B7BBE400BB7F9F /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 586BD68222B7BBD800BB7F9F /* NetworkExtension.framework */; }; 5871FB8325498CA20051A0A4 /* Swizzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5871FB8225498CA20051A0A4 /* Swizzle.swift */; }; + 5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */; }; + 5871FBA0254C26C00051A0A4 /* NSRegularExpression+IPAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5871FB9F254C26BF0051A0A4 /* NSRegularExpression+IPAddress.swift */; }; 5873884D239E6D7E00E96C4E /* EmbeddedViewContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5873884C239E6D7E00E96C4E /* EmbeddedViewContainerView.swift */; }; 587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587425C02299833500CA2045 /* RootContainerViewController.swift */; }; 5877153023981F7B001F8237 /* WireguardKeysViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5877152F23981F7B001F8237 /* WireguardKeysViewController.swift */; }; @@ -123,6 +126,7 @@ 588D2FE3248AC27F00E313F7 /* AsyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E973DD24850EB600096F90 /* AsyncOperation.swift */; }; 58906DE02445C7A5002F0673 /* NEProviderStopReason+Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58906DDF2445C7A5002F0673 /* NEProviderStopReason+Debug.swift */; }; 58907D9524D17B4E00CFC3F5 /* DisconnectSplitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58907D9424D17B4E00CFC3F5 /* DisconnectSplitButton.swift */; }; + 5891BF5125E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5891BF5025E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift */; }; 5896AE7E246ACE65005B36CB /* KeychainAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */; }; 5896AE7F246ACE76005B36CB /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FAEDF6245088E100CB0F5B /* Keychain.swift */; }; 5896AE80246ACE79005B36CB /* KeychainClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FAEE0024533A9C00CB0F5B /* KeychainClass.swift */; }; @@ -189,6 +193,8 @@ 58DF28A52417CB4B00E836B0 /* AppStorePaymentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DF28A42417CB4B00E836B0 /* AppStorePaymentManager.swift */; }; 58E5BC2624FEB6DB00A53A76 /* AccountViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 58E5BC2524FEB6DB00A53A76 /* AccountViewController.xib */; }; 58E6771F24ADFE7800AA26E7 /* SettingsNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E6771E24ADFE7800AA26E7 /* SettingsNavigationController.swift */; }; + 58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF580A25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift */; }; + 58EF581125D69DB400AEBA94 /* StatusImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF581025D69DB400AEBA94 /* StatusImageView.swift */; }; 58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */; }; 58F3C0962492617E003E76BE /* AsyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E973DD24850EB600096F90 /* AsyncOperation.swift */; }; 58F3C0A0249BBF1E003E76BE /* DiffableDataSources in Frameworks */ = {isa = PBXBuildFile; productRef = 58F3C09F249BBF1E003E76BE /* DiffableDataSources */; }; @@ -200,6 +206,7 @@ 58F840B02464382C0044E708 /* KeychainItemRevision.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F840AE2464382C0044E708 /* KeychainItemRevision.swift */; }; 58F840B22464491D0044E708 /* ChainedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F840B12464491D0044E708 /* ChainedError.swift */; }; 58F840B32464491D0044E708 /* ChainedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F840B12464491D0044E708 /* ChainedError.swift */; }; + 58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F8AC0D25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift */; }; 58FAEDEF245069C700CB0F5B /* KeychainAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */; }; 58FAEDF1245069CA00CB0F5B /* KeychainAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */; }; 58FAEDF4245088B300CB0F5B /* KeychainError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58AEEF642344A36000C9BBD5 /* KeychainError.swift */; }; @@ -290,6 +297,7 @@ 58293FB025124117005D0BB5 /* CustomTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTextField.swift; sourceTree = "<group>"; }; 58293FB2251241B3005D0BB5 /* CustomTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTextView.swift; sourceTree = "<group>"; }; 58293FB625138B88005D0BB5 /* CustomNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomNavigationController.swift; sourceTree = "<group>"; }; + 58293FAC2510CA58005D0BB5 /* ProblemReportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportViewController.swift; sourceTree = "<group>"; }; 582AE30F2440A6CA00E6733A /* AccountTokenInput.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountTokenInput.swift; sourceTree = "<group>"; }; 582AE3112440CA0D00E6733A /* AccountTokenInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTokenInputTests.swift; sourceTree = "<group>"; }; 582BB1AE229566420055B6EF /* SettingsCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsCell.swift; sourceTree = "<group>"; }; @@ -312,6 +320,8 @@ 5868585424054096000B8131 /* AppButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppButton.swift; sourceTree = "<group>"; }; 586BD68222B7BBD800BB7F9F /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; 5871FB8225498CA20051A0A4 /* Swizzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Swizzle.swift; sourceTree = "<group>"; }; + 5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsolidatedApplicationLog.swift; sourceTree = "<group>"; }; + 5871FB9F254C26BF0051A0A4 /* NSRegularExpression+IPAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+IPAddress.swift"; sourceTree = "<group>"; }; 5873884C239E6D7E00E96C4E /* EmbeddedViewContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedViewContainerView.swift; sourceTree = "<group>"; }; 587425C02299833500CA2045 /* RootContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootContainerViewController.swift; sourceTree = "<group>"; }; 5877152F23981F7B001F8237 /* WireguardKeysViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireguardKeysViewController.swift; sourceTree = "<group>"; }; @@ -327,6 +337,7 @@ 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationViewController.swift; sourceTree = "<group>"; }; 58906DDF2445C7A5002F0673 /* NEProviderStopReason+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NEProviderStopReason+Debug.swift"; sourceTree = "<group>"; }; 58907D9424D17B4E00CFC3F5 /* DisconnectSplitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisconnectSplitButton.swift; sourceTree = "<group>"; }; + 5891BF5025E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBarButtonItem+KeyboardNavigation.swift"; sourceTree = "<group>"; }; 5894E725236B2801008A2793 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDateComponentsFormatting.swift; sourceTree = "<group>"; }; 5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDateComponentsFormattingTests.swift; sourceTree = "<group>"; }; @@ -383,12 +394,15 @@ 58E6771E24ADFE7800AA26E7 /* SettingsNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNavigationController.swift; sourceTree = "<group>"; }; 58E973DD24850EB600096F90 /* AsyncOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncOperation.swift; sourceTree = "<group>"; }; 58ECD29123F178FD004298B6 /* Screenshots.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Screenshots.xcconfig; sourceTree = "<group>"; }; + 58EF580A25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportSubmissionOverlayView.swift; sourceTree = "<group>"; }; + 58EF581025D69DB400AEBA94 /* StatusImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusImageView.swift; sourceTree = "<group>"; }; 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerActivityIndicatorView.swift; sourceTree = "<group>"; }; 58F3C0A3249CB069003E76BE /* HeaderBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderBarView.swift; sourceTree = "<group>"; }; 58F3C0A524A50155003E76BE /* relays.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = relays.json; sourceTree = "<group>"; }; 58F7D30E250FA12E0097BE4E /* AnyIPEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyIPEndpoint.swift; sourceTree = "<group>"; }; 58F840AE2464382C0044E708 /* KeychainItemRevision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainItemRevision.swift; sourceTree = "<group>"; }; 58F840B12464491D0044E708 /* ChainedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChainedError.swift; sourceTree = "<group>"; }; + 58F8AC0D25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportReviewViewController.swift; sourceTree = "<group>"; }; 58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainAttributes.swift; sourceTree = "<group>"; }; 58FAEDF6245088E100CB0F5B /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = "<group>"; }; 58FAEDFC24533A5500CB0F5B /* KeychainMatchLimit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainMatchLimit.swift; sourceTree = "<group>"; }; @@ -586,10 +600,15 @@ 5866F39B2243B82D00168AE5 /* MullvadVPN.entitlements */, 58906DDF2445C7A5002F0673 /* NEProviderStopReason+Debug.swift */, 5811DE4F239014550011EB53 /* NEVPNStatus+Debug.swift */, + 5871FB9F254C26BF0051A0A4 /* NSRegularExpression+IPAddress.swift */, 58CC40EE24A601900019D96E /* ObserverList.swift */, 580EE1FF24B3218800F9D8A1 /* Operations */, 583BC70624FE4DC400C9DE04 /* Optional+DispatchQueue.swift */, 5845F841236CBACD00B2D93C /* PacketTunnelIpc.swift */, + 58C6B35322BB87C4003C19AD /* PrivateKeyWithMetadata.swift */, + 5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */, + 58293FAC2510CA58005D0BB5 /* ProblemReportViewController.swift */, + 58F8AC0D25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift */, 58BFA5C522A7C97F00A6173D /* RelayCache.swift */, 58781CC822AE7CA8009B9D8E /* RelayConstraints.swift */, 58781CD422AFBA39009B9D8E /* RelaySelector.swift */, @@ -620,7 +639,9 @@ 58B8743122B25A7600015324 /* WireguardAssociatedAddresses.swift */, 5877152F23981F7B001F8237 /* WireguardKeysViewController.swift */, 58B9814D24FEA70D00C0D59E /* WireguardKeysViewController.xib */, - 58C6B35322BB87C4003C19AD /* PrivateKeyWithMetadata.swift */, + 58EF580A25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift */, + 58EF581025D69DB400AEBA94 /* StatusImageView.swift */, + 5891BF5025E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift */, ); path = MullvadVPN; sourceTree = "<group>"; @@ -970,6 +991,7 @@ buildActionMask = 2147483647; files = ( 58BFA5CC22A7CE1F00A6173D /* ApplicationConfiguration.swift in Sources */, + 5891BF5125E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift in Sources */, 58BA692E23E99EFF009DC256 /* Locking.swift in Sources */, 580EE21E24B3237F00F9D8A1 /* OutputOperation.swift in Sources */, 5840250122B1124600E4CFEC /* IPAddress+Codable.swift in Sources */, @@ -980,6 +1002,7 @@ 58A1AA8C23F5584C009F7EA6 /* ConnectionPanelView.swift in Sources */, 582BB1B52295780F0055B6EF /* AccountExpiry.swift in Sources */, 582BB1B3229574F40055B6EF /* SettingsAccountCell.swift in Sources */, + 58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */, 58CCA010224249A1004F3011 /* ConnectViewController.swift in Sources */, 580EE21524B3231200F9D8A1 /* OperationBlockObserver.swift in Sources */, 58BFA5C622A7C97F00A6173D /* RelayCache.swift in Sources */, @@ -987,6 +1010,7 @@ 588D2FE3248AC27F00E313F7 /* AsyncOperation.swift in Sources */, 5877153023981F7B001F8237 /* WireguardKeysViewController.swift in Sources */, 5850367F25A481D800A43E93 /* IPAddressRange+Codable.swift in Sources */, + 5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */, 58FAEDEF245069C700CB0F5B /* KeychainAttributes.swift in Sources */, 58CB0EE024B86751001EF0D8 /* MullvadRest.swift in Sources */, 580EE20924B3224200F9D8A1 /* RetryOperation.swift in Sources */, @@ -994,6 +1018,7 @@ 582AE3102440A6CA00E6733A /* AccountTokenInput.swift in Sources */, 58FAEDF7245088E100CB0F5B /* Keychain.swift in Sources */, 5871FB8325498CA20051A0A4 /* Swizzle.swift in Sources */, + 58EF581125D69DB400AEBA94 /* StatusImageView.swift in Sources */, 58907D9524D17B4E00CFC3F5 /* DisconnectSplitButton.swift in Sources */, 5888AD87227B17950051EB06 /* SelectLocationViewController.swift in Sources */, 58293FB3251241B4005D0BB5 /* CustomTextView.swift in Sources */, @@ -1008,6 +1033,7 @@ 5840250422B11AB700E4CFEC /* MullvadEndpoint.swift in Sources */, 58CC40EF24A601900019D96E /* ObserverList.swift in Sources */, 58CCA01822426713004F3011 /* AccountViewController.swift in Sources */, + 5871FBA0254C26C00051A0A4 /* NSRegularExpression+IPAddress.swift in Sources */, 5868585524054096000B8131 /* AppButton.swift in Sources */, 5845F842236CBACD00B2D93C /* PacketTunnelIpc.swift in Sources */, 58781CC922AE7CA8009B9D8E /* RelayConstraints.swift in Sources */, @@ -1031,6 +1057,7 @@ 5807E2C02432038B00F5FF30 /* String+Split.swift in Sources */, 58CE5E66224146200008646E /* LoginViewController.swift in Sources */, 580EE21B24B3236900F9D8A1 /* InputOperation.swift in Sources */, + 58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */, 58FD5BE724192A2C00112C88 /* AppStoreReceipt.swift in Sources */, 5835B7CC233B76CB0096D79F /* TunnelManager.swift in Sources */, 5815039724D6ECAE00C9C50E /* CustomFormatLogHandler.swift in Sources */, @@ -1046,6 +1073,7 @@ 58CCA01222424D11004F3011 /* SettingsViewController.swift in Sources */, 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */, 580EE22424B3243100F9D8A1 /* AsyncBlockOperation.swift in Sources */, + 58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */, 589AB4F7227B64450039131E /* BasicTableViewCell.swift in Sources */, 58B9EB152489139B00095626 /* DisplayChainedError.swift in Sources */, 5888AD7F2279B6BF0051EB06 /* RelayStatusIndicatorView.swift in Sources */, diff --git a/ios/MullvadVPN/Account.swift b/ios/MullvadVPN/Account.swift index 3f96d9308b..2146c65a2f 100644 --- a/ios/MullvadVPN/Account.swift +++ b/ios/MullvadVPN/Account.swift @@ -76,7 +76,7 @@ class Account { case exclusive } - private let rest = MullvadRest() + private let rest = MullvadRest(session: URLSession(configuration: .ephemeral)) private let operationQueue = OperationQueue() private lazy var exclusivityController = ExclusivityController<ExclusivityCategory>(operationQueue: operationQueue) diff --git a/ios/MullvadVPN/AccountViewController.xib b/ios/MullvadVPN/AccountViewController.xib index 7094f3fdd4..42014035e3 100644 --- a/ios/MullvadVPN/AccountViewController.xib +++ b/ios/MullvadVPN/AccountViewController.xib @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097.2" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097.3" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <device id="retina6_1" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> diff --git a/ios/MullvadVPN/ConsolidatedApplicationLog.swift b/ios/MullvadVPN/ConsolidatedApplicationLog.swift new file mode 100644 index 0000000000..366ef0c611 --- /dev/null +++ b/ios/MullvadVPN/ConsolidatedApplicationLog.swift @@ -0,0 +1,200 @@ +// +// ConsolidatedApplicationLog.swift +// MullvadVPN +// +// Created by pronebird on 29/10/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +private let kLogMaxReadBytes: UInt64 = 128 * 1024 +private let kLogDelimeter = "====================" +private let kRedactedPlaceholder = "[REDACTED]" +private let kRedactedAccountPlaceholder = "[REDACTED ACCOUNT NUMBER]" +private let kRedactedContainerPlaceholder = "[REDACTED CONTAINER PATH]" + +class ConsolidatedApplicationLog: TextOutputStreamable { + + typealias Metadata = KeyValuePairs<MetadataKey, String> + + enum MetadataKey: String { + case id, os + case productVersion = "mullvad-product-version" + } + + struct LogAttachment { + let label: String + let content: String + } + + enum Error: ChainedError { + case logFileDoesNotExist(String) + case invalidLogFileURL(URL) + + var errorDescription: String? { + switch self { + case .logFileDoesNotExist(let path): + return "Log file does not exist: \(path)" + case .invalidLogFileURL(let url): + return "Invalid log file URL: \(url.absoluteString)" + } + } + } + + let redactCustomStrings: [String] + let applicationGroupContainers: [URL] + let metadata: Metadata + + private var logs: [LogAttachment] = [] + + init(redactCustomStrings: [String], redactContainerPathsForSecurityGroupIdentifiers securityGroupIdentifiers: [String]) { + self.metadata = Self.makeMetadata() + self.redactCustomStrings = redactCustomStrings + + applicationGroupContainers = securityGroupIdentifiers.compactMap { (securityGroupIdentifier) -> URL? in + return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: securityGroupIdentifier) + } + } + + func addLogFile(fileURL: URL) { + guard fileURL.isFileURL else { + addError(message: fileURL.absoluteString, error: Error.invalidLogFileURL(fileURL)) + return + } + + let path = fileURL.path + let redactedPath = redact(string: path) + + switch Self.readFileLossy(path: path, maxBytes: kLogMaxReadBytes) { + case .success(let lossyString): + let redactedString = redact(string: lossyString) + logs.append(LogAttachment(label: redactedPath, content: redactedString)) + + case .failure(let error): + addError(message: redactedPath, error: error) + } + } + + func addLogFiles(fileURLs: [URL]) { + fileURLs.forEach(self.addLogFile) + } + + func addError<ErrorType: ChainedError>(message: String, error: ErrorType) { + let redactedError = redact(string: error.displayChain()) + + logs.append(LogAttachment(label: message, content: redactedError)) + } + + var string: String { + var body = "" + write(to: &body) + return body + } + + func write<Target: TextOutputStream>(to stream: inout Target) { + print("System information:", to: &stream) + for (key, value) in metadata { + print("\(key.rawValue): \(value)", to: &stream) + } + print("", to: &stream) + + for attachment in logs { + print(kLogDelimeter, to: &stream) + print(attachment.label, to: &stream) + print(kLogDelimeter, to: &stream) + print(attachment.content, to: &stream) + print("", to: &stream) + } + } + + private static func makeMetadata() -> Metadata { + let bundle = Bundle.main + let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "nil" + let buildNumber = bundle.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as? String ?? "nil" + + let operatingSystemVersion: String = { + let version = ProcessInfo.processInfo.operatingSystemVersion + return "iOS \(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + }() + + return [ + .id : UUID().uuidString, + .productVersion: "\(version)-b\(buildNumber)", + .os: operatingSystemVersion + ] + } + + private static func readFileLossy(path: String, maxBytes: UInt64) -> Result<String, Error> { + guard let fileHandle = FileHandle(forReadingAtPath: path) else { + return .failure(.logFileDoesNotExist(path)) + } + + let endOfFileOffset = fileHandle.seekToEndOfFile() + if endOfFileOffset > maxBytes { + fileHandle.seek(toFileOffset: endOfFileOffset - maxBytes) + } else { + fileHandle.seek(toFileOffset: 0) + } + + let data = fileHandle.readData(ofLength: Int(kLogMaxReadBytes)) + let lossyString = String(decoding: data, as: UTF8.self) + + return .success(lossyString) + } + + private func redactCustomStrings(string: String) -> String { + return redactCustomStrings.reduce(string) { (resultString, redact) -> String in + return resultString.replacingOccurrences(of: redact, with: kRedactedPlaceholder) + } + } + + private func redact(string: String) -> String { + return [ + self.redactContainerPaths, + Self.redactAccountNumber, + Self.redactIPv4Address, + Self.redactIPv6Address, + self.redactCustomStrings + ].reduce(string) { (resultString, transform) -> String in + return transform(resultString) + } + } + + private func redactContainerPaths(string: String) -> String { + return applicationGroupContainers.reduce(string) { (resultString, containerURL) -> String in + return resultString.replacingOccurrences(of: containerURL.path, with: kRedactedContainerPlaceholder) + } + } + + private static func redactAccountNumber(string: String) -> String { + return redact(regularExpression: try! NSRegularExpression(pattern: #"\d{16}"#), + string: string, + replacementString: kRedactedAccountPlaceholder) + } + + private static func redactIPv4Address(string: String) -> String { + return redact(regularExpression: NSRegularExpression.ipv4RegularExpression, + string: string, + replacementString: kRedactedPlaceholder) + } + + private static func redactIPv6Address(string: String) -> String { + return redact(regularExpression: NSRegularExpression.ipv6RegularExpression, + string: string, + replacementString: kRedactedPlaceholder) + } + + private static func redact(regularExpression: NSRegularExpression, string: String, replacementString: String) -> String { + let nsRange = NSRange((string.startIndex..<string.endIndex), in: string) + let template = NSRegularExpression.escapedTemplate(for: replacementString) + + return regularExpression.stringByReplacingMatches( + in: string, + options: [], + range: nsRange, + withTemplate: template + ) + } + +} diff --git a/ios/MullvadVPN/LoginViewController.swift b/ios/MullvadVPN/LoginViewController.swift index 8c608fb39c..a0c676fb33 100644 --- a/ios/MullvadVPN/LoginViewController.swift +++ b/ios/MullvadVPN/LoginViewController.swift @@ -37,7 +37,7 @@ class LoginViewController: UIViewController, RootContainment { @IBOutlet var loginForm: UIView! @IBOutlet var loginFormWrapperBottomConstraint: NSLayoutConstraint! @IBOutlet var activityIndicator: SpinnerActivityIndicatorView! - @IBOutlet var statusImageView: UIImageView! + @IBOutlet var statusImageView: StatusImageView! @IBOutlet var createAccountButton: AppButton! private let logger = Logger(label: "LoginViewController") @@ -230,11 +230,11 @@ class LoginViewController: UIViewController, RootContainment { switch loginState { case .failure: let opacity: CGFloat = self.accountTextField.isEditing ? 0 : 1 - statusImageView.image = UIImage(named: "IconFail") + statusImageView.style = .failure animateStatusImage(to: opacity) case .success: - statusImageView.image = UIImage(named: "IconSuccess") + statusImageView.style = .success animateStatusImage(to: 1) case .default, .authenticating: diff --git a/ios/MullvadVPN/LoginViewController.xib b/ios/MullvadVPN/LoginViewController.xib index 44055f78f6..e5dd791b1b 100644 --- a/ios/MullvadVPN/LoginViewController.xib +++ b/ios/MullvadVPN/LoginViewController.xib @@ -1,9 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097.2" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <device id="retina6_1" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> <capability name="Named colors" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> @@ -41,9 +41,9 @@ <constraint firstAttribute="height" constant="48" id="tdi-f8-eAx"/> </constraints> </view> - <imageView clipsSubviews="YES" userInteractionEnabled="NO" alpha="0.0" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="IconSuccess" translatesAutoresizingMaskIntoConstraints="NO" id="L2c-40-K8a"> + <view clipsSubviews="YES" userInteractionEnabled="NO" alpha="0.0" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="L2c-40-K8a" customClass="StatusImageView" customModule="MullvadVPN" customModuleProvider="target"> <rect key="frame" x="177" y="259.5" width="60" height="60"/> - </imageView> + </view> <view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" translatesAutoresizingMaskIntoConstraints="NO" id="h09-lb-ltN" userLabel="Form"> <rect key="frame" x="0.0" y="343.5" width="414" height="125.5"/> <subviews> @@ -143,6 +143,7 @@ <edgeInsets key="layoutMargins" top="16" left="0.0" bottom="24" right="0.0"/> </view> </subviews> + <viewLayoutGuide key="safeArea" id="nNG-OH-fpp"/> <color key="backgroundColor" name="Primary"/> <constraints> <constraint firstItem="Hlu-LB-fs2" firstAttribute="top" secondItem="nNG-OH-fpp" secondAttribute="top" id="9Wj-KC-bOY"/> @@ -154,7 +155,6 @@ <constraint firstAttribute="trailing" secondItem="OWx-up-Gr3" secondAttribute="trailing" id="jUk-Gq-dMy"/> </constraints> <edgeInsets key="layoutMargins" top="0.0" left="24" bottom="0.0" right="24"/> - <viewLayoutGuide key="safeArea" id="nNG-OH-fpp"/> <point key="canvasLocation" x="139" y="153"/> </view> <toolbar opaque="NO" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="eMY-ag-aGA"> @@ -179,9 +179,16 @@ <point key="canvasLocation" x="138" y="517"/> </toolbar> </objects> + <designables> + <designable name="M05-uw-Xgl"> + <size key="intrinsicContentSize" width="207.5" height="25.5"/> + </designable> + <designable name="yyj-Bk-eOB"> + <size key="intrinsicContentSize" width="123" height="22"/> + </designable> + </designables> <resources> <image name="DefaultButton" width="9" height="9"/> - <image name="IconSuccess" width="60" height="60"/> <namedColor name="Primary"> <color red="0.16078431372549021" green="0.30196078431372547" blue="0.45098039215686275" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> </namedColor> diff --git a/ios/MullvadVPN/NSRegularExpression+IPAddress.swift b/ios/MullvadVPN/NSRegularExpression+IPAddress.swift new file mode 100644 index 0000000000..30e698f9c4 --- /dev/null +++ b/ios/MullvadVPN/NSRegularExpression+IPAddress.swift @@ -0,0 +1,52 @@ +// +// NSRegularExpression+IPAddress.swift +// MullvadVPN +// +// Created by pronebird on 30/10/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension NSRegularExpression { + static var ipv4RegularExpression: NSRegularExpression { + // Regular expression obtained from: + // https://www.regular-expressions.info/ip.html + let pattern = #""" + \b(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\. + (25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\. + (25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\. + (25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\b + """# + + return try! NSRegularExpression(pattern: pattern, options: [.allowCommentsAndWhitespace]) + } + + static var ipv6RegularExpression: NSRegularExpression { + // Regular expression obtained from: + // https://stackoverflow.com/a/17871737 + let pattern = #""" + # IPv6 RegEx + ( + ([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}| # 1:2:3:4:5:6:7:8 + ([0-9a-fA-F]{1,4}:){1,7}:| # 1:: 1:2:3:4:5:6:7:: + ([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}| # 1::8 1:2:3:4:5:6::8 1:2:3:4:5:6::8 + ([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}| # 1::7:8 1:2:3:4:5::7:8 1:2:3:4:5::8 + ([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}| # 1::6:7:8 1:2:3:4::6:7:8 1:2:3:4::8 + ([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}| # 1::5:6:7:8 1:2:3::5:6:7:8 1:2:3::8 + ([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}| # 1::4:5:6:7:8 1:2::4:5:6:7:8 1:2::8 + [0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})| # 1::3:4:5:6:7:8 1::3:4:5:6:7:8 1::8 + :((:[0-9a-fA-F]{1,4}){1,7}|:)| # ::2:3:4:5:6:7:8 ::2:3:4:5:6:7:8 ::8 :: + fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}| # fe80::7:8%eth0 fe80::7:8%1 (link-local IPv6 addresses with zone index) + ::(ffff(:0{1,4}){0,1}:){0,1} + ((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3} + (25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])| # ::255.255.255.255 ::ffff:255.255.255.255 ::ffff:0:255.255.255.255 (IPv4-mapped IPv6 addresses and IPv4-translated addresses) + ([0-9a-fA-F]{1,4}:){1,4}: + ((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3} + (25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]) # 2001:db8:3:4::192.0.2.33 64:ff9b::192.0.2.33 (IPv4-Embedded IPv6 Address) + ) + """# + + return try! NSRegularExpression(pattern: pattern, options: [.allowCommentsAndWhitespace]) + } +} diff --git a/ios/MullvadVPN/ProblemReportReviewViewController.swift b/ios/MullvadVPN/ProblemReportReviewViewController.swift new file mode 100644 index 0000000000..020d6bb6f6 --- /dev/null +++ b/ios/MullvadVPN/ProblemReportReviewViewController.swift @@ -0,0 +1,63 @@ +// +// ProblemReportReviewViewController.swift +// MullvadVPN +// +// Created by pronebird on 10/02/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class ProblemReportReviewViewController: UIViewController { + + private var textView = UITextView() + private let reportString: String + + private var dismissButtonItem: UIBarButtonItem { + return UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleDismissButton(_:))) + } + + init(reportString: String) { + self.reportString = reportString + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.title = NSLocalizedString("App logs", comment: "") + navigationItem.rightBarButtonItem = dismissButtonItem + + textView.translatesAutoresizingMaskIntoConstraints = false + textView.text = reportString + textView.isEditable = false + if #available(iOS 13.0, *) { + textView.font = UIFont.monospacedSystemFont(ofSize: UIFont.systemFontSize, weight: .regular) + } else { + textView.font = UIFont(name: "Courier", size: UIFont.systemFontSize) + } + + view.addSubview(textView) + + NSLayoutConstraint.activate([ + textView.topAnchor.constraint(equalTo: view.topAnchor), + textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + textView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + // Used to layout constraints so that navigation controller could properly adjust the text + // view insets. + view.layoutIfNeeded() + } + + // MARK: - Actions + + @objc func handleDismissButton(_ sender: Any) { + dismiss(animated: true) + } +} diff --git a/ios/MullvadVPN/ProblemReportSubmissionOverlayView.swift b/ios/MullvadVPN/ProblemReportSubmissionOverlayView.swift new file mode 100644 index 0000000000..1d5b66ed50 --- /dev/null +++ b/ios/MullvadVPN/ProblemReportSubmissionOverlayView.swift @@ -0,0 +1,194 @@ +// +// ProblemReportSubmissionOverlayView.swift +// MullvadVPN +// +// Created by pronebird on 12/02/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import UIKit +import Foundation + +class ProblemReportSubmissionOverlayView: UIView { + + var editButtonAction: (() -> Void)? + var retryButtonAction: (() -> Void)? + + enum State { + case sending + case sent(_ email: String) + case failure(RestError) + + var title: String? { + switch self { + case .sending: + return NSLocalizedString("Sending...", comment: "") + case .sent: + return NSLocalizedString("Sent", comment: "") + case .failure: + return NSLocalizedString("Failed to send", comment: "") + } + } + + var body: NSAttributedString? { + switch self { + case .sending: + return nil + case .sent(let email): + let combinedAttributedString = NSMutableAttributedString(string: NSLocalizedString("Thanks!", comment: ""), attributes: [.foregroundColor: UIColor.successColor]) + + if email.isEmpty { + combinedAttributedString.append(NSAttributedString(string: " ")) + combinedAttributedString.append(NSAttributedString(string: NSLocalizedString("We will look into this.", comment: ""))) + } else { + let emailText = String(format: NSLocalizedString("If needed we will contact you on %@", comment: ""), email) + let emailAttributedString = NSMutableAttributedString(string: emailText) + if let emailRange = emailText.range(of: email) { + let font = UIFont.systemFont(ofSize: 17, weight: .bold) + let nsRange = NSRange(emailRange, in: emailText) + + emailAttributedString.addAttribute(.font, value: font, range: nsRange) + } + + combinedAttributedString.append(NSAttributedString(string: " ")) + combinedAttributedString.append(emailAttributedString) + } + + return combinedAttributedString + + case .failure(let error): + return error.errorChainDescription.flatMap { NSAttributedString(string: $0) } + } + } + } + + var state: State = .sending { + didSet { + transitionToState(self.state) + } + } + + let activityIndicator: SpinnerActivityIndicatorView = { + let indicator = SpinnerActivityIndicatorView(style: .large) + indicator.tintColor = .white + return indicator + }() + let statusImageView = StatusImageView(style: .success) + + let titleLabel: UILabel = { + let textLabel = UILabel() + textLabel.font = UIFont.systemFont(ofSize: 32) + textLabel.textColor = .white + textLabel.numberOfLines = 0 + return textLabel + }() + + let bodyLabel: UILabel = { + let textLabel = UILabel() + textLabel.font = UIFont.systemFont(ofSize: 17) + textLabel.textColor = .white + textLabel.numberOfLines = 0 + return textLabel + }() + + /// Footer stack view that contains action buttons + private lazy var buttonsStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.editMessageButton, self.tryAgainButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 18 + + return stackView + }() + + private lazy var editMessageButton: AppButton = { + let button = AppButton(style: .default) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(NSLocalizedString("Edit message", comment: ""), for: .normal) + button.addTarget(self, action: #selector(handleEditButton), for: .touchUpInside) + return button + }() + + private lazy var tryAgainButton: AppButton = { + let button = AppButton(style: .success) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(NSLocalizedString("Try again", comment: ""), for: .normal) + button.addTarget(self, action: #selector(handleRetryButton), for: .touchUpInside) + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + addSubviews() + transitionToState(state) + + layoutMargins = UIEdgeInsets(top: 8, left: 24, bottom: 24, right: 24) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func addSubviews() { + for subview in [titleLabel, bodyLabel, activityIndicator, statusImageView, buttonsStackView] { + subview.translatesAutoresizingMaskIntoConstraints = false + addSubview(subview) + } + + NSLayoutConstraint.activate([ + statusImageView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor, constant: 32), + statusImageView.centerXAnchor.constraint(equalTo: centerXAnchor), + + activityIndicator.centerXAnchor.constraint(equalTo: statusImageView.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: statusImageView.centerYAnchor), + + titleLabel.topAnchor.constraint(equalTo: statusImageView.bottomAnchor, constant: 60), + titleLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + titleLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + + bodyLabel.topAnchor.constraint(equalToSystemSpacingBelow: titleLabel.bottomAnchor, multiplier: 1), + bodyLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + bodyLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + buttonsStackView.topAnchor.constraint(greaterThanOrEqualTo: bodyLabel.bottomAnchor, constant: 18), + + buttonsStackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + buttonsStackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + buttonsStackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) + ]) + } + + private func transitionToState(_ state: State) { + titleLabel.text = state.title + bodyLabel.attributedText = state.body + + switch state { + case .sending: + activityIndicator.startAnimating() + statusImageView.isHidden = true + buttonsStackView.isHidden = true + + case .sent: + activityIndicator.stopAnimating() + statusImageView.style = .success + statusImageView.isHidden = false + buttonsStackView.isHidden = true + + case .failure: + activityIndicator.stopAnimating() + statusImageView.style = .failure + statusImageView.isHidden = false + buttonsStackView.isHidden = false + } + } + + // MARK: - Actions + + @objc private func handleEditButton() { + editButtonAction?() + } + + @objc private func handleRetryButton() { + retryButtonAction?() + } +} diff --git a/ios/MullvadVPN/ProblemReportViewController.swift b/ios/MullvadVPN/ProblemReportViewController.swift new file mode 100644 index 0000000000..b2096963d6 --- /dev/null +++ b/ios/MullvadVPN/ProblemReportViewController.swift @@ -0,0 +1,653 @@ +// +// ProblemReportViewController.swift +// MullvadVPN +// +// Created by pronebird on 15/09/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class ProblemReportViewController: UIViewController, UITextFieldDelegate, ConditionalNavigation { + + private let mullvadRest = MullvadRest(session: URLSession(configuration: .ephemeral)) + private lazy var consolidatedLog: ConsolidatedApplicationLog = { + let securityGroupIdentifier = ApplicationConfiguration.securityGroupIdentifier + + // TODO: make sure we redact old tokens + let redactStrings = Account.shared.token.flatMap { [$0] } ?? [] + + let report = ConsolidatedApplicationLog( + redactCustomStrings: redactStrings, + redactContainerPathsForSecurityGroupIdentifiers: [securityGroupIdentifier] + ) + + report.addLogFiles(fileURLs: ApplicationConfiguration.logFileURLs) + + return report + }() + + /// Scroll view + private lazy var scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.backgroundColor = .clear + return scrollView + }() + + /// Scroll view content container + private lazy var containerView: UIView = { + let containerView = UIView() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.layoutMargins = UIEdgeInsets(top: 8, left: 24, bottom: 24, right: 24) + containerView.backgroundColor = .clear + return containerView + }() + + /// Subheading label displayed below navigation bar + private lazy var subheaderLabel: UILabel = { + let textLabel = UILabel() + textLabel.translatesAutoresizingMaskIntoConstraints = false + textLabel.numberOfLines = 0 + textLabel.textColor = .white + textLabel.text = NSLocalizedString("To help you more effectively, your app's log file will be attached to this message. Your data will remain secure and private, as it is anonymised before being sent over an encrypted channel.", comment: "") + + return textLabel + }() + + private lazy var emailTextField: CustomTextField = { + let textField = CustomTextField() + textField.translatesAutoresizingMaskIntoConstraints = false + textField.delegate = self + textField.keyboardType = .emailAddress + textField.textContentType = .emailAddress + textField.autocorrectionType = .no + textField.autocapitalizationType = .none + textField.smartInsertDeleteType = .no + textField.returnKeyType = .next + textField.borderStyle = .none + textField.backgroundColor = .white + textField.inputAccessoryView = emailAccessoryToolbar + textField.font = UIFont.systemFont(ofSize: 17) + textField.placeholder = NSLocalizedString("Your email (optional)", comment: "") + + return textField + }() + + private lazy var messageTextView: CustomTextView = { + let textView = CustomTextView() + textView.translatesAutoresizingMaskIntoConstraints = false + textView.backgroundColor = .white + textView.inputAccessoryView = messageAccessoryToolbar + textView.font = UIFont.systemFont(ofSize: 17) + textView.placeholder = NSLocalizedString("Describe your problem", comment: "") + textView.contentInsetAdjustmentBehavior = .never + + return textView + }() + + /// Container view for text input fields + private lazy var textFieldsHolder: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + /// Constraints used when description text view is active + private var activeMessageTextViewConstraints = [NSLayoutConstraint]() + + /// Constraints used when description text view is inactive + private var inactiveMessageTextViewConstraints = [NSLayoutConstraint]() + + /// Flag indicating when the text view is expanded to fill the entire view + private var isMessageTextViewExpanded = false + + /// Keyboard intersection with the controller view + private var keyboardIntersectionRect = CGRect.zero + + /// Bottom content inset necessary to compensate for the keyboard overlapping + var scrollViewBottomContentInsetAccountingForKeyboard: CGFloat { + return max(0, keyboardIntersectionRect.height - view.safeAreaInsets.bottom) + } + + /// Placeholder view used to fill the space within the scroll view when the text view is + /// expanded to fill the entire view + private lazy var messagePlaceholder: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .clear + return view + }() + + /// Footer stack view that contains action buttons + private lazy var buttonsStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.viewLogsButton, self.sendButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 18 + + return stackView + }() + + private lazy var viewLogsButton: AppButton = { + let button = AppButton(style: .default) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(NSLocalizedString("View app logs", comment: ""), for: .normal) + button.addTarget(self, action: #selector(handleViewLogsButtonTap), for: .touchUpInside) + return button + }() + + private lazy var sendButton: AppButton = { + let button = AppButton(style: .success) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(NSLocalizedString("Send", comment: ""), for: .normal) + button.addTarget(self, action: #selector(handleSendButtonTap), for: .touchUpInside) + return button + }() + + private lazy var emailAccessoryToolbar: UIToolbar = { + return makeKeyboardToolbar(canGoBackward: false, canGoForward: true) + }() + + private lazy var messageAccessoryToolbar: UIToolbar = { + return makeKeyboardToolbar(canGoBackward: true, canGoForward: false) + }() + + private lazy var submissionOverlayView: ProblemReportSubmissionOverlayView = { + let overlay = ProblemReportSubmissionOverlayView() + overlay.translatesAutoresizingMaskIntoConstraints = false + + overlay.editButtonAction = { [weak self] in + self?.hideSubmissionOverlay() + } + + overlay.retryButtonAction = { [weak self] in + self?.sendProblemReport() + } + + return overlay + }() + + // MARK: - View lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .secondaryColor + + navigationItem.title = NSLocalizedString("Report a problem", comment: "Navigation title") + + // Make sure that the user can't easily dismiss the controller on iOS 13 and above + if #available(iOS 13.0, *) { + isModalInPresentation = true + } + + // Set hugging & compression priorities so that description text view wants to grow + emailTextField.setContentHuggingPriority(.defaultHigh, for: .vertical) + emailTextField.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + messageTextView.setContentHuggingPriority(.defaultLow, for: .vertical) + messageTextView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + + textFieldsHolder.addSubview(emailTextField) + textFieldsHolder.addSubview(messagePlaceholder) + textFieldsHolder.addSubview(messageTextView) + + view.addSubview(scrollView) + scrollView.addSubview(containerView) + containerView.addSubview(subheaderLabel) + containerView.addSubview(textFieldsHolder) + containerView.addSubview(buttonsStackView) + + addConstraints() + registerForNotifications() + + loadPersistentViewModel() + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + updateScrollViewContentInsets() + updateMessageTextViewContentInsets() + } + + + // MARK: - Actions + + @objc func focusEmailTextField() { + emailTextField.becomeFirstResponder() + } + + @objc func focusDescriptionTextView() { + messageTextView.becomeFirstResponder() + } + + @objc func dismissKeyboard() { + view.endEditing(false) + } + + @objc func handleSendButtonTap() { + let proceedWithSubmission = { + self.sendProblemReport() + } + + if Self.persistentViewModel.email.isEmpty { + presentEmptyEmailConfirmationAlert { (shouldSend) in + if shouldSend { + proceedWithSubmission() + } + } + } else { + proceedWithSubmission() + } + } + + @objc func handleViewLogsButtonTap() { + let reviewController = ProblemReportReviewViewController(reportString: consolidatedLog.string) + let navigationController = UINavigationController(rootViewController: reviewController) + + present(navigationController, animated: true) + } + + // MARK: - Private + + private func registerForNotifications() { + let notificationCenter = NotificationCenter.default + + notificationCenter.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), + name: UIWindow.keyboardWillChangeFrameNotification, + object: nil) + notificationCenter.addObserver(self, selector: #selector(emailTextFieldDidChange), + name: UITextField.textDidChangeNotification, + object: emailTextField) + notificationCenter.addObserver(self, selector: #selector(messageTextViewDidBeginEditing), + name: UITextView.textDidBeginEditingNotification, + object: messageTextView) + notificationCenter.addObserver(self, selector: #selector(messageTextViewDidEndEditing), + name: UITextView.textDidEndEditingNotification, + object: messageTextView) + notificationCenter.addObserver(self, selector: #selector(messageTextViewDidChange), + name: UITextView.textDidChangeNotification, + object: messageTextView) + } + + private func makeKeyboardToolbar(canGoBackward: Bool, canGoForward: Bool) -> UIToolbar { + var toolbarItems = UIBarButtonItem.makeKeyboardNavigationItems { (prevButton, nextButton) in + prevButton.target = self + prevButton.action = #selector(focusEmailTextField) + prevButton.isEnabled = canGoBackward + + nextButton.target = self + nextButton.action = #selector(focusDescriptionTextView) + nextButton.isEnabled = canGoForward + } + + toolbarItems.append(contentsOf: [ + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), + UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(dismissKeyboard)) + ]) + + let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + toolbar.items = toolbarItems + return toolbar + } + + private func addConstraints() { + self.activeMessageTextViewConstraints = [ + messageTextView.topAnchor.constraint(equalTo: view.topAnchor), + messageTextView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + messageTextView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + messageTextView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ] + + self.inactiveMessageTextViewConstraints = [ + messageTextView.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 12), + messageTextView.leadingAnchor.constraint(equalTo: textFieldsHolder.leadingAnchor), + messageTextView.trailingAnchor.constraint(equalTo: textFieldsHolder.trailingAnchor), + messageTextView.bottomAnchor.constraint(equalTo: textFieldsHolder.bottomAnchor), + ] + + var constraints = [ + subheaderLabel.topAnchor.constraint(equalTo: containerView.layoutMarginsGuide.topAnchor), + subheaderLabel.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), + subheaderLabel.trailingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.trailingAnchor), + + textFieldsHolder.topAnchor.constraint(equalTo: subheaderLabel.bottomAnchor, constant: 24), + textFieldsHolder.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), + textFieldsHolder.trailingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.trailingAnchor), + + buttonsStackView.topAnchor.constraint(equalTo: textFieldsHolder.bottomAnchor, constant: 18), + buttonsStackView.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), + buttonsStackView.trailingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.trailingAnchor), + buttonsStackView.bottomAnchor.constraint(equalTo: containerView.layoutMarginsGuide.bottomAnchor), + + emailTextField.topAnchor.constraint(equalTo: textFieldsHolder.topAnchor), + emailTextField.leadingAnchor.constraint(equalTo: textFieldsHolder.leadingAnchor), + emailTextField.trailingAnchor.constraint(equalTo: textFieldsHolder.trailingAnchor), + + messagePlaceholder.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 12), + messagePlaceholder.leadingAnchor.constraint(equalTo: textFieldsHolder.leadingAnchor), + messagePlaceholder.trailingAnchor.constraint(equalTo: textFieldsHolder.trailingAnchor), + messagePlaceholder.bottomAnchor.constraint(equalTo: textFieldsHolder.bottomAnchor), + messagePlaceholder.heightAnchor.constraint(equalTo: messageTextView.heightAnchor), + + scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor), + scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + scrollView.contentLayoutGuide.topAnchor.constraint(equalTo: containerView.topAnchor), + scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + scrollView.contentLayoutGuide.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + scrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + + scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + scrollView.contentLayoutGuide.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.safeAreaLayoutGuide.heightAnchor), + + messageTextView.heightAnchor.constraint(greaterThanOrEqualToConstant: 150), + ] + + constraints.append(contentsOf: self.inactiveMessageTextViewConstraints) + + NSLayoutConstraint.activate(constraints) + } + + private func setDescriptionFieldExpanded(_ isExpanded: Bool) { + if isExpanded { + // Disable the large title + self.navigationItem.largeTitleDisplayMode = .never + + // Move the text view above scroll view + view.addSubview(self.messageTextView) + + // Re-add old constraints + NSLayoutConstraint.activate(self.inactiveMessageTextViewConstraints) + + // Do a layout pass + view.layoutIfNeeded() + + // Swap constraints + NSLayoutConstraint.deactivate(self.inactiveMessageTextViewConstraints) + NSLayoutConstraint.activate(self.activeMessageTextViewConstraints) + + // Enable content inset adjustment on text view + self.messageTextView.contentInsetAdjustmentBehavior = .always + + // Animate constraints & rounded corners on the text view + animateDescriptionTextView(animations: { + // Turn off rounded corners as the text view fills in the entire view + self.messageTextView.roundCorners = false + + self.view.layoutIfNeeded() + }) { (completed) in + self.isMessageTextViewExpanded = true + + self.updateMessageTextViewContentInsets() + } + + } else { + // Re-enable the large title + self.navigationItem.largeTitleDisplayMode = .automatic + + // Swap constraints + NSLayoutConstraint.deactivate(self.activeMessageTextViewConstraints) + NSLayoutConstraint.activate(self.inactiveMessageTextViewConstraints) + + // Animate constraints & rounded corners on the text view + animateDescriptionTextView(animations: { + // Turn on rounded corners as the text view returns back to where it was + self.messageTextView.roundCorners = true + + self.view.layoutIfNeeded() + }) { (completed) in + // Revert the content adjustment behavior + self.messageTextView.contentInsetAdjustmentBehavior = .never + + // Add the text view inside of the scroll view + self.textFieldsHolder.addSubview(self.messageTextView) + + self.isMessageTextViewExpanded = false + } + } + } + + private func updateScrollViewContentInsets() { + let scrollViewBottomInset = scrollViewBottomContentInsetAccountingForKeyboard + + scrollView.contentInset.bottom = scrollViewBottomInset + scrollView.scrollIndicatorInsets.bottom = scrollViewBottomInset + } + + private func updateMessageTextViewContentInsets() { + // Ignore updating text view insets until it's fully expanded + guard isMessageTextViewExpanded else { return } + + let textViewBottomInset: CGFloat + + if messageTextView.isFirstResponder { + textViewBottomInset = scrollViewBottomContentInsetAccountingForKeyboard + } else { + textViewBottomInset = 0 + } + + messageTextView.contentInset.bottom = textViewBottomInset + messageTextView.scrollIndicatorInsets.bottom = textViewBottomInset + } + + private func animateDescriptionTextView(animations: @escaping () -> Void, completion: @escaping (Bool) -> Void) { + UIView.animate(withDuration: 0.25, animations: animations) { (completed) in + completion(completed) + } + } + + private func presentEmptyEmailConfirmationAlert(completion: @escaping (Bool) -> Void) { + let message = NSLocalizedString("You are about to send the problem report without a way for us to get back to you. If you want an answer to your report you will have to enter an email address.", comment: "") + + let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert) + + let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) { _ in + completion(false) + } + let sendAction = UIAlertAction(title: NSLocalizedString("Send anyway", comment: ""), style: .destructive) { _ in + completion(true) + } + + alertController.addAction(cancelAction) + alertController.addAction(sendAction) + + present(alertController, animated: true) + } + + // MARK: - Private: Problem report submission + + private var showsSubmissionOverlay = false + + private func showSubmissionOverlay() { + guard !showsSubmissionOverlay else { return } + + self.showsSubmissionOverlay = true + + view.addSubview(submissionOverlayView) + + NSLayoutConstraint.activate([ + submissionOverlayView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + submissionOverlayView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + submissionOverlayView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + submissionOverlayView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ]) + + UIView.transition(from: scrollView, to: submissionOverlayView, duration: 0.25, options: [.showHideTransitionViews, .transitionCrossDissolve]) { (success) in + // success + } + } + + private func hideSubmissionOverlay() { + guard showsSubmissionOverlay else { return } + + self.showsSubmissionOverlay = false + + UIView.transition(from: submissionOverlayView, to: scrollView, duration: 0.25, options: [.showHideTransitionViews, .transitionCrossDissolve]) { (success) in + // success + self.submissionOverlayView.removeFromSuperview() + } + } + + // MARK: - Data model + + private struct ViewModel { + let email: String + let message: String + + init() { + email = "" + message = "" + } + + init(email: String, message: String) { + self.email = email.trimmingCharacters(in: .whitespacesAndNewlines) + self.message = message.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var isValid: Bool { + return !message.isEmpty + } + } + + private static var persistentViewModel = ViewModel() + + private func loadPersistentViewModel() { + emailTextField.text = Self.persistentViewModel.email + messageTextView.text = Self.persistentViewModel.message + + validateForm() + } + + private func updatePersistentViewModel() { + Self.persistentViewModel = ViewModel( + email: emailTextField.text ?? "", + message: messageTextView.text + ) + + validateForm() + } + + private func clearPersistentViewModel() { + Self.persistentViewModel = ViewModel() + } + + // MARK: - Form validation + + private func validateForm() { + sendButton.isEnabled = Self.persistentViewModel.isValid + } + + // MARK: - Problem submission progress handling + + private func willSendProblemReport() { + showSubmissionOverlay() + + submissionOverlayView.state = .sending + navigationItem.setHidesBackButton(true, animated: true) + } + + private func didSendProblemReport(viewModel: ViewModel, result: Result<(), RestError>) { + switch result { + case .success: + submissionOverlayView.state = .sent(viewModel.email) + + // Clear persistent view model upon successful submission + clearPersistentViewModel() + + case .failure(let error): + submissionOverlayView.state = .failure(error) + } + + navigationItem.setHidesBackButton(false, animated: true) + } + + // MARK: - Problem report submission helpers + + private func sendProblemReport() { + let viewModel = Self.persistentViewModel + + willSendProblemReport() + sendProblemReportHelper(with: viewModel) { [weak self] (result) in + self?.didSendProblemReport(viewModel: viewModel, result: result) + } + } + + private func sendProblemReportHelper(with viewModel: ViewModel, completion: @escaping (Result<(), RestError>) -> Void) { + let log = consolidatedLog.string + let metadata = consolidatedLog.metadata.reduce(into: [:]) { (output, entry) in + output[entry.key.rawValue] = entry.value + } + + let request = ProblemReportRequest(address: viewModel.email, message: viewModel.message, log: log, metadata: metadata) + let result = mullvadRest.sendProblemReport().dataTask(payload: request) { (result) in + DispatchQueue.main.async { + completion(result) + } + } + + switch result { + case .success(let task): + task.resume() + case .failure(let error): + completion(.failure(error)) + } + } + + // MARK: - Input fields' notifications + + @objc private func messageTextViewDidBeginEditing() { + setDescriptionFieldExpanded(true) + } + + @objc private func messageTextViewDidEndEditing() { + setDescriptionFieldExpanded(false) + } + + @objc private func messageTextViewDidChange() { + updatePersistentViewModel() + } + + @objc private func emailTextFieldDidChange() { + updatePersistentViewModel() + } + + // MARK: - Keyboard notifications + + @objc private func keyboardWillChangeFrame(_ notification: Notification) { + guard let keyboardFrameValue = notification.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? NSValue else { return } + + let screenRect = self.view.convert(self.view.bounds, to: nil) + + keyboardIntersectionRect = screenRect.intersection(keyboardFrameValue.cgRectValue) + + updateScrollViewContentInsets() + updateMessageTextViewContentInsets() + } + + // MARK: - UITextFieldDelegate + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + messageTextView.becomeFirstResponder() + return false + } + + // MARK: - ConditionalNavigation + + func shouldPopNavigationItem(_ navigationItem: UINavigationItem, trigger: NavigationPopTrigger) -> Bool { + switch trigger { + case .interactiveGesture: + // Disable swipe when editing + return !emailTextField.isFirstResponder && !messageTextView.isFirstResponder + + case .backButton: + // Dismiss the keyboard to fix a visual glitch when moving back to the previous controller + view.endEditing(true) + return true + } + } + +} diff --git a/ios/MullvadVPN/SettingsViewController.swift b/ios/MullvadVPN/SettingsViewController.swift index cd78c2b282..4f50245074 100644 --- a/ios/MullvadVPN/SettingsViewController.swift +++ b/ios/MullvadVPN/SettingsViewController.swift @@ -163,6 +163,24 @@ class SettingsViewController: UITableViewController, AccountViewControllerDelega } middleSection.addRows([logStreamerRow]) #endif + + let bottomSection = StaticTableViewSection() + + let problemReportRow = StaticTableViewRow(reuseIdentifier: CellIdentifier.basicCell.rawValue) { (indexPath, cell) in + let cell = cell as! SettingsCell + + cell.titleLabel.text = NSLocalizedString("Report a problem", comment: "") + cell.accessoryType = .disclosureIndicator + } + + problemReportRow.actionBlock = { [weak self] (indexPath) in + let controller = ProblemReportViewController() + + self?.navigationController?.pushViewController(controller, animated: true) + } + + bottomSection.addRows([problemReportRow]) + staticDataSource.addSections([bottomSection]) } } diff --git a/ios/MullvadVPN/StatusImageView.swift b/ios/MullvadVPN/StatusImageView.swift new file mode 100644 index 0000000000..3507a833d3 --- /dev/null +++ b/ios/MullvadVPN/StatusImageView.swift @@ -0,0 +1,50 @@ +// +// StatusImageView.swift +// MullvadVPN +// +// Created by pronebird on 12/02/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class StatusImageView: UIImageView { + enum Style: Int { + case success + case failure + + fileprivate var image: UIImage? { + switch self { + case .success: + return UIImage(named: "IconSuccess") + case .failure: + return UIImage(named: "IconFail") + } + } + } + + var style: Style = .success { + didSet { + self.image = style.image + } + } + + override var intrinsicContentSize: CGSize { + return CGSize(width: 60, height: 60) + } + + override init(frame: CGRect) { + super.init(frame: frame) + self.image = style.image + } + + init(style: Style) { + self.style = style + super.init(image: style.image) + self.image = style.image + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } +} diff --git a/ios/MullvadVPN/UIBarButtonItem+KeyboardNavigation.swift b/ios/MullvadVPN/UIBarButtonItem+KeyboardNavigation.swift new file mode 100644 index 0000000000..85d2d258a6 --- /dev/null +++ b/ios/MullvadVPN/UIBarButtonItem+KeyboardNavigation.swift @@ -0,0 +1,61 @@ +// +// UIBarButtonItem+KeyboardNavigation.swift +// MullvadVPN +// +// Created by pronebird on 24/02/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +extension UIBarButtonItem { + + enum KeyboardNavigationItemType { + case previous, next + + fileprivate var localizedTitle: String { + switch self { + case .previous: + return NSLocalizedString("Previous", comment: "Keyboard navigation toolbar") + case .next: + return NSLocalizedString("Next", comment: "Keyboard navigation toolbar") + } + } + + @available(iOS 13, *) + fileprivate var systemImage: UIImage? { + switch self { + case .previous: + return UIImage(systemName: "chevron.up") + case .next: + return UIImage(systemName: "chevron.down") + } + } + } + + convenience init(keyboardNavigationItemType: KeyboardNavigationItemType, target: Any?, action: Selector?) { + if #available(iOS 13, *) { + self.init(image: keyboardNavigationItemType.systemImage, style: .plain, target: target, action: action) + } else { + self.init(title: keyboardNavigationItemType.localizedTitle, style: .plain, target: target, action: action) + } + accessibilityLabel = keyboardNavigationItemType.localizedTitle + } + + static func makeKeyboardNavigationItems(_ configurationBlock: (_ prevItem: UIBarButtonItem, _ nextItem: UIBarButtonItem) -> Void) -> [UIBarButtonItem] { + let prevButton = UIBarButtonItem(keyboardNavigationItemType: .previous, target: nil, action: nil) + let nextButton = UIBarButtonItem(keyboardNavigationItemType: .next, target: nil, action: nil) + + configurationBlock(prevButton, nextButton) + + if #available(iOS 13, *) { + let spacer = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil) + spacer.width = 8 + + return [prevButton, spacer, nextButton] + } else { + return [prevButton, nextButton] + } + } + +} |
