summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2023-03-24 13:13:00 +0100
committerAndrej Mihajlov <and@mullvad.net>2023-03-27 16:35:56 +0200
commitb5b791def7f83af5fc0fd7c395850dcd00b16008 (patch)
treed2747a4c95f92f126076621b11b34e972cc691f6
parente86a962ce095e145154f6431477e589e4a3013ac (diff)
downloadmullvadvpn-b5b791def7f83af5fc0fd7c395850dcd00b16008.tar.xz
mullvadvpn-b5b791def7f83af5fc0fd7c395850dcd00b16008.zip
Add changelog
-rw-r--r--gui/changes.txt1
-rw-r--r--ios/Assets/changes.txt5
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj28
-rw-r--r--ios/MullvadVPN/Classes/ChangeLog.swift64
-rw-r--r--ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift28
-rw-r--r--ios/MullvadVPN/Coordinators/App/ApplicationRouter.swift4
-rw-r--r--ios/MullvadVPN/Coordinators/App/ChangeLogCoordinator.swift46
-rw-r--r--ios/MullvadVPN/Extensions/Bundle+ProductVersion.swift5
-rw-r--r--ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift273
-rw-r--r--ios/MullvadVPN/View controllers/ChangeLog/ChangeLogContentView.swift136
-rw-r--r--ios/MullvadVPN/View controllers/ChangeLog/ChangeLogViewController.swift49
11 files changed, 508 insertions, 131 deletions
diff --git a/gui/changes.txt b/gui/changes.txt
index 75b0418348..f9d1c0f3c8 100644
--- a/gui/changes.txt
+++ b/gui/changes.txt
@@ -1,5 +1,4 @@
CHANGE THIS BEFORE A RELEASE
Each line is treated as a separate change item shown in the GUI the first time it runs after install.
Start each line with a capital letter and end each line with a period.
-[macOS, Windows, linux] To make an entry platform specific, start the line with an angle bracket enclosed comma separated list of platforms to show the entry on.
Only point out the major changes.
diff --git a/ios/Assets/changes.txt b/ios/Assets/changes.txt
new file mode 100644
index 0000000000..75b0418348
--- /dev/null
+++ b/ios/Assets/changes.txt
@@ -0,0 +1,5 @@
+CHANGE THIS BEFORE A RELEASE
+Each line is treated as a separate change item shown in the GUI the first time it runs after install.
+Start each line with a capital letter and end each line with a period.
+[macOS, Windows, linux] To make an entry platform specific, start the line with an angle bracket enclosed comma separated list of platforms to show the entry on.
+Only point out the major changes.
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index b202225aae..72be02bc76 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -113,6 +113,8 @@
584EBDBD2747C98F00A0C9FD /* NSAttributedString+Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */; };
584F99202902CBDD001F858D /* libRelaySelector.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5898D29829017DAC00EB5EBA /* libRelaySelector.a */; };
5857F24324C8662600CF6F47 /* SelectLocationHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5857F24224C8662600CF6F47 /* SelectLocationHeaderView.swift */; };
+ 5859A55329CD9B1300F66591 /* ChangeLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5859A55229CD9B1300F66591 /* ChangeLog.swift */; };
+ 5859A55529CD9DD900F66591 /* changes.txt in Resources */ = {isa = PBXBuildFile; fileRef = 5859A55429CD9DD800F66591 /* changes.txt */; };
585B4B8726D9098900555C4C /* TunnelStatusNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A94AE326CFD945001CB97C /* TunnelStatusNotificationProvider.swift */; };
585CA70F25F8C44600B47C62 /* UIMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CA70E25F8C44600B47C62 /* UIMetrics.swift */; };
585E820327F3285E00939F0E /* SendStoreReceiptOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585E820227F3285E00939F0E /* SendStoreReceiptOperation.swift */; };
@@ -151,7 +153,10 @@
5878A279290954790096FC88 /* TunnelViewControllerInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878A278290954790096FC88 /* TunnelViewControllerInteractor.swift */; };
5878A27B2909649A0096FC88 /* CustomOverlayRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878A27A2909649A0096FC88 /* CustomOverlayRenderer.swift */; };
5878A27D2909657C0096FC88 /* RevokedDeviceInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878A27C2909657C0096FC88 /* RevokedDeviceInteractor.swift */; };
+ 5878F4FC29CDA2E4003D4BE2 /* ChangeLogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878F4FB29CDA2E4003D4BE2 /* ChangeLogViewController.swift */; };
5878F50029CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878F4FF29CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift */; };
+ 5878F50229CDB989003D4BE2 /* ChangeLogCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878F50129CDB989003D4BE2 /* ChangeLogCoordinator.swift */; };
+ 5878F50429CDC547003D4BE2 /* ChangeLogContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878F50329CDC547003D4BE2 /* ChangeLogContentView.swift */; };
587988C728A2A01F00E3DF54 /* AccountDataThrottling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587988C628A2A01F00E3DF54 /* AccountDataThrottling.swift */; };
587A01FC23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587A01FB23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift */; };
587AD7C623421D7000E93A53 /* TunnelSettingsV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587AD7C523421D7000E93A53 /* TunnelSettingsV1.swift */; };
@@ -731,6 +736,8 @@
584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Markdown.swift"; sourceTree = "<group>"; };
58561C98239A5D1500BD6B5E /* IPv4Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPv4Endpoint.swift; sourceTree = "<group>"; };
5857F24224C8662600CF6F47 /* SelectLocationHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationHeaderView.swift; sourceTree = "<group>"; };
+ 5859A55229CD9B1300F66591 /* ChangeLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeLog.swift; sourceTree = "<group>"; };
+ 5859A55429CD9DD800F66591 /* changes.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = changes.txt; sourceTree = "<group>"; };
585CA70E25F8C44600B47C62 /* UIMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIMetrics.swift; sourceTree = "<group>"; };
585DA87626B024A600B8C587 /* CachedRelays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedRelays.swift; sourceTree = "<group>"; };
585DA89226B0323E00B8C587 /* TunnelProviderMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelProviderMessage.swift; sourceTree = "<group>"; };
@@ -771,7 +778,10 @@
5878A278290954790096FC88 /* TunnelViewControllerInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelViewControllerInteractor.swift; sourceTree = "<group>"; };
5878A27A2909649A0096FC88 /* CustomOverlayRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomOverlayRenderer.swift; sourceTree = "<group>"; };
5878A27C2909657C0096FC88 /* RevokedDeviceInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevokedDeviceInteractor.swift; sourceTree = "<group>"; };
+ 5878F4FB29CDA2E4003D4BE2 /* ChangeLogViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeLogViewController.swift; sourceTree = "<group>"; };
5878F4FF29CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+AutoLayoutBuilder.swift"; sourceTree = "<group>"; };
+ 5878F50129CDB989003D4BE2 /* ChangeLogCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeLogCoordinator.swift; sourceTree = "<group>"; };
+ 5878F50329CDC547003D4BE2 /* ChangeLogContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeLogContentView.swift; sourceTree = "<group>"; };
587988C628A2A01F00E3DF54 /* AccountDataThrottling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDataThrottling.swift; sourceTree = "<group>"; };
587A01FB23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorTunnelProviderHost.swift; sourceTree = "<group>"; };
587AD7C523421D7000E93A53 /* TunnelSettingsV1.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV1.swift; sourceTree = "<group>"; };
@@ -1241,6 +1251,7 @@
583FE01D29C197C1006E85F9 /* DeviceList */,
583FE01C29C19793006E85F9 /* RevokedDevice */,
583FE01B29C19786006E85F9 /* OutOfTime */,
+ 5878F4FA29CDA2D4003D4BE2 /* ChangeLog */,
583FE01A29C19777006E85F9 /* Preferences */,
583FE01929C19760006E85F9 /* ProblemReport */,
583FE01829C19709006E85F9 /* Settings */,
@@ -1460,6 +1471,7 @@
5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */,
5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */,
5872D6E7286304DE00DB5F4E /* TermsOfService.swift */,
+ 5859A55229CD9B1300F66591 /* ChangeLog.swift */,
587988C628A2A01F00E3DF54 /* AccountDataThrottling.swift */,
58138E60294871C600684F0C /* DeviceDataThrottling.swift */,
589A454B28DDF5E100565204 /* Swizzle.swift */,
@@ -1557,6 +1569,15 @@
path = Operations;
sourceTree = "<group>";
};
+ 5878F4FA29CDA2D4003D4BE2 /* ChangeLog */ = {
+ isa = PBXGroup;
+ children = (
+ 5878F4FB29CDA2E4003D4BE2 /* ChangeLogViewController.swift */,
+ 5878F50329CDC547003D4BE2 /* ChangeLogContentView.swift */,
+ );
+ path = ChangeLog;
+ sourceTree = "<group>";
+ };
587B75422669034500DEF7E9 /* Notification Providers */ = {
isa = PBXGroup;
children = (
@@ -1579,6 +1600,7 @@
58C3F4FA296C3AD500D72515 /* SettingsCoordinator.swift */,
5847D58C29B7740F008C3808 /* RevokedCoordinator.swift */,
583FE00D29C0D586006E85F9 /* OutOfTimeCoordinator.swift */,
+ 5878F50129CDB989003D4BE2 /* ChangeLogCoordinator.swift */,
);
path = App;
sourceTree = "<group>";
@@ -1879,6 +1901,7 @@
58F3C0A824A50C0E003E76BE /* Assets */ = {
isa = PBXGroup;
children = (
+ 5859A55429CD9DD800F66591 /* changes.txt */,
587DCCEE287D84A500CE821E /* countries.geo.json */,
);
path = Assets;
@@ -2375,6 +2398,7 @@
buildActionMask = 2147483647;
files = (
58727283265D173C00F315B2 /* LaunchScreen.storyboard in Resources */,
+ 5859A55529CD9DD900F66591 /* changes.txt in Resources */,
587DCCEF287D84A500CE821E /* countries.geo.json in Resources */,
58CE5E6B224146210008646E /* Assets.xcassets in Resources */,
5883A09E266A5AF7003EFFCB /* Localizable.strings in Resources */,
@@ -2597,6 +2621,7 @@
58F185AA298A3E3E00075977 /* TunnelCoordinator.swift in Sources */,
58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */,
5878A27129091CF20096FC88 /* AccountInteractor.swift in Sources */,
+ 5878F4FC29CDA2E4003D4BE2 /* ChangeLogViewController.swift in Sources */,
068CE5742927B7A400A068BB /* Migration.swift in Sources */,
58CCA010224249A1004F3011 /* TunnelViewController.swift in Sources */,
58B26E22294351EA00D5980C /* InAppNotificationProvider.swift in Sources */,
@@ -2692,6 +2717,7 @@
58607A4D2947287800BC467D /* AccountExpiryInAppNotificationProvider.swift in Sources */,
06410E07292D108E00AFC18C /* SettingsStore.swift in Sources */,
586A950D290125F0007BAF2B /* PresentAlertOperation.swift in Sources */,
+ 5878F50229CDB989003D4BE2 /* ChangeLogCoordinator.swift in Sources */,
58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */,
58B26E262943522400D5980C /* NotificationProvider.swift in Sources */,
58FEEB46260A028D00A621A8 /* GeoJSON.swift in Sources */,
@@ -2714,6 +2740,7 @@
58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */,
587D9676288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift in Sources */,
58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */,
+ 5878F50429CDC547003D4BE2 /* ChangeLogContentView.swift in Sources */,
58B9EB152489139B00095626 /* RESTError+Display.swift in Sources */,
587B753F2668E5A700DEF7E9 /* NotificationContainerView.swift in Sources */,
58421034282E4B1500F24E46 /* TunnelSettingsV2+REST.swift in Sources */,
@@ -2739,6 +2766,7 @@
58A8EE5E2976DB00009C0F8D /* StorePaymentManagerError+Display.swift in Sources */,
580F8B8328197881002E0998 /* TunnelSettingsV2.swift in Sources */,
58A8EE5A2976BFBB009C0F8D /* SKError+Localized.swift in Sources */,
+ 5859A55329CD9B1300F66591 /* ChangeLog.swift in Sources */,
58BBB39729717E0C00C8DB7C /* ApplicationCoordinator.swift in Sources */,
5803B4B22940A48700C23744 /* TunnelStore.swift in Sources */,
586A950F29012BEE007BAF2B /* AddressCacheTracker.swift in Sources */,
diff --git a/ios/MullvadVPN/Classes/ChangeLog.swift b/ios/MullvadVPN/Classes/ChangeLog.swift
new file mode 100644
index 0000000000..418bf277a8
--- /dev/null
+++ b/ios/MullvadVPN/Classes/ChangeLog.swift
@@ -0,0 +1,64 @@
+//
+// ChangeLog.swift
+// MullvadVPN
+//
+// Created by pronebird on 24/03/2023.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+enum ChangeLog {
+ private static let userDefaultsKey = "lastSeenChangeLogVersion"
+
+ /**
+ Returns `true` if changelog for current application version was already seen by user, otherwise
+ `false`.
+ */
+ static var isSeen: Bool {
+ let version = UserDefaults.standard.string(forKey: Self.userDefaultsKey)
+
+ return version == Bundle.main.shortVersion
+ }
+
+ /**
+ Marks changelog for current application version as seen in user defaults.
+ */
+ static func markAsSeen() {
+ UserDefaults.standard.set(Bundle.main.shortVersion, forKey: Self.userDefaultsKey)
+ }
+
+ /**
+ Marks changelog as unseen. Removes an entry from user defaults.
+ */
+ static func markAsUnseen() {
+ UserDefaults.standard.removeObject(forKey: Self.userDefaultsKey)
+ }
+
+ /**
+ Reads changelog file from bundle and returns its contents as a string.
+ */
+ static func readFromFile() throws -> String {
+ return try String(contentsOfFile: try getPathToChangesFile())
+ .split(whereSeparator: { $0.isNewline })
+ .compactMap { line in
+ let trimmedString = line.trimmingCharacters(in: .whitespaces)
+
+ guard !trimmedString.isEmpty else { return nil }
+
+ return "• \(trimmedString)"
+ }
+ .joined(separator: "\n")
+ }
+
+ /**
+ Returns path to changelog file in bundle.
+ */
+ static func getPathToChangesFile() throws -> String {
+ if let filePath = Bundle.main.path(forResource: "changes", ofType: "txt") {
+ return filePath
+ } else {
+ throw CocoaError(.fileNoSuchFile)
+ }
+ }
+}
diff --git a/ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift
index c0b18a2aaf..ba03323d0d 100644
--- a/ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift
@@ -138,6 +138,9 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
case .login:
presentLogin(animated: animated, completion: completion)
+ case .changelog:
+ presentChangeLog(animated: animated, completion: completion)
+
case .tos:
presentTOS(animated: animated, completion: completion)
@@ -267,6 +270,10 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
return .tos
}
+ guard ChangeLog.isSeen else {
+ return .changelog
+ }
+
switch tunnelManager.deviceState {
case .revoked:
return .revoked
@@ -318,11 +325,9 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
Begins horizontal flow presenting a navigation controller suitable for current user interface
idiom.
- On iPad this takes care of presenting a secondary navigation context using modal presentation
- after calling the given `block`.
+ On iPad this takes care of presenting a secondary navigation context using modal presentation.
- On iPhone this function simply passes the primary navigation container to the `block` and
- nothing else.
+ On iPhone this does nothing.
*/
private func beginHorizontalFlow(animated: Bool, completion: @escaping () -> Void) {
if isPad, secondaryNavigationContainer.presentingViewController == nil {
@@ -395,6 +400,21 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
}
}
+ private func presentChangeLog(animated: Bool, completion: @escaping (Coordinator) -> Void) {
+ let coordinator = ChangeLogCoordinator(navigationController: horizontalFlowController)
+
+ coordinator.didFinish = { [weak self] coordinator in
+ self?.continueFlow(animated: true)
+ }
+
+ addChild(coordinator)
+ coordinator.start(animated: animated)
+
+ beginHorizontalFlow(animated: animated) {
+ completion(coordinator)
+ }
+ }
+
private func presentMain(animated: Bool, completion: @escaping (Coordinator) -> Void) {
precondition(!isPad)
diff --git a/ios/MullvadVPN/Coordinators/App/ApplicationRouter.swift b/ios/MullvadVPN/Coordinators/App/ApplicationRouter.swift
index 8b52b21044..a6277dba58 100644
--- a/ios/MullvadVPN/Coordinators/App/ApplicationRouter.swift
+++ b/ios/MullvadVPN/Coordinators/App/ApplicationRouter.swift
@@ -75,7 +75,7 @@ enum AppRoute: Equatable, Hashable {
/**
Routes that are part of primary horizontal navigation group.
*/
- case tos, login, main, revoked, outOfTime
+ case tos, changelog, login, main, revoked, outOfTime
/**
Returns `true` when only one route of a kind can be displayed.
@@ -105,7 +105,7 @@ enum AppRoute: Equatable, Hashable {
*/
var routeGroup: AppRouteGroup {
switch self {
- case .tos, .login, .main, .revoked, .outOfTime:
+ case .tos, .changelog, .login, .main, .revoked, .outOfTime:
return .primary
case .selectLocation:
return .selectLocation
diff --git a/ios/MullvadVPN/Coordinators/App/ChangeLogCoordinator.swift b/ios/MullvadVPN/Coordinators/App/ChangeLogCoordinator.swift
new file mode 100644
index 0000000000..0dca0a41a4
--- /dev/null
+++ b/ios/MullvadVPN/Coordinators/App/ChangeLogCoordinator.swift
@@ -0,0 +1,46 @@
+//
+// ChangeLogCoordinator.swift
+// MullvadVPN
+//
+// Created by pronebird on 24/03/2023.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadLogging
+import UIKit
+
+final class ChangeLogCoordinator: Coordinator {
+ private let logger = Logger(label: "ChangeLogCoordinator")
+
+ let navigationController: RootContainerViewController
+
+ var didFinish: ((ChangeLogCoordinator) -> Void)?
+
+ init(navigationController: RootContainerViewController) {
+ self.navigationController = navigationController
+ }
+
+ func start(animated: Bool) {
+ let controller = ChangeLogViewController()
+
+ controller.setApplicationVersion(Bundle.main.shortVersion)
+
+ do {
+ let string = try ChangeLog.readFromFile()
+
+ controller.setChangeLogText(string)
+ } catch {
+ logger.error(error: error, message: "Cannot read changelog from bundle.")
+ }
+
+ controller.onFinish = { [weak self] in
+ guard let self = self else { return }
+
+ ChangeLog.markAsSeen()
+
+ self.didFinish?(self)
+ }
+
+ navigationController.pushViewController(controller, animated: animated)
+ }
+}
diff --git a/ios/MullvadVPN/Extensions/Bundle+ProductVersion.swift b/ios/MullvadVPN/Extensions/Bundle+ProductVersion.swift
index a3631c1872..410811842f 100644
--- a/ios/MullvadVPN/Extensions/Bundle+ProductVersion.swift
+++ b/ios/MullvadVPN/Extensions/Bundle+ProductVersion.swift
@@ -31,4 +31,9 @@ extension Bundle {
}
#endif
}
+
+ /// Returns short version XXXX.YY (i.e 2020.5).
+ var shortVersion: String {
+ return object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "???"
+ }
}
diff --git a/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift b/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift
index 7e207b0c68..c8f6970ed4 100644
--- a/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift
+++ b/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift
@@ -10,161 +10,87 @@ import UIKit
extension UIView {
/**
- Pin edges to edges of other view edges.
+ Pin all edges to edges of other view.
*/
- func pinEdgesTo(
- _ other: UIView,
- insets: NSDirectionalEdgeInsets = .zero,
- excludingEdges: NSDirectionalRectEdge = []
- ) -> [NSLayoutConstraint] {
- var constraints = [NSLayoutConstraint]()
+ func pinEdgesTo(_ other: UIView) -> [NSLayoutConstraint] {
+ return pinEdges(.all(), to: other)
+ }
- if !excludingEdges.contains(.top) {
- constraints.append(
- topAnchor.constraint(equalTo: other.topAnchor, constant: insets.top)
- )
- }
+ /**
+ Pin edges to edges of other view.
+ */
+ func pinEdges(_ edges: PinnableEdges, to other: UIView) -> [NSLayoutConstraint] {
+ return edges.inner.map { edge -> NSLayoutConstraint in
+ switch edge {
+ case let .top(inset):
+ return topAnchor.constraint(equalTo: other.topAnchor, constant: inset)
- if !excludingEdges.contains(.bottom) {
- constraints.append(
- bottomAnchor.constraint(equalTo: other.bottomAnchor, constant: insets.bottom)
- )
- }
+ case let .bottom(inset):
+ return bottomAnchor.constraint(equalTo: other.bottomAnchor, constant: inset)
- if !excludingEdges.contains(.leading) {
- constraints.append(
- leadingAnchor.constraint(equalTo: other.leadingAnchor, constant: insets.leading)
- )
- }
+ case let .leading(inset):
+ return leadingAnchor.constraint(equalTo: other.leadingAnchor, constant: inset)
- if !excludingEdges.contains(.trailing) {
- constraints.append(
- trailingAnchor.constraint(equalTo: other.trailingAnchor, constant: insets.trailing)
- )
+ case let .trailing(inset):
+ return trailingAnchor.constraint(equalTo: other.trailingAnchor, constant: inset)
+ }
}
-
- return constraints
}
/**
Pin edges to superview edges.
*/
- func pinEdgesToSuperview(
- insets: NSDirectionalEdgeInsets = .zero,
- excludingEdges: NSDirectionalRectEdge = []
- ) -> [NSLayoutConstraint] {
+ func pinEdgesToSuperview(_ edges: PinnableEdges = .all()) -> [NSLayoutConstraint] {
guard let superview = superview else { return [] }
- return pinEdgesTo(superview, insets: insets, excludingEdges: excludingEdges)
+ return pinEdges(edges, to: superview)
}
/**
Pin edges to superview margins.
*/
- func pinEdgesToSuperviewMargins(
- insets: NSDirectionalEdgeInsets = .zero,
- excludingEdges: NSDirectionalRectEdge = []
- ) -> [NSLayoutConstraint] {
+ func pinEdgesToSuperviewMargins(_ edges: PinnableEdges = .all()) -> [NSLayoutConstraint] {
guard let superview = superview else { return [] }
- return pinEdgesToMargins(superview, insets: insets, excludingEdges: excludingEdges)
+ return pinEdges(edges, toMarginsOf: superview)
+ }
+
+ /**
+ Pin all edges to other view layout margins.
+ */
+ func pinEdgesToMarginsOf(_ other: UIView) -> [NSLayoutConstraint] {
+ return pinEdges(.all(), toMarginsOf: other)
}
/**
Pin edges to other view layout margins.
*/
- func pinEdgesToMargins(
- _ other: UIView,
- insets: NSDirectionalEdgeInsets = .zero,
- excludingEdges: NSDirectionalRectEdge = []
- ) -> [NSLayoutConstraint] {
- return pinEdgesTo(other.layoutMarginsGuide, insets: insets, excludingEdges: excludingEdges)
+ func pinEdges(_ edges: PinnableEdges, toMarginsOf other: UIView) -> [NSLayoutConstraint] {
+ return pinEdges(edges, to: other.layoutMarginsGuide)
}
/**
Pin edges to layout guide.
*/
- func pinEdgesTo(
- _ layoutGuide: UILayoutGuide,
- insets: NSDirectionalEdgeInsets = .zero,
- excludingEdges: NSDirectionalRectEdge = []
- ) -> [NSLayoutConstraint] {
- var constraints = [NSLayoutConstraint]()
+ func pinEdges(_ edges: PinnableEdges, to layoutGuide: UILayoutGuide) -> [NSLayoutConstraint] {
+ return edges.inner.map { edge -> NSLayoutConstraint in
+ switch edge {
+ case let .top(inset):
+ return topAnchor.constraint(equalTo: layoutGuide.topAnchor, constant: inset)
- if !excludingEdges.contains(.top) {
- constraints.append(
- topAnchor.constraint(equalTo: layoutGuide.topAnchor, constant: insets.top)
- )
- }
+ case let .bottom(inset):
+ return bottomAnchor.constraint(equalTo: layoutGuide.bottomAnchor, constant: inset)
- if !excludingEdges.contains(.bottom) {
- constraints.append(
- bottomAnchor.constraint(
- equalTo: layoutGuide.bottomAnchor,
- constant: insets.bottom
- )
- )
- }
+ case let .leading(inset):
+ return leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor, constant: inset)
- if !excludingEdges.contains(.leading) {
- constraints.append(
- leadingAnchor.constraint(
- equalTo: layoutGuide.leadingAnchor,
- constant: insets.leading
- )
- )
- }
-
- if !excludingEdges.contains(.trailing) {
- constraints.append(
- trailingAnchor.constraint(
+ case let .trailing(inset):
+ return trailingAnchor.constraint(
equalTo: layoutGuide.trailingAnchor,
- constant: insets.trailing
+ constant: inset
)
- )
+ }
}
-
- return constraints
- }
-
- /**
- Pin horizontal edges to other view edges.
- */
- func pinHorizontalEdgesTo(
- _ other: UIView,
- leadingInset: CGFloat = .zero,
- trailingInset: CGFloat = .zero
- ) -> [NSLayoutConstraint] {
- return pinEdgesTo(
- other,
- insets: NSDirectionalEdgeInsets(
- top: 0,
- leading: leadingInset,
- bottom: 0,
- trailing: trailingInset
- ),
- excludingEdges: [.bottom, .top]
- )
- }
-
- /**
- Pin horizontal edges to other view layout margins.
- */
- func pinHorizontalEdgesToMargins(
- _ other: UIView,
- leadingInset: CGFloat = .zero,
- trailingInset: CGFloat = .zero
- ) -> [NSLayoutConstraint] {
- return pinEdgesToMargins(
- other,
- insets: NSDirectionalEdgeInsets(
- top: 0,
- leading: leadingInset,
- bottom: 0,
- trailing: trailingInset
- ),
- excludingEdges: [.bottom, .top]
- )
}
}
@@ -182,13 +108,12 @@ extension UIView {
view.addSubview(subview)
NSLayoutConstraint.activate {
- subview.pinEdgesToSuperview(
- insets: NSDirectionalEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 24),
- excludingEdges: .bottom
- )
+ // Pin top, leading and trailing edges to superview.
+ subview.pinEdgesToSuperview(.init([.top(8), .leading(16), .trailing(8)]))
+
+ // Pin bottom to safe area layout guide.
subview.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
}
-
```
*/
@resultBuilder enum AutoLayoutBuilder {
@@ -229,3 +154,103 @@ extension NSLayoutConstraint {
activate(builder())
}
}
+
+extension UIView {
+ /**
+ Add subviews using AutoLayout and configure constraints.
+ */
+ func addConstrainedSubviews(
+ _ subviews: [UIView],
+ @AutoLayoutBuilder builder: () -> [NSLayoutConstraint]
+ ) {
+ for subview in subviews {
+ subview.configureForAutoLayout()
+ addSubview(subview)
+ }
+
+ NSLayoutConstraint.activate(builder())
+ }
+
+ /**
+ Add subviews using AutoLayout without configuring constraints.
+ */
+ func addConstrainedSubviews(_ subviews: [UIView]) {
+ addConstrainedSubviews(subviews) {}
+ }
+
+ /**
+ Configure view for AutoLayout by disabling automatic autoresizing mask translation into
+ constraints.
+ */
+ func configureForAutoLayout() {
+ translatesAutoresizingMaskIntoConstraints = false
+ }
+}
+
+/**
+ Struct describing a relationship between AutoLayout anchors.
+ */
+struct PinnableEdges {
+ /**
+ Enum describing each inidividual edge with associated inset value.
+ */
+ enum Edge: Hashable {
+ case top(CGFloat)
+ case bottom(CGFloat)
+ case leading(CGFloat)
+ case trailing(CGFloat)
+
+ var rectEdge: NSDirectionalRectEdge {
+ switch self {
+ case .top:
+ return .top
+ case .bottom:
+ return .bottom
+ case .leading:
+ return .leading
+ case .trailing:
+ return .trailing
+ }
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(rectEdge.rawValue)
+ }
+
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ return lhs.rectEdge == rhs.rectEdge
+ }
+ }
+
+ /**
+ Inner set of `Edge` objects.
+ */
+ var inner: Set<Edge>
+
+ /**
+ Designated initializer.
+ */
+ init(_ edges: Set<Edge>) {
+ inner = edges
+ }
+
+ /**
+ Returns new `PinnableEdges` with the given edge(s) excluded.
+ */
+ func excluding(_ excludeEdges: NSDirectionalRectEdge) -> Self {
+ return Self(inner.filter { !excludeEdges.contains($0.rectEdge) })
+ }
+
+ /**
+ Returns new `PinnableEdges` initialized with four edges and corresponding insets from
+ `NSDirectionalEdgeInsets`.
+ */
+ static func all(_ directionalEdgeInsets: NSDirectionalEdgeInsets = .zero) -> Self {
+ return Self([
+ .top(directionalEdgeInsets.top),
+ .bottom(directionalEdgeInsets.bottom),
+ .leading(directionalEdgeInsets.leading),
+ .trailing(directionalEdgeInsets.trailing),
+ ])
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/ChangeLog/ChangeLogContentView.swift b/ios/MullvadVPN/View controllers/ChangeLog/ChangeLogContentView.swift
new file mode 100644
index 0000000000..edfe3413d4
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/ChangeLog/ChangeLogContentView.swift
@@ -0,0 +1,136 @@
+//
+// ChangeLogContentView.swift
+// MullvadVPN
+//
+// Created by pronebird on 24/03/2023.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+final class ChangeLogContentView: UIView {
+ private let titleLabel: UILabel = {
+ let titleLabel = UILabel()
+ titleLabel.font = .systemFont(ofSize: 24, weight: .bold)
+ titleLabel.numberOfLines = 0
+ titleLabel.textColor = .white
+ titleLabel.allowsDefaultTighteningForTruncation = true
+ titleLabel.lineBreakMode = .byWordWrapping
+ if #available(iOS 14.0, *) {
+ // See: https://stackoverflow.com/q/46200027/351305
+ titleLabel.lineBreakStrategy = []
+ }
+ return titleLabel
+ }()
+
+ private let subheadLabel: UILabel = {
+ let subheadLabel = UILabel()
+ subheadLabel.font = .systemFont(ofSize: 18, weight: .bold)
+ subheadLabel.numberOfLines = 0
+ subheadLabel.textColor = .white
+ subheadLabel.allowsDefaultTighteningForTruncation = true
+ subheadLabel.lineBreakMode = .byWordWrapping
+ if #available(iOS 14.0, *) {
+ // See: https://stackoverflow.com/q/46200027/351305
+ subheadLabel.lineBreakStrategy = []
+ }
+ subheadLabel.text = NSLocalizedString(
+ "CHANGES_IN_THIS_VERSION",
+ tableName: "ChangeLog",
+ value: "Changes in this version:",
+ comment: ""
+ )
+ return subheadLabel
+ }()
+
+ private let textView: UITextView = {
+ let textView = UITextView()
+ textView.backgroundColor = .clear
+ textView.isEditable = false
+ textView.isSelectable = false
+ textView.textContainerInset = UIMetrics.contentLayoutMargins
+ return textView
+ }()
+
+ private let okButton: AppButton = {
+ let button = AppButton(style: .default)
+ button.accessibilityIdentifier = "OkButton"
+ button.setTitle(NSLocalizedString(
+ "OK_BUTTON",
+ tableName: "ChangeLog",
+ value: "Got it",
+ comment: ""
+ ), for: .normal)
+ return button
+ }()
+
+ private let footerContainer: UIView = {
+ let container = UIView()
+ container.layoutMargins = UIMetrics.contentLayoutMargins
+ container.backgroundColor = .secondaryColor
+ return container
+ }()
+
+ var didTapButton: (() -> Void)?
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ backgroundColor = .primaryColor
+ layoutMargins = UIMetrics.contentLayoutMargins
+
+ okButton.addTarget(self, action: #selector(handleButtonTap), for: .touchUpInside)
+
+ addSubviews()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func setApplicationVersion(_ string: String) {
+ titleLabel.text = string
+ }
+
+ func setChangeLogText(_ string: String) {
+ let paragraphStyle = NSMutableParagraphStyle()
+ paragraphStyle.lineHeightMultiple = 1.5
+ paragraphStyle.lineBreakMode = .byWordWrapping
+
+ textView.attributedText = NSAttributedString(
+ string: string,
+ attributes: [
+ .paragraphStyle: paragraphStyle,
+ .font: UIFont.systemFont(ofSize: 18),
+ .foregroundColor: UIColor.white,
+ ]
+ )
+ }
+
+ private func addSubviews() {
+ footerContainer.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
+
+ footerContainer.addConstrainedSubviews([okButton]) {
+ okButton.pinEdgesToSuperviewMargins()
+ }
+
+ addConstrainedSubviews([titleLabel, subheadLabel, textView, footerContainer]) {
+ titleLabel.pinEdgesToSuperviewMargins(.all().excluding(.bottom))
+ subheadLabel.pinEdgesToSuperviewMargins(.init([.leading(0), .trailing(0)]))
+ subheadLabel.topAnchor.constraint(
+ equalToSystemSpacingBelow: titleLabel.bottomAnchor,
+ multiplier: 1
+ )
+
+ textView.topAnchor.constraint(equalTo: subheadLabel.bottomAnchor)
+ textView.pinEdgesToSuperview(.init([.leading(0), .trailing(0)]))
+
+ footerContainer.pinEdgesToSuperview(.all().excluding(.top))
+ footerContainer.topAnchor.constraint(equalTo: textView.bottomAnchor)
+ }
+ }
+
+ @objc private func handleButtonTap() {
+ didTapButton?()
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/ChangeLog/ChangeLogViewController.swift b/ios/MullvadVPN/View controllers/ChangeLog/ChangeLogViewController.swift
new file mode 100644
index 0000000000..2a1279e6fd
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/ChangeLog/ChangeLogViewController.swift
@@ -0,0 +1,49 @@
+//
+// ChangeLogViewController.swift
+// MullvadVPN
+//
+// Created by pronebird on 24/03/2023.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+class ChangeLogViewController: UIViewController, RootContainment {
+ // MARK: - RootContainment
+
+ var preferredHeaderBarPresentation: HeaderBarPresentation {
+ return HeaderBarPresentation(style: .default, showsDivider: false)
+ }
+
+ var prefersHeaderBarHidden: Bool {
+ return false
+ }
+
+ // MARK: - Public
+
+ var onFinish: (() -> Void)?
+
+ func setApplicationVersion(_ string: String) {
+ contentView.setApplicationVersion(string)
+ }
+
+ func setChangeLogText(_ string: String) {
+ contentView.setChangeLogText(string)
+ }
+
+ // MARK: - View lifecycle
+
+ private let contentView = ChangeLogContentView()
+
+ override var preferredStatusBarStyle: UIStatusBarStyle {
+ return .lightContent
+ }
+
+ override func loadView() {
+ view = contentView
+
+ contentView.didTapButton = { [weak self] in
+ self?.onFinish?()
+ }
+ }
+}