diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 61a08e84e5b6..fb569033da88 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -180,6 +180,13 @@ 588527B4276B4F2F00BAA373 /* SetAccountOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588527B3276B4F2F00BAA373 /* SetAccountOperation.swift */; }; 5888AD83227B11080051EB06 /* SelectLocationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD82227B11080051EB06 /* SelectLocationCell.swift */; }; 5888AD87227B17950051EB06 /* SelectLocationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */; }; + 588D7EE22AF4C8CE005DF40A /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EE12AF4C8CE005DF40A /* MarkdownParser.swift */; }; + 588D7EE52AF4CA5A005DF40A /* MarkdownNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EE42AF4CA5A005DF40A /* MarkdownNode.swift */; }; + 588D7EE72AF4CC64005DF40A /* MarkdownLinkNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EE62AF4CC64005DF40A /* MarkdownLinkNode.swift */; }; + 588D7EE92AF4CCB5005DF40A /* MarkdownBoldNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EE82AF4CCB5005DF40A /* MarkdownBoldNode.swift */; }; + 588D7EEB2AF4CCC3005DF40A /* MarkdownTextNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EEA2AF4CCC3005DF40A /* MarkdownTextNode.swift */; }; + 588D7EED2AF4CFF0005DF40A /* PeekableIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EEC2AF4CFF0005DF40A /* PeekableIterator.swift */; }; + 588D7EEF2AF4D1E1005DF40A /* MarkdownNodeType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EEE2AF4D1E1005DF40A /* MarkdownNodeType.swift */; }; 588E4EAE28FEEDD8008046E3 /* MullvadREST.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06799ABC28F98E1D00ACD94E /* MullvadREST.framework */; }; 58906DE02445C7A5002F0673 /* NEProviderStopReason+Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58906DDF2445C7A5002F0673 /* NEProviderStopReason+Debug.swift */; }; 58907D9524D17B4E00CFC3F5 /* DisconnectSplitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58907D9424D17B4E00CFC3F5 /* DisconnectSplitButton.swift */; }; @@ -241,6 +248,17 @@ 58B2FDEF2AA720C4003EB5C6 /* ApplicationTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C76A072A33850E00100D75 /* ApplicationTarget.swift */; }; 58B43C1925F77DB60002C8C3 /* TunnelControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B43C1825F77DB60002C8C3 /* TunnelControlView.swift */; }; 58B465702A98C53300467203 /* RequestExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B4656F2A98C53300467203 /* RequestExecutorTests.swift */; }; + 58B6966D2AF4F621006A6B71 /* IteratorProtocol+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B6966C2AF4F621006A6B71 /* IteratorProtocol+String.swift */; }; + 58B6966F2AF4FA0C006A6B71 /* MarkdownParagraphNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B6966E2AF4FA0C006A6B71 /* MarkdownParagraphNode.swift */; }; + 58B696712AF4FA82006A6B71 /* MarkdownDocumentNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B696702AF4FA82006A6B71 /* MarkdownDocumentNode.swift */; }; + 58B696732AF502BF006A6B71 /* AttributedMarkdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B696722AF502BF006A6B71 /* AttributedMarkdown.swift */; }; + 58B696752AF502E6006A6B71 /* MarkdownBoldNode+AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B696742AF502E6006A6B71 /* MarkdownBoldNode+AttributedString.swift */; }; + 58B696772AF5030F006A6B71 /* MarkdownTextNode+AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B696762AF5030F006A6B71 /* MarkdownTextNode+AttributedString.swift */; }; + 58B696792AF50332006A6B71 /* MarkdownLinkNode+AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B696782AF50332006A6B71 /* MarkdownLinkNode+AttributedString.swift */; }; + 58B6967B2AF50366006A6B71 /* MarkdownParagraphNode+AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B6967A2AF50366006A6B71 /* MarkdownParagraphNode+AttributedString.swift */; }; + 58B6967D2AF50378006A6B71 /* MarkdownDocumentNode+AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B6967C2AF50378006A6B71 /* MarkdownDocumentNode+AttributedString.swift */; }; + 58B6967F2AF50681006A6B71 /* String+UnicodeLineSeparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B6967E2AF50681006A6B71 /* String+UnicodeLineSeparator.swift */; }; + 58B696852AFA4C3F006A6B71 /* MarkdownParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B696842AFA4C3F006A6B71 /* MarkdownParserTests.swift */; }; 58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B93A1226C3F13600A55733 /* TunnelState.swift */; }; 58B993B12608A34500BA7811 /* LoginContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B993B02608A34500BA7811 /* LoginContentView.swift */; }; 58B9EB152489139B00095626 /* RESTError+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B9EB142489139B00095626 /* RESTError+Display.swift */; }; @@ -371,6 +389,25 @@ 58EE2E3B272FF814003BFF93 /* SettingsDataSourceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EE2E39272FF814003BFF93 /* SettingsDataSourceDelegate.swift */; }; 58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF580A25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift */; }; 58EF581125D69DB400AEBA94 /* StatusImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF581025D69DB400AEBA94 /* StatusImageView.swift */; }; + 58EFC7502AFA6F2200E9F4CB /* MarkdownDocumentNode+AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B6967C2AF50378006A6B71 /* MarkdownDocumentNode+AttributedString.swift */; }; + 58EFC7512AFA6F2200E9F4CB /* AttributedMarkdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B696722AF502BF006A6B71 /* AttributedMarkdown.swift */; }; + 58EFC7522AFA6F2200E9F4CB /* NSAttributedString+Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */; }; + 58EFC7532AFA6F2200E9F4CB /* MarkdownLinkNode+AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B696782AF50332006A6B71 /* MarkdownLinkNode+AttributedString.swift */; }; + 58EFC7542AFA6F2200E9F4CB /* MarkdownParagraphNode+AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B6967A2AF50366006A6B71 /* MarkdownParagraphNode+AttributedString.swift */; }; + 58EFC7552AFA6F2200E9F4CB /* String+UnicodeLineSeparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B6967E2AF50681006A6B71 /* String+UnicodeLineSeparator.swift */; }; + 58EFC7562AFA6F2200E9F4CB /* MarkdownBoldNode+AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B696742AF502E6006A6B71 /* MarkdownBoldNode+AttributedString.swift */; }; + 58EFC7572AFA6F2200E9F4CB /* MarkdownTextNode+AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B696762AF5030F006A6B71 /* MarkdownTextNode+AttributedString.swift */; }; + 58EFC7582AFA6F3800E9F4CB /* IteratorProtocol+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B6966C2AF4F621006A6B71 /* IteratorProtocol+String.swift */; }; + 58EFC7592AFA6F3800E9F4CB /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EE12AF4C8CE005DF40A /* MarkdownParser.swift */; }; + 58EFC75A2AFA6F3800E9F4CB /* MarkdownNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EE42AF4CA5A005DF40A /* MarkdownNode.swift */; }; + 58EFC75B2AFA6F3800E9F4CB /* MarkdownNodeType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EEE2AF4D1E1005DF40A /* MarkdownNodeType.swift */; }; + 58EFC75C2AFA6F3800E9F4CB /* MarkdownStylingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE982A4E0FE900DBFEDB /* MarkdownStylingOptions.swift */; }; + 58EFC75D2AFA6F3800E9F4CB /* PeekableIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EEC2AF4CFF0005DF40A /* PeekableIterator.swift */; }; + 58EFC75E2AFA6F3B00E9F4CB /* MarkdownBoldNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EE82AF4CCB5005DF40A /* MarkdownBoldNode.swift */; }; + 58EFC75F2AFA6F3B00E9F4CB /* MarkdownParagraphNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B6966E2AF4FA0C006A6B71 /* MarkdownParagraphNode.swift */; }; + 58EFC7602AFA6F3B00E9F4CB /* MarkdownTextNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EEA2AF4CCC3005DF40A /* MarkdownTextNode.swift */; }; + 58EFC7612AFA6F3B00E9F4CB /* MarkdownLinkNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EE62AF4CC64005DF40A /* MarkdownLinkNode.swift */; }; + 58EFC7622AFA6F3B00E9F4CB /* MarkdownDocumentNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B696702AF4FA82006A6B71 /* MarkdownDocumentNode.swift */; }; 58F0974E2A20C31100DA2DAD /* WireGuardKitTypes in Frameworks */ = {isa = PBXBuildFile; productRef = 58F0974D2A20C31100DA2DAD /* WireGuardKitTypes */; }; 58F0974F2A20C31100DA2DAD /* WireGuardKitTypes in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 58F0974D2A20C31100DA2DAD /* WireGuardKitTypes */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 58F097512A20C35000DA2DAD /* WireGuardKitTypes in Frameworks */ = {isa = PBXBuildFile; productRef = 58F097502A20C35000DA2DAD /* WireGuardKitTypes */; }; @@ -521,7 +558,6 @@ A9A5F9E52ACB05160083449F /* CustomDateComponentsFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */; }; A9A5F9E62ACB05160083449F /* DeviceDataThrottling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58138E60294871C600684F0C /* DeviceDataThrottling.swift */; }; A9A5F9E72ACB05160083449F /* FirstTimeLaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */; }; - A9A5F9E82ACB05160083449F /* MarkdownStylingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE982A4E0FE900DBFEDB /* MarkdownStylingOptions.swift */; }; A9A5F9E92ACB05160083449F /* ObserverList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CC40EE24A601900019D96E /* ObserverList.swift */; }; A9A5F9EA2ACB05160083449F /* Bundle+ProductVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */; }; A9A5F9EB2ACB05160083449F /* CharacterSet+IPAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */; }; @@ -605,7 +641,6 @@ A9A5FA392ACB05910083449F /* UIColor+Palette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA0152242560B004F3011 /* UIColor+Palette.swift */; }; A9A5FA3A2ACB05910083449F /* UIEdgeInsets+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E034632ABB302000E59A5A /* UIEdgeInsets+Extensions.swift */; }; A9A5FA3B2ACB05910083449F /* UIMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CA70E25F8C44600B47C62 /* UIMetrics.swift */; }; - A9A5FA3C2ACB05B20083449F /* NSAttributedString+Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */; }; A9A5FA3D2ACB05D90083449F /* DeviceCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FE65922AB1CDE000E53CB5 /* DeviceCheck.swift */; }; A9A5FA3E2ACB05D90083449F /* DeviceCheckOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FDF2D82A0BA11900C2B061 /* DeviceCheckOperation.swift */; }; A9A5FA3F2ACB05D90083449F /* DeviceCheckRemoteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58915D672A25FA080066445B /* DeviceCheckRemoteService.swift */; }; @@ -1344,6 +1379,13 @@ 588527B3276B4F2F00BAA373 /* SetAccountOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetAccountOperation.swift; sourceTree = ""; }; 5888AD82227B11080051EB06 /* SelectLocationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationCell.swift; sourceTree = ""; }; 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationViewController.swift; sourceTree = ""; }; + 588D7EE12AF4C8CE005DF40A /* MarkdownParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownParser.swift; sourceTree = ""; }; + 588D7EE42AF4CA5A005DF40A /* MarkdownNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownNode.swift; sourceTree = ""; }; + 588D7EE62AF4CC64005DF40A /* MarkdownLinkNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownLinkNode.swift; sourceTree = ""; }; + 588D7EE82AF4CCB5005DF40A /* MarkdownBoldNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownBoldNode.swift; sourceTree = ""; }; + 588D7EEA2AF4CCC3005DF40A /* MarkdownTextNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTextNode.swift; sourceTree = ""; }; + 588D7EEC2AF4CFF0005DF40A /* PeekableIterator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeekableIterator.swift; sourceTree = ""; }; + 588D7EEE2AF4D1E1005DF40A /* MarkdownNodeType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownNodeType.swift; sourceTree = ""; }; 58900D0228BBDCC70094E4F0 /* FixedWidthInteger+Arithmetics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FixedWidthInteger+Arithmetics.swift"; sourceTree = ""; }; 58906DDF2445C7A5002F0673 /* NEProviderStopReason+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NEProviderStopReason+Debug.swift"; sourceTree = ""; }; 58907D9424D17B4E00CFC3F5 /* DisconnectSplitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisconnectSplitButton.swift; sourceTree = ""; }; @@ -1399,6 +1441,18 @@ 58B2FDD52AA71D2A003EB5C6 /* MullvadSettings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MullvadSettings.h; sourceTree = ""; }; 58B43C1825F77DB60002C8C3 /* TunnelControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelControlView.swift; sourceTree = ""; }; 58B4656F2A98C53300467203 /* RequestExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestExecutorTests.swift; sourceTree = ""; }; + 58B6966C2AF4F621006A6B71 /* IteratorProtocol+String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IteratorProtocol+String.swift"; sourceTree = ""; }; + 58B6966E2AF4FA0C006A6B71 /* MarkdownParagraphNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownParagraphNode.swift; sourceTree = ""; }; + 58B696702AF4FA82006A6B71 /* MarkdownDocumentNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownDocumentNode.swift; sourceTree = ""; }; + 58B696722AF502BF006A6B71 /* AttributedMarkdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedMarkdown.swift; sourceTree = ""; }; + 58B696742AF502E6006A6B71 /* MarkdownBoldNode+AttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MarkdownBoldNode+AttributedString.swift"; sourceTree = ""; }; + 58B696762AF5030F006A6B71 /* MarkdownTextNode+AttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MarkdownTextNode+AttributedString.swift"; sourceTree = ""; }; + 58B696782AF50332006A6B71 /* MarkdownLinkNode+AttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MarkdownLinkNode+AttributedString.swift"; sourceTree = ""; }; + 58B6967A2AF50366006A6B71 /* MarkdownParagraphNode+AttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MarkdownParagraphNode+AttributedString.swift"; sourceTree = ""; }; + 58B6967C2AF50378006A6B71 /* MarkdownDocumentNode+AttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MarkdownDocumentNode+AttributedString.swift"; sourceTree = ""; }; + 58B6967E2AF50681006A6B71 /* String+UnicodeLineSeparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+UnicodeLineSeparator.swift"; sourceTree = ""; }; + 58B696802AF51141006A6B71 /* HyperLinkLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HyperLinkLabel.swift; sourceTree = ""; }; + 58B696842AFA4C3F006A6B71 /* MarkdownParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownParserTests.swift; sourceTree = ""; }; 58B93A1226C3F13600A55733 /* TunnelState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelState.swift; sourceTree = ""; }; 58B993B02608A34500BA7811 /* LoginContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginContentView.swift; sourceTree = ""; }; 58B9EB122488ED2100095626 /* AlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPresenter.swift; sourceTree = ""; }; @@ -2201,7 +2255,6 @@ 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */, 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */, 5811DE4F239014550011EB53 /* NEVPNStatus+Debug.swift */, - 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */, 587D9675288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift */, 5871FB9F254C26BF0051A0A4 /* NSRegularExpression+IPAddress.swift */, 06FAE67828F83CA50033DD93 /* RESTCreateApplePaymentResponse+Localization.swift */, @@ -2263,8 +2316,8 @@ 58138E60294871C600684F0C /* DeviceDataThrottling.swift */, 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */, 582AE30F2440A6CA00E6733A /* InputTextFormatter.swift */, - 7AF9BE982A4E0FE900DBFEDB /* MarkdownStylingOptions.swift */, 58CC40EE24A601900019D96E /* ObserverList.swift */, + 588D7EE32AF4CA3D005DF40A /* Markdown */, ); path = Classes; sourceTree = ""; @@ -2415,6 +2468,21 @@ path = WireGuardAdapter; sourceTree = ""; }; + 588D7EE32AF4CA3D005DF40A /* Markdown */ = { + isa = PBXGroup; + children = ( + 58B696832AF538DA006A6B71 /* AttributedStringSupport */, + 58B6966C2AF4F621006A6B71 /* IteratorProtocol+String.swift */, + 588D7EE42AF4CA5A005DF40A /* MarkdownNode.swift */, + 588D7EEE2AF4D1E1005DF40A /* MarkdownNodeType.swift */, + 588D7EE12AF4C8CE005DF40A /* MarkdownParser.swift */, + 7AF9BE982A4E0FE900DBFEDB /* MarkdownStylingOptions.swift */, + 58B696822AF538C9006A6B71 /* Nodes */, + 588D7EEC2AF4CFF0005DF40A /* PeekableIterator.swift */, + ); + path = Markdown; + sourceTree = ""; + }; 58915D662A25F9F20066445B /* DeviceCheck */ = { isa = PBXGroup; children = ( @@ -2497,6 +2565,7 @@ A9E0317B2ACBFC7E0095D843 /* TunnelStore+Stubs.swift */, A9E031792ACB0AE70095D843 /* UIApplication+Stubs.swift */, 58165EBD2A262CBB00688EAD /* WgKeyRotationTests.swift */, + 58B696842AFA4C3F006A6B71 /* MarkdownParserTests.swift */, ); path = MullvadVPNTests; sourceTree = ""; @@ -2553,6 +2622,33 @@ path = MullvadSettings; sourceTree = ""; }; + 58B696822AF538C9006A6B71 /* Nodes */ = { + isa = PBXGroup; + children = ( + 588D7EEA2AF4CCC3005DF40A /* MarkdownTextNode.swift */, + 588D7EE82AF4CCB5005DF40A /* MarkdownBoldNode.swift */, + 588D7EE62AF4CC64005DF40A /* MarkdownLinkNode.swift */, + 58B6966E2AF4FA0C006A6B71 /* MarkdownParagraphNode.swift */, + 58B696702AF4FA82006A6B71 /* MarkdownDocumentNode.swift */, + ); + path = Nodes; + sourceTree = ""; + }; + 58B696832AF538DA006A6B71 /* AttributedStringSupport */ = { + isa = PBXGroup; + children = ( + 58B696722AF502BF006A6B71 /* AttributedMarkdown.swift */, + 58B696742AF502E6006A6B71 /* MarkdownBoldNode+AttributedString.swift */, + 58B6967C2AF50378006A6B71 /* MarkdownDocumentNode+AttributedString.swift */, + 58B696782AF50332006A6B71 /* MarkdownLinkNode+AttributedString.swift */, + 58B6967A2AF50366006A6B71 /* MarkdownParagraphNode+AttributedString.swift */, + 58B696762AF5030F006A6B71 /* MarkdownTextNode+AttributedString.swift */, + 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */, + 58B6967E2AF50681006A6B71 /* String+UnicodeLineSeparator.swift */, + ); + path = AttributedStringSupport; + sourceTree = ""; + }; 58BDEB9E2A98F6B400F578F2 /* Mocks */ = { isa = PBXGroup; children = ( @@ -4062,13 +4158,15 @@ files = ( A9A5FA432ACB05F20083449F /* UIColor+Helpers.swift in Sources */, A9A5FA3D2ACB05D90083449F /* DeviceCheck.swift in Sources */, + 58EFC7572AFA6F2200E9F4CB /* MarkdownTextNode+AttributedString.swift in Sources */, A900E9B82ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift in Sources */, A9A5FA3E2ACB05D90083449F /* DeviceCheckOperation.swift in Sources */, + 58EFC7502AFA6F2200E9F4CB /* MarkdownDocumentNode+AttributedString.swift in Sources */, + 58EFC7602AFA6F3B00E9F4CB /* MarkdownTextNode.swift in Sources */, A9A5FA3F2ACB05D90083449F /* DeviceCheckRemoteService.swift in Sources */, A9A5FA402ACB05D90083449F /* DeviceCheckRemoteServiceProtocol.swift in Sources */, A9A5FA412ACB05D90083449F /* DeviceStateAccessor.swift in Sources */, A9A5FA422ACB05D90083449F /* DeviceStateAccessorProtocol.swift in Sources */, - A9A5FA3C2ACB05B20083449F /* NSAttributedString+Markdown.swift in Sources */, A9A5FA392ACB05910083449F /* UIColor+Palette.swift in Sources */, A9A5FA3A2ACB05910083449F /* UIEdgeInsets+Extensions.swift in Sources */, A9C342C52ACC42130045F00E /* ServerRelaysResponse+Stubs.swift in Sources */, @@ -4078,16 +4176,19 @@ A9A5FA372ACB052D0083449F /* ApplicationTarget.swift in Sources */, A9A5F9E12ACB05160083449F /* AddressCacheTracker.swift in Sources */, A900E9BC2ACC609200C95F67 /* DevicesProxy+Stubs.swift in Sources */, + 58EFC7522AFA6F2200E9F4CB /* NSAttributedString+Markdown.swift in Sources */, A9A5F9E22ACB05160083449F /* BackgroundTask.swift in Sources */, A9A5F9E32ACB05160083449F /* AccountDataThrottling.swift in Sources */, A9A5F9E42ACB05160083449F /* AppPreferences.swift in Sources */, A9A5F9E52ACB05160083449F /* CustomDateComponentsFormatting.swift in Sources */, A9A5F9E62ACB05160083449F /* DeviceDataThrottling.swift in Sources */, A9A5F9E72ACB05160083449F /* FirstTimeLaunch.swift in Sources */, - A9A5F9E82ACB05160083449F /* MarkdownStylingOptions.swift in Sources */, + 58EFC7532AFA6F2200E9F4CB /* MarkdownLinkNode+AttributedString.swift in Sources */, A9A5F9E92ACB05160083449F /* ObserverList.swift in Sources */, A9B6AC1B2ADEA3AD00F7802A /* MemoryCache.swift in Sources */, A9A5F9EA2ACB05160083449F /* Bundle+ProductVersion.swift in Sources */, + 58EFC75C2AFA6F3800E9F4CB /* MarkdownStylingOptions.swift in Sources */, + 58EFC7562AFA6F2200E9F4CB /* MarkdownBoldNode+AttributedString.swift in Sources */, A9A5F9EB2ACB05160083449F /* CharacterSet+IPAddress.swift in Sources */, A9A5F9EC2ACB05160083449F /* CodingErrors+CustomErrorDescription.swift in Sources */, A9A5F9ED2ACB05160083449F /* NSRegularExpression+IPAddress.swift in Sources */, @@ -4097,6 +4198,7 @@ A9A5F9F12ACB05160083449F /* String+Split.swift in Sources */, A9A5F9F22ACB05160083449F /* NotificationConfiguration.swift in Sources */, A9A5F9F32ACB05160083449F /* AccountExpirySystemNotificationProvider.swift in Sources */, + 58EFC7512AFA6F2200E9F4CB /* AttributedMarkdown.swift in Sources */, A9A5F9F42ACB05160083449F /* AccountExpiryInAppNotificationProvider.swift in Sources */, A9A5F9F52ACB05160083449F /* RegisteredDeviceInAppNotificationProvider.swift in Sources */, A9A5F9F62ACB05160083449F /* TunnelStatusNotificationProvider.swift in Sources */, @@ -4108,8 +4210,12 @@ A9A5F9FC2ACB05160083449F /* SystemNotificationProvider.swift in Sources */, A9A5F9FD2ACB05160083449F /* NotificationResponse.swift in Sources */, A9A5F9FE2ACB05160083449F /* NotificationManager.swift in Sources */, + 58EFC75E2AFA6F3B00E9F4CB /* MarkdownBoldNode.swift in Sources */, + 58EFC7542AFA6F2200E9F4CB /* MarkdownParagraphNode+AttributedString.swift in Sources */, A9A5F9FF2ACB05160083449F /* NotificationManagerDelegate.swift in Sources */, A900E9BE2ACC654100C95F67 /* APIProxy+Stubs.swift in Sources */, + 58EFC7612AFA6F3B00E9F4CB /* MarkdownLinkNode.swift in Sources */, + 58EFC7622AFA6F3B00E9F4CB /* MarkdownDocumentNode.swift in Sources */, A900E9BA2ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift in Sources */, A9A5FA002ACB05160083449F /* ProductsRequestOperation.swift in Sources */, A9A5FA012ACB05160083449F /* RelayCacheTrackerObserver.swift in Sources */, @@ -4118,9 +4224,12 @@ A9A5FA042ACB05160083449F /* SimulatorTunnelProvider.swift in Sources */, A9A5FA052ACB05160083449F /* SimulatorTunnelProviderHost.swift in Sources */, A900E9C02ACC661900C95F67 /* AccessTokenManager+Stubs.swift in Sources */, + 58EFC7582AFA6F3800E9F4CB /* IteratorProtocol+String.swift in Sources */, A9E0317A2ACB0AE70095D843 /* UIApplication+Stubs.swift in Sources */, A9A5FA062ACB05160083449F /* SimulatorTunnelProviderManager.swift in Sources */, + 58EFC7552AFA6F2200E9F4CB /* String+UnicodeLineSeparator.swift in Sources */, A9A5FA072ACB05160083449F /* SimulatorVPNConnection.swift in Sources */, + 58EFC75B2AFA6F3800E9F4CB /* MarkdownNodeType.swift in Sources */, A9A5FA082ACB05160083449F /* StorePaymentBlockObserver.swift in Sources */, A9E0317C2ACBFC7E0095D843 /* TunnelStore+Stubs.swift in Sources */, A9A5FA092ACB05160083449F /* SendStoreReceiptOperation.swift in Sources */, @@ -4133,12 +4242,16 @@ A9A5FA102ACB05160083449F /* PacketTunnelTransport.swift in Sources */, A9A5FA112ACB05160083449F /* TransportMonitor.swift in Sources */, A9A5FA122ACB05160083449F /* DeleteAccountOperation.swift in Sources */, + 58B696852AFA4C3F006A6B71 /* MarkdownParserTests.swift in Sources */, A9B6AC1A2ADE8FBB00F7802A /* InMemorySettingsStore.swift in Sources */, + 58EFC75F2AFA6F3B00E9F4CB /* MarkdownParagraphNode.swift in Sources */, A9A5FA132ACB05160083449F /* LoadTunnelConfigurationOperation.swift in Sources */, A9A5FA142ACB05160083449F /* MapConnectionStatusOperation.swift in Sources */, A9A5FA152ACB05160083449F /* RedeemVoucherOperation.swift in Sources */, + 58EFC7592AFA6F3800E9F4CB /* MarkdownParser.swift in Sources */, A9A5FA162ACB05160083449F /* RotateKeyOperation.swift in Sources */, A9A5FA172ACB05160083449F /* SendTunnelProviderMessageOperation.swift in Sources */, + 58EFC75D2AFA6F3800E9F4CB /* PeekableIterator.swift in Sources */, A9A5FA182ACB05160083449F /* SetAccountOperation.swift in Sources */, A9A5FA192ACB05160083449F /* StartTunnelOperation.swift in Sources */, A9A5FA1A2ACB05160083449F /* StopTunnelOperation.swift in Sources */, @@ -4154,6 +4267,7 @@ A9E0317F2ACC331C0095D843 /* TunnelStatusBlockObserver.swift in Sources */, A9A5FA232ACB05160083449F /* TunnelState.swift in Sources */, A9A5FA242ACB05160083449F /* TunnelStore.swift in Sources */, + 58EFC75A2AFA6F3800E9F4CB /* MarkdownNode.swift in Sources */, A9A5FA252ACB05160083449F /* UpdateAccountDataOperation.swift in Sources */, A9A5FA262ACB05160083449F /* UpdateDeviceDataOperation.swift in Sources */, A9A5FA272ACB05160083449F /* VPNConnectionProtocol.swift in Sources */, @@ -4314,6 +4428,7 @@ 58C3F4F92964B08300D72515 /* MapViewController.swift in Sources */, 584D26C6270C8741004EA533 /* SettingsDNSTextCell.swift in Sources */, 58F2E148276A307400A79513 /* MapConnectionStatusOperation.swift in Sources */, + 588D7EEF2AF4D1E1005DF40A /* MarkdownNodeType.swift in Sources */, 58BA693123EADA6A009DC256 /* SimulatorTunnelProvider.swift in Sources */, 7A9CCCB32A96302800DD6A34 /* WelcomeCoordinator.swift in Sources */, 587B753B2666467500DEF7E9 /* NotificationBannerView.swift in Sources */, @@ -4322,12 +4437,14 @@ 7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */, 58B26E2A2943545A00D5980C /* NotificationManagerDelegate.swift in Sources */, 58A1AA8C23F5584C009F7EA6 /* ConnectionPanelView.swift in Sources */, + 58B6967B2AF50366006A6B71 /* MarkdownParagraphNode+AttributedString.swift in Sources */, 5878A27B2909649A0096FC88 /* CustomOverlayRenderer.swift in Sources */, 588527B2276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift in Sources */, 7AF9BE992A4E0FE900DBFEDB /* MarkdownStylingOptions.swift in Sources */, 5867770E29096984006F721F /* OutOfTimeInteractor.swift in Sources */, F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */, 58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */, + 58EFC7752AFB4CEF00E9F4CB /* AboutViewController.swift in Sources */, 5878A27129091CF20096FC88 /* AccountInteractor.swift in Sources */, 7AF9BE882A30C62100DBFEDB /* SelectableSettingsCell.swift in Sources */, 58CCA010224249A1004F3011 /* TunnelViewController.swift in Sources */, @@ -4338,6 +4455,8 @@ 58BFA5C622A7C97F00A6173D /* RelayCacheTracker.swift in Sources */, E158B360285381C60002F069 /* String+AccountFormatting.swift in Sources */, 7AC8A3AE2ABC6FBB00DC4939 /* SettingsHeaderView.swift in Sources */, + 588D7EDC2AF3A55E005DF40A /* APIAccessListInteractorProtocol.swift in Sources */, + 588D7ED62AF3903F005DF40A /* APIAccessListViewController.swift in Sources */, 582BB1B1229569620055B6EF /* UINavigationBar+Appearance.swift in Sources */, 7A9FA1442A2E3FE5000B728D /* CheckableSettingsCell.swift in Sources */, 58ACF6492655365700ACE4B7 /* PreferencesViewController.swift in Sources */, @@ -4347,6 +4466,7 @@ 7A2960FD2A964BB700389B82 /* AlertPresentation.swift in Sources */, 0697D6E728F01513007A9E99 /* TransportMonitor.swift in Sources */, 58968FAE28743E2000B799DC /* TunnelInteractor.swift in Sources */, + 588D7EEB2AF4CCC3005DF40A /* MarkdownTextNode.swift in Sources */, 7A1A26472A29CF0800B978AA /* RelayFilterDataSource.swift in Sources */, 5864AF0929C78850005B0CD9 /* PreferencesCellFactory.swift in Sources */, 587B7536266528A200DEF7E9 /* NotificationManager.swift in Sources */, @@ -4354,6 +4474,7 @@ 7A9CCCB92A96302800DD6A34 /* SelectLocationCoordinator.swift in Sources */, 58FB865A26EA214400F188BC /* RelayCacheTrackerObserver.swift in Sources */, 58ACF64D26567A5000ACE4B7 /* CustomSwitch.swift in Sources */, + 58B696752AF502E6006A6B71 /* MarkdownBoldNode+AttributedString.swift in Sources */, F0DA874B2A9CBACB006044F1 /* AccountNumberRow.swift in Sources */, 58F2E14C276A61C000A79513 /* RotateKeyOperation.swift in Sources */, 5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */, @@ -4361,15 +4482,20 @@ 7A9CCCC12A96302800DD6A34 /* AccountCoordinator.swift in Sources */, 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */, 5846227326E22A160035F7C2 /* StorePaymentObserver.swift in Sources */, + 58B696772AF5030F006A6B71 /* MarkdownTextNode+AttributedString.swift in Sources */, F0E3618B2A4ADD2F00AEEF2B /* WelcomeContentView.swift in Sources */, 58F2E146276A2C9900A79513 /* StopTunnelOperation.swift in Sources */, + 58B696712AF4FA82006A6B71 /* MarkdownDocumentNode.swift in Sources */, E1187ABC289BBB850024E748 /* OutOfTimeViewController.swift in Sources */, + 58B6966D2AF4F621006A6B71 /* IteratorProtocol+String.swift in Sources */, 7A9CCCBD2A96302800DD6A34 /* LoginCoordinator.swift in Sources */, 58293FB125124117005D0BB5 /* CustomTextField.swift in Sources */, F09A29822A9F8AD200EA3B6F /* RedeemVoucherInteractor.swift in Sources */, 58138E61294871C600684F0C /* DeviceDataThrottling.swift in Sources */, 5878A279290954790096FC88 /* TunnelViewControllerInteractor.swift in Sources */, + 588D7EE92AF4CCB5005DF40A /* MarkdownBoldNode.swift in Sources */, 7A818F1F29F0305800C7F0F4 /* RootConfiguration.swift in Sources */, + 58B696792AF50332006A6B71 /* MarkdownLinkNode+AttributedString.swift in Sources */, 7A9CCCBF2A96302800DD6A34 /* SettingsCoordinator.swift in Sources */, 58F70FE52AEA707800E6890E /* StoreTransactionLog.swift in Sources */, 582AE3102440A6CA00E6733A /* InputTextFormatter.swift in Sources */, @@ -4386,6 +4512,7 @@ 586A950E290125F3007BAF2B /* ProductsRequestOperation.swift in Sources */, 7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */, 58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */, + 588D7EED2AF4CFF0005DF40A /* PeekableIterator.swift in Sources */, 5864859929A0D028006C5743 /* FormsheetPresentationController.swift in Sources */, 7A9CCCB52A96302800DD6A34 /* AddCreditSucceededCoordinator.swift in Sources */, 7A0C0F632A979C4A0058EFCE /* Coordinator+Router.swift in Sources */, @@ -4394,6 +4521,7 @@ 587CBFE322807F530028DED3 /* UIColor+Helpers.swift in Sources */, 7A9CCCBE2A96302800DD6A34 /* AccountDeletionCoordinator.swift in Sources */, 588527B4276B4F2F00BAA373 /* SetAccountOperation.swift in Sources */, + 58B6967D2AF50378006A6B71 /* MarkdownDocumentNode+AttributedString.swift in Sources */, F0DA87472A9CB9A2006044F1 /* AccountExpiryRow.swift in Sources */, 585CA70F25F8C44600B47C62 /* UIMetrics.swift in Sources */, E1187ABD289BBB850024E748 /* OutOfTimeContentView.swift in Sources */, @@ -4426,8 +4554,11 @@ 5864AF0829C78849005B0CD9 /* CellFactoryProtocol.swift in Sources */, F0C6FA812A66E23300F521F0 /* DeleteAccountOperation.swift in Sources */, F07CFF2029F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift in Sources */, + 588D7EE52AF4CA5A005DF40A /* MarkdownNode.swift in Sources */, + 588D7EDA2AF3A547005DF40A /* APIAccessListItemProtocol.swift in Sources */, 587A01FC23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift in Sources */, 5819C2172729595500D6EC38 /* SettingsAddDNSEntryCell.swift in Sources */, + 58B696732AF502BF006A6B71 /* AttributedMarkdown.swift in Sources */, 7A1A26452A29CEF700B978AA /* RelayFilterViewController.swift in Sources */, 5862805422428EF100F5A6E1 /* TranslucentButtonBlurView.swift in Sources */, 587EB66A270EFACB00123C75 /* CharacterSet+IPAddress.swift in Sources */, @@ -4448,10 +4579,16 @@ 583FE01029C0F532006E85F9 /* CustomSplitViewController.swift in Sources */, 58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */, 5892A45E265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift in Sources */, + 58B6967F2AF50681006A6B71 /* String+UnicodeLineSeparator.swift in Sources */, 580909D32876D09A0078138D /* RevokedDeviceViewController.swift in Sources */, 5835B7CC233B76CB0096D79F /* TunnelManager.swift in Sources */, + 588D7EDE2AF3A585005DF40A /* APIAccessListItem.swift in Sources */, + 588D7EE02AF3A595005DF40A /* APIAccessListInteractor.swift in Sources */, 58607A4D2947287800BC467D /* AccountExpiryInAppNotificationProvider.swift in Sources */, 58C8191829FAA2C400DEB1B4 /* NotificationConfiguration.swift in Sources */, + 58EFC76A2AFAC3B800E9F4CB /* APIAccessHeaderView.swift in Sources */, + 58EFC7732AFB471500E9F4CB /* APIAccessAddViewController.swift in Sources */, + 58B696812AF51141006A6B71 /* HyperLinkLabel.swift in Sources */, 58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */, 58B26E262943522400D5980C /* NotificationProvider.swift in Sources */, 58CE5E64224146200008646E /* AppDelegate.swift in Sources */, @@ -4469,8 +4606,11 @@ 58677710290975E9006F721F /* SettingsInteractorFactory.swift in Sources */, 7A9CCCC02A96302800DD6A34 /* ProfileVoucherCoordinator.swift in Sources */, 7A9CCCBC2A96302800DD6A34 /* ChangeLogCoordinator.swift in Sources */, + 588D7EE72AF4CC64005DF40A /* MarkdownLinkNode.swift in Sources */, 58B26E282943527300D5980C /* SystemNotificationProvider.swift in Sources */, + 58EFC7712AFB45E500E9F4CB /* SettingsChildCoordinator.swift in Sources */, 58CCA01222424D11004F3011 /* SettingsViewController.swift in Sources */, + 588D7EE22AF4C8CE005DF40A /* MarkdownParser.swift in Sources */, F0E8CC0A2A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift in Sources */, 581DA2752A1E283E0046ED47 /* WgKeyRotation.swift in Sources */, 7A83C4022A57FAA800DFB83A /* SettingsDNSInfoCell.swift in Sources */, @@ -4483,6 +4623,7 @@ F028A56C2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift in Sources */, 58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */, 58B9EB152489139B00095626 /* RESTError+Display.swift in Sources */, + 58B6966F2AF4FA0C006A6B71 /* MarkdownParagraphNode.swift in Sources */, 587B753F2668E5A700DEF7E9 /* NotificationContainerView.swift in Sources */, 58F2E144276A13F300A79513 /* StartTunnelOperation.swift in Sources */, 58CCA01E2242787B004F3011 /* AccountTextField.swift in Sources */, diff --git a/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/AttributedMarkdown.swift b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/AttributedMarkdown.swift new file mode 100644 index 000000000000..8700eb849130 --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/AttributedMarkdown.swift @@ -0,0 +1,47 @@ +// +// AttributedMarkdownProtocol.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Type of markdown element. +enum MarkdownElement { + /// Bold text. + case bold + /// URL link. + case link +} + +/// Callback type used to override the attributed string attributes during parsing. +typealias MarkdownEffectCallback = (MarkdownElement, String) -> [NSAttributedString.Key: Any] + +/// Type implementing conversion from markdown to attributed string. +protocol AttributedMarkdown { + /// Convert the type to attributed string. + /// + /// - Parameters: + /// - options: markdown styling options. + /// - applyEffect: the callback used to override the string attributes during parsing. + /// - Returns: the attributed string. + func attributedString(options: MarkdownStylingOptions, applyEffect: MarkdownEffectCallback?) -> NSAttributedString +} + +extension NSAttributedString.Key { + /// The attributed string key used in place of `.link` whos text color is not customizable in UILabels. + /// The value associated with this key can be a `String` or an `URL`. + static let hyperlink = NSAttributedString.Key("HyperLink") +} + +extension AttributedMarkdown { + /// Convert the type to attributed string. + /// + /// - Parameter options: markdown styling options. + /// - Returns: the attributed string. + func attributedString(options: MarkdownStylingOptions) -> NSAttributedString { + attributedString(options: options, applyEffect: nil) + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownBoldNode+AttributedString.swift b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownBoldNode+AttributedString.swift new file mode 100644 index 000000000000..d8c69dd61640 --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownBoldNode+AttributedString.swift @@ -0,0 +1,24 @@ +// +// MarkdownBoldNode+AttributedString.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension MarkdownBoldNode: AttributedMarkdown { + func attributedString(options: MarkdownStylingOptions, applyEffect: MarkdownEffectCallback?) -> NSAttributedString { + let string = text?.withUnicodeLineSeparators() ?? "" + var attributes: [NSAttributedString.Key: Any] = [.font: options.boldFont] + + if let textColor = options.textColor { + attributes[.foregroundColor] = textColor + } + + attributes.merge(applyEffect?(.bold, string) ?? [:], uniquingKeysWith: { $1 }) + + return NSAttributedString(string: string, attributes: attributes) + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownDocumentNode+AttributedString.swift b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownDocumentNode+AttributedString.swift new file mode 100644 index 000000000000..96432f83009f --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownDocumentNode+AttributedString.swift @@ -0,0 +1,38 @@ +// +// MarkdownDocumentNode+AttributedString.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension MarkdownDocumentNode: AttributedMarkdown { + func attributedString(options: MarkdownStylingOptions, applyEffect: MarkdownEffectCallback?) -> NSAttributedString { + var isPrecededByParagraph = false + + return children.reduce(into: NSMutableAttributedString()) { partialResult, node in + guard let transformableNode = node as? AttributedMarkdown else { return } + + defer { isPrecededByParagraph = node.isParagraph } + + // Add newline between paragraphs. + if node.isParagraph, isPrecededByParagraph { + partialResult.append(NSAttributedString( + string: "\n", + attributes: [.font: options.font, .paragraphStyle: options.paragraphStyle] + )) + } + + let attributedString = transformableNode.attributedString(options: options, applyEffect: applyEffect) + partialResult.append(attributedString) + } + } +} + +private extension MarkdownNode { + var isParagraph: Bool { + type == .paragraph + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownLinkNode+AttributedString.swift b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownLinkNode+AttributedString.swift new file mode 100644 index 000000000000..2725194fdd56 --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownLinkNode+AttributedString.swift @@ -0,0 +1,23 @@ +// +// MarkdownLinkNode+AttributedString.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension MarkdownLinkNode: AttributedMarkdown { + func attributedString(options: MarkdownStylingOptions, applyEffect: MarkdownEffectCallback?) -> NSAttributedString { + var attributes: [NSAttributedString.Key: Any] = [.font: options.font, options.linkAttribute.attributeKey: url] + + if let linkColor = options.linkColor { + attributes[.foregroundColor] = linkColor + } + + attributes.merge(applyEffect?(.link, title) ?? [:], uniquingKeysWith: { $1 }) + + return NSAttributedString(string: title, attributes: attributes) + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownParagraphNode+AttributedString.swift b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownParagraphNode+AttributedString.swift new file mode 100644 index 000000000000..955d2cf79ea2 --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownParagraphNode+AttributedString.swift @@ -0,0 +1,24 @@ +// +// MarkdownParagraphNode+AttributedString.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension MarkdownParagraphNode: AttributedMarkdown { + func attributedString(options: MarkdownStylingOptions, applyEffect: MarkdownEffectCallback?) -> NSAttributedString { + let mutableAttributedString = children.compactMap { $0 as? AttributedMarkdown } + .reduce(into: NSMutableAttributedString()) { partialResult, node in + let attributedString = node.attributedString(options: options, applyEffect: applyEffect) + partialResult.append(attributedString) + } + + let range = NSRange(location: 0, length: mutableAttributedString.length) + mutableAttributedString.addAttribute(.paragraphStyle, value: options.paragraphStyle, range: range) + + return mutableAttributedString + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownTextNode+AttributedString.swift b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownTextNode+AttributedString.swift new file mode 100644 index 000000000000..3ccf50a29a1c --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/MarkdownTextNode+AttributedString.swift @@ -0,0 +1,22 @@ +// +// MarkdownTextNode+AttributedString.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension MarkdownTextNode: AttributedMarkdown { + func attributedString(options: MarkdownStylingOptions, applyEffect: MarkdownEffectCallback?) -> NSAttributedString { + let string = text?.withUnicodeLineSeparators() ?? "" + var attributes: [NSAttributedString.Key: Any] = [.font: options.font] + + if let textColor = options.textColor { + attributes[.foregroundColor] = textColor + } + + return NSAttributedString(string: string, attributes: attributes) + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/NSAttributedString+Markdown.swift b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/NSAttributedString+Markdown.swift new file mode 100644 index 000000000000..605c7441ed59 --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/NSAttributedString+Markdown.swift @@ -0,0 +1,30 @@ +// +// NSAttributedString+Markdown.swift +// MullvadVPN +// +// Created by pronebird on 19/11/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +extension NSAttributedString { + /// Initialize the attributed string from markdown. + /// + /// - Parameters: + /// - markdownString: the markdown string. + /// - options: markdown styling options. + /// - applyEffect: the callback used to override the string attributes during parsing. + convenience init( + markdownString: String, + options: MarkdownStylingOptions, + applyEffect: MarkdownEffectCallback? = nil + ) { + var parser = MarkdownParser(markdown: markdownString) + let document = parser.parse() + + let attributedString = document.attributedString(options: options, applyEffect: applyEffect) + + self.init(attributedString: attributedString) + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/String+UnicodeLineSeparator.swift b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/String+UnicodeLineSeparator.swift new file mode 100644 index 000000000000..7360e5b0c262 --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/AttributedStringSupport/String+UnicodeLineSeparator.swift @@ -0,0 +1,25 @@ +// +// String+UnicodeLineSeparator.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Unicode line separator. +/// Declared on macOS as `NSLineSeparatorCharacter` but not on iOS. +private let unicodeLineSeparator: Character = "\u{2028}" + +extension String { + /// Return a new string with all line seprators `\r\n` or `\n` replaced with unicode line separator. + /// + /// `NSAttributedString` treats `\n` as a paragraph separator. + /// - Returns: a new string with all line separators converted to unicode line separator. + func withUnicodeLineSeparators() -> String { + String(map { ch in + ch.isNewline ? unicodeLineSeparator : ch + }) + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/IteratorProtocol+String.swift b/ios/MullvadVPN/Classes/Markdown/IteratorProtocol+String.swift new file mode 100644 index 000000000000..8b8cb7c16d73 --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/IteratorProtocol+String.swift @@ -0,0 +1,27 @@ +// +// IteratorProtocol+String.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension IteratorProtocol where Element == Character { + /// Collect characters into a string while the predicate evaluates to `true`. Rethrows errors thrown by the predicate. + /// + /// - Parameter predicate: The predicate to evaluate each character before appending it to the result string. + /// - Returns: The result string. + mutating func take(while predicate: (Character) throws -> Bool) rethrows -> String { + var accummulated = "" + + while let char = next() { + guard try predicate(char) else { break } + + accummulated.append(char) + } + + return accummulated + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/MarkdownNode.swift b/ios/MullvadVPN/Classes/Markdown/MarkdownNode.swift new file mode 100644 index 000000000000..c649ad5ad1cc --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/MarkdownNode.swift @@ -0,0 +1,99 @@ +// +// MarkdownNode.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// The base type defining markdown node. +/// Do not instantiate this type directly. Use one of its subclasses instead. +class MarkdownNode: CustomDebugStringConvertible { + /// The type of node. + let type: MarkdownNodeType + + /// The child nodes. + private(set) var children: [MarkdownNode] = [] + + /// The parent node. + private(set) weak var parent: MarkdownNode? + + init(type: MarkdownNodeType, children: [MarkdownNode] = []) { + self.type = type + children.forEach { addChild($0) } + } + + /// Returns last child. + var lastChild: MarkdownNode? { + return children.last + } + + /// Add child node. + func addChild(_ child: MarkdownNode) { + child.parent = self + children.append(child) + } + + /// Remove child. + func removeChild(_ child: MarkdownNode) { + children.removeAll { childFromArray in + guard child === childFromArray else { return false } + + child.parent = nil + return true + } + } + + /// Detach this node from parent. + func removeFromParent() { + parent?.removeChild(self) + } + + var debugDescription: String { + // Subclasses should override this method. + return "\(self)" + } + + /// Returns a recursive description of a markdown subtree. Useful when debugging. + /// + /// - Parameter level: indentation level. + /// - Returns: recursive description of a subtree + func recursiveDescription(level: Int = 0) -> String { + let indent = String(repeating: " ", count: level) + var str = "" + + let descriptionLines = debugDescription.components(separatedBy: .newlines) + if let firstLine = descriptionLines.first { + str += "\(indent)+ \(firstLine)" + } + descriptionLines.dropFirst().forEach { line in + str += "\n\(indent) \(line)" + } + + for child in children { + str += "\n" + child.recursiveDescription(level: level + 1) + } + + return str + } + + /// Test equality. + /// + /// Default implementation only checks node types. + /// + /// - Parameter other: other node. + /// - Returns: `true` if objects are equal, otherwise `false`. + func isEqualTo(_ other: MarkdownNode) -> Bool { + guard type == other.type && children.count == other.children.count else { return false } + + return zip(children, other.children).allSatisfy { $0.isEqualTo($1) } + } +} + +extension MarkdownNode: Equatable { + static func == (lhs: MarkdownNode, rhs: MarkdownNode) -> Bool { + return lhs.isEqualTo(rhs) + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/MarkdownNodeType.swift b/ios/MullvadVPN/Classes/Markdown/MarkdownNodeType.swift new file mode 100644 index 000000000000..30ea585c4617 --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/MarkdownNodeType.swift @@ -0,0 +1,30 @@ +// +// MarkdownNodeType.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// The type of node used within the markdown tree. +enum MarkdownNodeType { + /// The unstyled text fragment. + case text + + /// The bold text node. + /// Syntax: `**Proceed carefully in unknown waters!**` + case bold + + /// The URL link node. + /// Syntax: `[Mullvad VPN](https://mullvad.net)` + case link + + /// The paragraph node. + /// Typically groups of elements separated by two newline characters form a paragraph. + case paragraph + + /// The fragment of a markdown document. + case document +} diff --git a/ios/MullvadVPN/Classes/Markdown/MarkdownParser.swift b/ios/MullvadVPN/Classes/Markdown/MarkdownParser.swift new file mode 100644 index 000000000000..11f40a0f2b77 --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/MarkdownParser.swift @@ -0,0 +1,158 @@ +// +// MarkdownParser.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Markdown grammar. +private enum MarkdownToken { + static let asterisk: Character = "*" + static let openBrace: Character = "[" + static let closeBrace: Character = "]" + static let openParen: Character = "(" + static let closeParen: Character = ")" +} + +/** + A simple markdown parser. + + The following markdown syntax is currently supported: + + 1. Bold text: `**bold text**` + 2. Links: `[Mullvad VPN](https://mullvad.net)` + 3. Paragraphs represented by two line separators between text. + 4. Plain unstyled text nodes. + */ +@available(iOS, introduced: 14.0, obsoleted: 15.0, message: "Replace with native support for Markdown.") +struct MarkdownParser { + private var iterator: PeekableIterator + private let documentNode = MarkdownDocumentNode() + + /// Initializes the parser with a markdown string. + /// - Parameter markdown: markdown string. + init(markdown: String) { + iterator = PeekableIterator(markdown.makeIterator()) + } + + /// Parse markdown into the tree structure. + /// + /// - Returns: a document node. + mutating func parse() -> MarkdownDocumentNode { + while let char = iterator.next() { + // Parse bold tag **text** + if char == MarkdownToken.asterisk, char == iterator.peek() { + _ = iterator.next() // consume peeked element + + // If current node is bold then we found a closing tag for it. + if let boldNode = documentNode.lastChild as? MarkdownBoldNode, !boldNode.isClosed { + boldNode.markClosed() + } else { + documentNode.addChild(MarkdownBoldNode(isClosed: false)) + } + + continue + } + + // Parse URL [title](url) + if char == MarkdownToken.openBrace, let linkNode = try? tryParseLink() { + documentNode.addChild(linkNode) + continue + } + + // Parse paragraphs separated by a sequence of two CRLF. + // Swift string iterator parses CRLF into a single character. + if char.isNewline, let nextChar = iterator.peek(), nextChar.isNewline { + _ = iterator.next() // consume peeked element + wrapIntoParagraph() + continue + } + + // Found untagged text. + switch documentNode.lastChild?.type { + case .bold: + if let boldNode = documentNode.lastChild as? MarkdownBoldNode, !boldNode.isClosed { + boldNode.appendText(String(char)) + } else { + let textNode = MarkdownTextNode(text: String(char)) + + documentNode.addChild(textNode) + } + + case .text: + let textNode = documentNode.lastChild as? MarkdownTextNode + textNode?.appendText(String(char)) + + case .link, .paragraph, .document, .none: + let textNode = MarkdownTextNode(text: String(char)) + + documentNode.addChild(textNode) + } + } + + // Wrap the remaining nodes into paragraph. + wrapIntoParagraph() + + return documentNode + } + + /// Wraps all preceding nodes into a paragraph traversing in reverse until either the beginning of the document is reached or another paragraph. + private func wrapIntoParagraph() { + var extractedChildren = [MarkdownNode]() + + for child in documentNode.children.reversed() { + guard child.type != .paragraph else { break } + + child.removeFromParent() + extractedChildren.insert(child, at: 0) + } + + guard !extractedChildren.isEmpty else { return } + + let paragraph = MarkdownParagraphNode() + extractedChildren.forEach { paragraph.addChild($0) } + documentNode.addChild(paragraph) + } + + /// Parse markdown link. + /// + /// Advances the cursor of internal iterator upon success. + /// + /// Markdown links have the following syntax: `[Mullvad VPN](https://mullvad.net)`. + /// The copy of an iterator has already consumed the first `[` letter. + /// + /// - Returns: an instance of `MarkdownLinkNode` upon success, otherwise throws an error. + private mutating func tryParseLink() throws -> MarkdownLinkNode { + // Copy the underlying iterator to prevent advancing the cursor in case of failure to parse the link. + var tempIterator = iterator + + // Parse the title. `[` is already consumed by the caller. + let title = try tempIterator.take { ch in + guard !ch.isNewline else { throw MarkdownParseURLError() } + return ch != MarkdownToken.closeBrace + } + + // Parse the opening paren. + guard tempIterator.next() == MarkdownToken.openParen else { throw MarkdownParseURLError() } + + // Parse URL until the closing paren. + var isFoundClosingParen = false + let url = try tempIterator.take { ch in + guard !ch.isNewline else { throw MarkdownParseURLError() } + isFoundClosingParen = ch == MarkdownToken.closeParen + return !isFoundClosingParen + } + guard isFoundClosingParen else { throw MarkdownParseURLError() } + + // Replace the underlying iterator to advance the cursor. + iterator = tempIterator + + return MarkdownLinkNode(title: title, url: url) + } +} + +/// Internal error type used to indicate URL parsing error. +private struct MarkdownParseURLError: Error {} diff --git a/ios/MullvadVPN/Classes/Markdown/MarkdownStylingOptions.swift b/ios/MullvadVPN/Classes/Markdown/MarkdownStylingOptions.swift new file mode 100644 index 000000000000..0605ccdeead1 --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/MarkdownStylingOptions.swift @@ -0,0 +1,53 @@ +// +// MarkdownStylingOptions.swift +// MullvadVPN +// +// Created by Jon Petersson on 2023-06-29. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Struct describing the visual style that should be used when converting from markdown to attributed string. +struct MarkdownStylingOptions { + /// Primary font for text. + var font: UIFont + + /// Text color. + var textColor: UIColor? + + /// The color of the link. + /// UIKit controls may ignore it when used with standard link attributes. + var linkColor: UIColor? + + /// The attribute that holds the URL. + var linkAttribute: MarkdownLinkAttribute = .standard + + /// Paragraph style + var paragraphStyle: NSParagraphStyle = .default + + /// Bold font derived from primary font. + var boldFont: UIFont { + let fontDescriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) ?? font.fontDescriptor + return UIFont(descriptor: fontDescriptor, size: font.pointSize) + } +} + +/// The attribute that holds the URL. +enum MarkdownLinkAttribute { + /// Standard `NSLinkAttribute` attribute. + case standard + + /// Custom hyperlink attribute. + case custom + + /// Returns the attribute key which should be used to store the link URL. + var attributeKey: NSAttributedString.Key { + switch self { + case .standard: + return .link + case .custom: + return .hyperlink + } + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownBoldNode.swift b/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownBoldNode.swift new file mode 100644 index 000000000000..6b24c47c1577 --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownBoldNode.swift @@ -0,0 +1,54 @@ +// +// MarkdownBoldNode.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// The bold text node. +class MarkdownBoldNode: MarkdownNode { + /// The text content. + private(set) var text: String? + + /// Indicates whether the closing tag was found for that node. + private(set) var isClosed = false + + /// Initializes the bold text node. + /// + /// - Parameters: + /// - text: text content. + /// - isClosed: whether the closing tag was found. + init(text: String? = nil, isClosed: Bool = true) { + self.text = text + self.isClosed = isClosed + super.init(type: .bold) + } + + override var debugDescription: String { + "Bold: \(text ?? "(nil)")" + } + + /// Append the string to node's text content. + /// - Parameter string: the string to append to the node's text content. + func appendText(_ string: String) { + if text == nil { + text = string + } else { + text?.append(string) + } + } + + /// Mark that the closing tag was found for that node. + func markClosed() { + isClosed = true + } + + override func isEqualTo(_ other: MarkdownNode) -> Bool { + guard let other = other as? MarkdownBoldNode else { return false } + + return text == other.text + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownDocumentNode.swift b/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownDocumentNode.swift new file mode 100644 index 000000000000..5632fddd9dab --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownDocumentNode.swift @@ -0,0 +1,21 @@ +// +// MarkdownDocumentNode.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// The root node that represents a markdown document. +class MarkdownDocumentNode: MarkdownNode { + /// Initializes the document node. + init(children: [MarkdownNode] = []) { + super.init(type: .document, children: children) + } + + override var debugDescription: String { + return "Document" + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownLinkNode.swift b/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownLinkNode.swift new file mode 100644 index 000000000000..c7f3c1712878 --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownLinkNode.swift @@ -0,0 +1,38 @@ +// +// MarkdownLinkNode.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// The URL link node. +class MarkdownLinkNode: MarkdownNode { + /// The link title. + let title: String + + /// The URL string. + let url: String + + /// Initialzes the link node with title and URL. + /// - Parameters: + /// - title: the link title. + /// - url: the link URL. + init(title: String, url: String) { + self.title = title + self.url = url + super.init(type: .link) + } + + override var debugDescription: String { + "Link: \(title) (\(url))" + } + + override func isEqualTo(_ other: MarkdownNode) -> Bool { + guard let other = other as? MarkdownLinkNode else { return false } + + return title == other.title && url == other.url + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownParagraphNode.swift b/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownParagraphNode.swift new file mode 100644 index 000000000000..769cb5ba026c --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownParagraphNode.swift @@ -0,0 +1,21 @@ +// +// MarkdownParagraphNode.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// The paragraph node. +class MarkdownParagraphNode: MarkdownNode { + /// Initializes the paragraph node. + init(children: [MarkdownNode] = []) { + super.init(type: .paragraph, children: children) + } + + override var debugDescription: String { + return "Paragraph" + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownTextNode.swift b/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownTextNode.swift new file mode 100644 index 000000000000..5ff572591fd9 --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/Nodes/MarkdownTextNode.swift @@ -0,0 +1,42 @@ +// +// MarkdownTextNode.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// The untagged and unstyled text fragment. +class MarkdownTextNode: MarkdownNode { + /// The text content. + private(set) var text: String? + + /// Initializes the text node. + /// - Parameter text: the text content. + init(text: String? = nil) { + self.text = text + super.init(type: .text) + } + + override var debugDescription: String { + "Text: \(text ?? "(nil)")" + } + + /// Append text content if set, otherwise assigns the text content to the given string. + /// - Parameter string: the string to append to the node's text content. + func appendText(_ string: String) { + if text == nil { + text = string + } else { + text?.append(string) + } + } + + override func isEqualTo(_ other: MarkdownNode) -> Bool { + guard let other = other as? MarkdownTextNode else { return false } + + return text == other.text + } +} diff --git a/ios/MullvadVPN/Classes/Markdown/PeekableIterator.swift b/ios/MullvadVPN/Classes/Markdown/PeekableIterator.swift new file mode 100644 index 000000000000..88f62e9569a0 --- /dev/null +++ b/ios/MullvadVPN/Classes/Markdown/PeekableIterator.swift @@ -0,0 +1,39 @@ +// +// PeekableIterator.swift +// MullvadVPN +// +// Created by pronebird on 03/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Iterator that can look one element ahead without consuming it. +struct PeekableIterator: IteratorProtocol { + typealias Element = Wrapped.Element + + private var base: Wrapped + private var nextElement: Wrapped.Element? + + init(_ base: Wrapped) { + self.base = base + } + + mutating func next() -> Element? { + if let nextElement = nextElement { + self.nextElement = nil + return nextElement + } else { + return base.next() + } + } + + mutating func peek() -> Element? { + if let nextElement = nextElement { + return nextElement + } else { + nextElement = base.next() + return nextElement + } + } +} diff --git a/ios/MullvadVPN/Classes/MarkdownStylingOptions.swift b/ios/MullvadVPN/Classes/MarkdownStylingOptions.swift deleted file mode 100644 index 1b2e2390d715..000000000000 --- a/ios/MullvadVPN/Classes/MarkdownStylingOptions.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// MarkdownStylingOptions.swift -// MullvadVPN -// -// Created by Jon Petersson on 2023-06-29. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import UIKit - -struct MarkdownStylingOptions { - var font: UIFont - var paragraphStyle: NSParagraphStyle = .default - - var boldFont: UIFont { - let fontDescriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) ?? font.fontDescriptor - return UIFont(descriptor: fontDescriptor, size: font.pointSize) - } -} diff --git a/ios/MullvadVPN/Extensions/NSAttributedString+Markdown.swift b/ios/MullvadVPN/Extensions/NSAttributedString+Markdown.swift deleted file mode 100644 index 173888f6065e..000000000000 --- a/ios/MullvadVPN/Extensions/NSAttributedString+Markdown.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// NSAttributedString+Markdown.swift -// MullvadVPN -// -// Created by pronebird on 19/11/2021. -// Copyright © 2021 Mullvad VPN AB. All rights reserved. -// - -import UIKit - -extension NSAttributedString { - enum MarkdownElement { - case bold - } - - convenience init( - markdownString: String, - options: MarkdownStylingOptions, - applyEffect: ((MarkdownElement, String) -> [NSAttributedString.Key: Any])? = nil - ) { - let attributedString = NSMutableAttributedString() - let paragraphs = markdownString.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n\n") - - for (paragraphIndex, paragraph) in paragraphs.enumerated() { - let attributedParagraph = NSMutableAttributedString() - - // Replace \n with \u2028 to prevent attributed string from picking up single line breaks as paragraphs. - let components = paragraph.replacingOccurrences(of: "\n", with: "\u{2028}") - .components(separatedBy: "**") - - if paragraphIndex > 0 { - // Add single line break to add spacing between paragraphs. - attributedParagraph.append(NSAttributedString(string: "\n")) - } - - for (stringIndex, string) in components.enumerated() { - var attributes: [NSAttributedString.Key: Any] = [:] - - if stringIndex % 2 == 0 { - attributes[.font] = options.font - } else { - attributes[.font] = options.boldFont - attributes.merge(applyEffect?(.bold, string) ?? [:], uniquingKeysWith: { $1 }) - } - - attributedParagraph.append(NSAttributedString(string: string, attributes: attributes)) - } - - attributedString.append(attributedParagraph) - } - - attributedString.addAttribute( - .paragraphStyle, - value: options.paragraphStyle, - range: NSRange(location: 0, length: attributedString.length) - ) - - self.init(attributedString: attributedString) - } -} diff --git a/ios/MullvadVPN/Notifications/Notification Providers/RegisteredDeviceInAppNotificationProvider.swift b/ios/MullvadVPN/Notifications/Notification Providers/RegisteredDeviceInAppNotificationProvider.swift index 8ac30f74f716..b9b1a9c8e6e2 100644 --- a/ios/MullvadVPN/Notifications/Notification Providers/RegisteredDeviceInAppNotificationProvider.swift +++ b/ios/MullvadVPN/Notifications/Notification Providers/RegisteredDeviceInAppNotificationProvider.swift @@ -36,9 +36,10 @@ final class RegisteredDeviceInAppNotificationProvider: NotificationProvider, let stylingOptions = MarkdownStylingOptions(font: .systemFont(ofSize: 14.0)) return NSAttributedString(markdownString: string, options: stylingOptions) { markdownType, _ in - switch markdownType { - case .bold: + if case .bold = markdownType { return [.foregroundColor: UIColor.InAppNotificationBanner.titleColor] + } else { + return [:] } } } diff --git a/ios/MullvadVPNTests/MarkdownParserTests.swift b/ios/MullvadVPNTests/MarkdownParserTests.swift new file mode 100644 index 000000000000..840b1c26bf23 --- /dev/null +++ b/ios/MullvadVPNTests/MarkdownParserTests.swift @@ -0,0 +1,209 @@ +// +// MarkdownParserTests.swift +// MullvadVPNTests +// +// Created by pronebird on 07/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import XCTest + +final class MarkdownParserTests: XCTestCase { + let defaultStylingOptions = MarkdownStylingOptions( + font: UIFont(name: "Courier", size: 9)!, + linkColor: UIColor.red + ) + + func testParsingText() { + var parser = MarkdownParser(markdown: "Untagged text") + let document = parser.parse() + + XCTAssertEqual(document, MarkdownDocumentNode(children: [ + MarkdownParagraphNode(children: [ + MarkdownTextNode(text: "Untagged text"), + ]), + ])) + } + + func testParsingBold() { + var parser = MarkdownParser(markdown: "**bold text**") + let document = parser.parse() + + XCTAssertEqual(document, MarkdownDocumentNode(children: [ + MarkdownParagraphNode(children: [ + MarkdownBoldNode(text: "bold text"), + ]), + ])) + } + + func testParsingUnclosedBold() { + var parser = MarkdownParser(markdown: "**bold text") + let document = parser.parse() + + XCTAssertEqual(document, MarkdownDocumentNode(children: [ + MarkdownParagraphNode(children: [ + MarkdownBoldNode(text: "bold text"), + ]), + ])) + } + + func testParsingLinks() { + var parser = MarkdownParser(markdown: "[Mullvad VPN](https://mullvad.net/)") + let document = parser.parse() + + XCTAssertEqual(document, MarkdownDocumentNode(children: [ + MarkdownParagraphNode(children: [ + MarkdownLinkNode(title: "Mullvad VPN", url: "https://mullvad.net/"), + ]), + ])) + } + + func testParsingMalformedLinks() { + var parser = MarkdownParser(markdown: "[Mullvad VPN](https://mullvad.net/") + let document = parser.parse() + + XCTAssertEqual(document, MarkdownDocumentNode(children: [ + MarkdownParagraphNode(children: [ + MarkdownTextNode(text: "[Mullvad VPN](https://mullvad.net/"), + ]), + ])) + } + + func testParsingParagraphs() { + var parser = MarkdownParser(markdown: "Paragraph 1\nStill paragraph 1\n\nParagraph 2") + let document = parser.parse() + + XCTAssertEqual(document, MarkdownDocumentNode(children: [ + MarkdownParagraphNode(children: [ + MarkdownTextNode(text: "Paragraph 1\nStill paragraph 1"), + ]), + MarkdownParagraphNode(children: [ + MarkdownTextNode(text: "Paragraph 2"), + ]), + ])) + } + + func testTransformingBoldToAttributedString() { + var parser = MarkdownParser(markdown: "**bold text**") + let attributedString = parser.parse().attributedString(options: defaultStylingOptions) + + let expectedString = NSMutableAttributedString() + paragraph(appendingInto: expectedString) { + bold("bold text") + } + + XCTAssertTrue(expectedString.isEqual(to: attributedString)) + } + + func testTransformingLinkToAttributedString() { + var parser = MarkdownParser(markdown: "[Mullvad VPN](https://mullvad.net/)") + let attributedString = parser.parse().attributedString(options: defaultStylingOptions) + + let expectedString = NSMutableAttributedString() + paragraph(appendingInto: expectedString) { + link(title: "Mullvad VPN", url: "https://mullvad.net/") + } + + XCTAssertTrue(expectedString.isEqual(to: attributedString)) + } + + func testTransformingParagraphsToAttributedString() { + var parser = MarkdownParser(markdown: "Paragraph 1\nStill paragraph 1\n\nParagraph 2") + let parsedString = parser.parse().attributedString(options: defaultStylingOptions) + + let expectedString = NSMutableAttributedString() + paragraph(appendingInto: expectedString) { + text("Paragraph 1\u{2028}Still paragraph 1\n") + } + paragraph(appendingInto: expectedString) { + text("Paragraph 2") + } + + XCTAssertTrue(parsedString.isEqual(to: expectedString)) + } + + func testTransformingComplexMarkdownToAttributedString() { + let markdown = """ + Manage default and setup custom methods to access to Mullvad VPN API. [About API access...](#about) + + **Important:** direct access method **cannot** be removed. + """ + + var parser = MarkdownParser(markdown: markdown) + let parsedString = parser.parse().attributedString(options: defaultStylingOptions) + + let expectedString = NSMutableAttributedString() + + paragraph(appendingInto: expectedString) { + text("Manage default and setup custom methods to access to Mullvad VPN API. ") + link(title: "About API access...", url: "#about") + text("\n") + } + paragraph(appendingInto: expectedString) { + bold("Important:") + text(" direct access method ") + bold("cannot") + text(" be removed.") + } + XCTAssertTrue(parsedString.isEqual(to: expectedString)) + } +} + +private extension MarkdownParserTests { + func paragraph( + appendingInto resultString: NSMutableAttributedString, + @ParagraphBuilder builder: () -> [NSAttributedString] + ) { + let mutableString = NSMutableAttributedString() + + builder().forEach { mutableString.append($0) } + mutableString.addAttribute( + .paragraphStyle, + value: defaultStylingOptions.paragraphStyle, + range: NSRange(location: 0, length: mutableString.length) + ) + + resultString.append(mutableString) + } + + func link(title: String, url: String) -> NSAttributedString { + var attributes: [NSAttributedString.Key: Any] = [ + .font: defaultStylingOptions.font, + .link: url, + ] + if let linkColor = defaultStylingOptions.linkColor { + attributes[.foregroundColor] = linkColor + } + return NSAttributedString(string: title, attributes: attributes) + } + + func bold(_ text: String) -> NSAttributedString { + NSAttributedString(string: text, attributes: [.font: defaultStylingOptions.boldFont]) + } + + func text(_ text: String) -> NSAttributedString { + NSAttributedString(string: text, attributes: [.font: defaultStylingOptions.font]) + } +} + +@resultBuilder +private enum ParagraphBuilder { + static func buildPartialBlock(first: NSAttributedString) -> [NSAttributedString] { [first] } + static func buildPartialBlock(first: [NSAttributedString]) -> [NSAttributedString] { first } + + static func buildPartialBlock( + accumulated: [NSAttributedString], + next: NSAttributedString + ) -> [NSAttributedString] { + accumulated + [next] + } + + static func buildPartialBlock( + accumulated: [NSAttributedString], + next: [NSAttributedString] + ) -> [NSAttributedString] { + accumulated + next + } + + static func buildBlock() -> [NSAttributedString] { [] } +}