diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 61a08e84e5b6..300905989385 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -438,6 +438,8 @@ 7A3FD1B82AD54AE60042BEA6 /* TimeServerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BDEB9A2A98F58600F578F2 /* TimeServerProxy.swift */; }; 7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */; }; 7A6B4F592AB8412E00123853 /* TunnelMonitorTimings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */; }; + 7A6F2FA52AFA3CB2006D0856 /* AccountExpiryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */; }; + 7A6F2FA72AFBB9AE006D0856 /* AccountExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */; }; 7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */; }; 7A818F1F29F0305800C7F0F4 /* RootConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */; }; 7A83C3FF2A55B72E00DFB83A /* MullvadVPNApp.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 7A83C3FE2A55B72E00DFB83A /* MullvadVPNApp.xctestplan */; }; @@ -508,6 +510,7 @@ A97F1F472A1F4E1A00ECEFDE /* MullvadTransport.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A97F1F412A1F4E1A00ECEFDE /* MullvadTransport.framework */; }; A97F1F482A1F4E1A00ECEFDE /* MullvadTransport.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A97F1F412A1F4E1A00ECEFDE /* MullvadTransport.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; A97FF5502A0D2FFC00900996 /* NSFileCoordinator+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97FF54F2A0D2FFC00900996 /* NSFileCoordinator+Extensions.swift */; }; + A988A3E22AFE54AC0008D2C7 /* AccountExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */; }; A988DF212ADD293D00D807EF /* RESTTransportStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A1DE782AD5708E0073F689 /* RESTTransportStrategy.swift */; }; A988DF242ADD307200D807EF /* libRelaySelector.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5898D29829017DAC00EB5EBA /* libRelaySelector.a */; }; A988DF262ADE86ED00D807EF /* WireGuardObfuscationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A988DF252ADE86ED00D807EF /* WireGuardObfuscationSettings.swift */; }; @@ -533,7 +536,6 @@ A9A5F9F12ACB05160083449F /* String+Split.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5807E2BF2432038B00F5FF30 /* String+Split.swift */; }; A9A5F9F22ACB05160083449F /* NotificationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C8191729FAA2C400DEB1B4 /* NotificationConfiguration.swift */; }; A9A5F9F32ACB05160083449F /* AccountExpirySystemNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B75402668FD7700DEF7E9 /* AccountExpirySystemNotificationProvider.swift */; }; - A9A5F9F42ACB05160083449F /* AccountExpiryInAppNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58607A4C2947287800BC467D /* AccountExpiryInAppNotificationProvider.swift */; }; A9A5F9F52ACB05160083449F /* RegisteredDeviceInAppNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07CFF1F29F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift */; }; A9A5F9F62ACB05160083449F /* TunnelStatusNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A94AE326CFD945001CB97C /* TunnelStatusNotificationProvider.swift */; }; A9A5F9F72ACB05160083449F /* NotificationProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B26E232943520C00D5980C /* NotificationProviderProtocol.swift */; }; @@ -1533,6 +1535,8 @@ 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInputCell.swift; sourceTree = ""; }; 7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableSettingsCell.swift; sourceTree = ""; }; 7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitorTimings.swift; sourceTree = ""; }; + 7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiryTests.swift; sourceTree = ""; }; + 7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiry.swift; sourceTree = ""; }; 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTimeLaunch.swift; sourceTree = ""; }; 7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootConfiguration.swift; sourceTree = ""; }; 7A83C3FE2A55B72E00DFB83A /* MullvadVPNApp.xctestplan */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = MullvadVPNApp.xctestplan; sourceTree = ""; }; @@ -2396,6 +2400,7 @@ isa = PBXGroup; children = ( 58C8191729FAA2C400DEB1B4 /* NotificationConfiguration.swift */, + 7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */, 587B75402668FD7700DEF7E9 /* AccountExpirySystemNotificationProvider.swift */, 58607A4C2947287800BC467D /* AccountExpiryInAppNotificationProvider.swift */, F07CFF1F29F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift */, @@ -2472,6 +2477,7 @@ isa = PBXGroup; children = ( A900E9BF2ACC661900C95F67 /* AccessTokenManager+Stubs.swift */, + 7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */, A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */, A9CF11FC2A0518E7001D9565 /* AddressCacheTests.swift */, A900E9BD2ACC654100C95F67 /* APIProxy+Stubs.swift */, @@ -4097,7 +4103,6 @@ A9A5F9F12ACB05160083449F /* String+Split.swift in Sources */, A9A5F9F22ACB05160083449F /* NotificationConfiguration.swift in Sources */, A9A5F9F32ACB05160083449F /* AccountExpirySystemNotificationProvider.swift in Sources */, - A9A5F9F42ACB05160083449F /* AccountExpiryInAppNotificationProvider.swift in Sources */, A9A5F9F52ACB05160083449F /* RegisteredDeviceInAppNotificationProvider.swift in Sources */, A9A5F9F62ACB05160083449F /* TunnelStatusNotificationProvider.swift in Sources */, A9A5F9F72ACB05160083449F /* NotificationProviderProtocol.swift in Sources */, @@ -4121,6 +4126,7 @@ A9E0317A2ACB0AE70095D843 /* UIApplication+Stubs.swift in Sources */, A9A5FA062ACB05160083449F /* SimulatorTunnelProviderManager.swift in Sources */, A9A5FA072ACB05160083449F /* SimulatorVPNConnection.swift in Sources */, + 7A6F2FA52AFA3CB2006D0856 /* AccountExpiryTests.swift in Sources */, A9A5FA082ACB05160083449F /* StorePaymentBlockObserver.swift in Sources */, A9E0317C2ACBFC7E0095D843 /* TunnelStore+Stubs.swift in Sources */, A9A5FA092ACB05160083449F /* SendStoreReceiptOperation.swift in Sources */, @@ -4151,6 +4157,7 @@ A9A5FA212ACB05160083449F /* TunnelManagerErrors.swift in Sources */, A9C342C32ACC3EE90045F00E /* RelayCacheTracker+Stubs.swift in Sources */, A9A5FA222ACB05160083449F /* TunnelObserver.swift in Sources */, + A988A3E22AFE54AC0008D2C7 /* AccountExpiry.swift in Sources */, A9E0317F2ACC331C0095D843 /* TunnelStatusBlockObserver.swift in Sources */, A9A5FA232ACB05160083449F /* TunnelState.swift in Sources */, A9A5FA242ACB05160083449F /* TunnelStore.swift in Sources */, @@ -4427,6 +4434,7 @@ F0C6FA812A66E23300F521F0 /* DeleteAccountOperation.swift in Sources */, F07CFF2029F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift in Sources */, 587A01FC23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift in Sources */, + 7A6F2FA72AFBB9AE006D0856 /* AccountExpiry.swift in Sources */, 5819C2172729595500D6EC38 /* SettingsAddDNSEntryCell.swift in Sources */, 7A1A26452A29CEF700B978AA /* RelayFilterViewController.swift in Sources */, 5862805422428EF100F5A6E1 /* TranslucentButtonBlurView.swift in Sources */, diff --git a/ios/MullvadVPN/Classes/CustomDateComponentsFormatting.swift b/ios/MullvadVPN/Classes/CustomDateComponentsFormatting.swift index f0b227fa140a..de1c8610766f 100644 --- a/ios/MullvadVPN/Classes/CustomDateComponentsFormatting.swift +++ b/ios/MullvadVPN/Classes/CustomDateComponentsFormatting.swift @@ -15,8 +15,9 @@ extension CustomDateComponentsFormatting { /// /// The behaviour of that method differs from `DateComponentsFormatter`: /// - /// 1. Intervals of two years or more are formatted in years quantity. - /// 2. Otherwise intervals matching none of the above are formatted in days quantity. + /// 1. Intervals of less than a day return a custom string. + /// 2. Intervals of two years or more are formatted in years quantity. + /// 3. Otherwise intervals matching none of the above are formatted in days quantity. /// static func localizedString( from start: Date, @@ -24,14 +25,29 @@ extension CustomDateComponentsFormatting { calendar: Calendar = Calendar.current, unitsStyle: DateComponentsFormatter.UnitsStyle ) -> String? { - let years = calendar.dateComponents([.year], from: start, to: max(start, end)).year ?? 0 + let dateComponents = calendar.dateComponents([.year, .day], from: start, to: max(start, end)) + + guard !isLessThanOneDay(dateComponents: dateComponents) else { + return NSLocalizedString( + "CUSTOM_DATE_COMPONENTS_FORMATTING_LESS_THAN_ONE_DAY", + value: "Less than a day", + comment: "" + ) + } let formatter = DateComponentsFormatter() formatter.calendar = calendar formatter.unitsStyle = unitsStyle formatter.maximumUnitCount = 1 - formatter.allowedUnits = years >= 2 ? .year : .day + formatter.allowedUnits = (dateComponents.year ?? 0) >= 2 ? .year : .day return formatter.string(from: start, to: max(start, end)) } + + private static func isLessThanOneDay(dateComponents: DateComponents) -> Bool { + let year = dateComponents.year ?? 0 + let day = dateComponents.day ?? 0 + + return (year == 0) && (day == 0) + } } diff --git a/ios/MullvadVPN/Notifications/Notification Providers/AccountExpiry.swift b/ios/MullvadVPN/Notifications/Notification Providers/AccountExpiry.swift new file mode 100644 index 000000000000..e38b0fd3d57f --- /dev/null +++ b/ios/MullvadVPN/Notifications/Notification Providers/AccountExpiry.swift @@ -0,0 +1,43 @@ +// +// AccountExpiry.swift +// MullvadVPN +// +// Created by Jon Petersson on 2023-11-08. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +struct AccountExpiry { + var expiryDate: Date? + + var triggerDate: Date? { + guard let expiryDate else { return nil } + + return Calendar.current.date( + byAdding: .day, + value: -NotificationConfiguration.closeToExpiryTriggerInterval, + to: expiryDate + ) + } + + var formattedDuration: String? { + let now = Date() + + guard + let expiryDate, + let triggerDate, + let duration = CustomDateComponentsFormatting.localizedString( + from: now, + to: expiryDate, + unitsStyle: .full + ), + now >= triggerDate, + now < expiryDate + else { + return nil + } + + return duration + } +} diff --git a/ios/MullvadVPN/Notifications/Notification Providers/AccountExpiryInAppNotificationProvider.swift b/ios/MullvadVPN/Notifications/Notification Providers/AccountExpiryInAppNotificationProvider.swift index 4412fd5b3a87..04658f0da977 100644 --- a/ios/MullvadVPN/Notifications/Notification Providers/AccountExpiryInAppNotificationProvider.swift +++ b/ios/MullvadVPN/Notifications/Notification Providers/AccountExpiryInAppNotificationProvider.swift @@ -11,7 +11,7 @@ import MullvadSettings import MullvadTypes final class AccountExpiryInAppNotificationProvider: NotificationProvider, InAppNotificationProvider { - private var accountExpiry: Date? + private var accountExpiry = AccountExpiry() private var tunnelObserver: TunnelBlockObserver? private var timer: DispatchSourceTimer? @@ -38,31 +38,10 @@ final class AccountExpiryInAppNotificationProvider: NotificationProvider, InAppN // MARK: - InAppNotificationProvider var notificationDescriptor: InAppNotificationDescriptor? { - let now = Date() - guard let accountExpiry, let triggerDate, now >= triggerDate, - now < accountExpiry - else { + guard let duration = accountExpiry.formattedDuration else { return nil } - let formatter = DateComponentsFormatter() - formatter.unitsStyle = .full - formatter.allowedUnits = [.minute, .hour, .day] - formatter.maximumUnitCount = 1 - - let duration: String? - if accountExpiry.timeIntervalSince(now) < .minutes(1) { - duration = NSLocalizedString( - "ACCOUNT_EXPIRY_INAPP_NOTIFICATION_LESS_THAN_ONE_MINUTE", - value: "Less than a minute", - comment: "" - ) - } else { - duration = formatter.string(from: now, to: accountExpiry) - } - - guard let duration else { return nil } - return InAppNotificationDescriptor( identifier: identifier, style: .warning, @@ -83,16 +62,6 @@ final class AccountExpiryInAppNotificationProvider: NotificationProvider, InAppN // MARK: - Private - private var triggerDate: Date? { - guard let accountExpiry else { return nil } - - return Calendar.current.date( - byAdding: .day, - value: -NotificationConfiguration.closeToExpiryTriggerInterval, - to: accountExpiry - ) - } - private func invalidate(deviceState: DeviceState) { updateExpiry(deviceState: deviceState) updateTimer() @@ -100,13 +69,13 @@ final class AccountExpiryInAppNotificationProvider: NotificationProvider, InAppN } private func updateExpiry(deviceState: DeviceState) { - accountExpiry = deviceState.accountData?.expiry + accountExpiry.expiryDate = deviceState.accountData?.expiry } private func updateTimer() { timer?.cancel() - guard let triggerDate else { + guard let triggerDate = accountExpiry.triggerDate else { return } @@ -127,7 +96,7 @@ final class AccountExpiryInAppNotificationProvider: NotificationProvider, InAppN } private func timerDidFire() { - let shouldCancelTimer = accountExpiry.map { $0 <= Date() } ?? true + let shouldCancelTimer = accountExpiry.expiryDate.map { $0 <= Date() } ?? true if shouldCancelTimer { timer?.cancel() diff --git a/ios/MullvadVPNTests/AccountExpiryTests.swift b/ios/MullvadVPNTests/AccountExpiryTests.swift new file mode 100644 index 000000000000..cb06ab92783d --- /dev/null +++ b/ios/MullvadVPNTests/AccountExpiryTests.swift @@ -0,0 +1,48 @@ +// +// AccountExpiryTests.swift +// MullvadVPNTests +// +// Created by Jon Petersson on 2023-11-07. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import XCTest + +class AccountExpiryTests: XCTestCase { + func testNoDateReturnsNoDuration() { + let accountExpiry = AccountExpiry() + XCTAssertNil(accountExpiry.formattedDuration) + } + + func testDateNowReturnsNoDuration() { + let accountExpiry = AccountExpiry(expiryDate: Date()) + XCTAssertNil(accountExpiry.formattedDuration) + } + + func testDateInPastReturnsNoDuration() { + let accountExpiry = AccountExpiry(expiryDate: Date().addingTimeInterval(-10)) + XCTAssertNil(accountExpiry.formattedDuration) + } + + func testDateWithinTriggerIntervalReturnsDuration() { + let date = Calendar.current.date( + byAdding: .day, + value: NotificationConfiguration.closeToExpiryTriggerInterval - 1, + to: Date() + ) + + let accountExpiry = AccountExpiry(expiryDate: date) + XCTAssertNotNil(accountExpiry.formattedDuration) + } + + func testDateNotWithinTriggerIntervalReturnsNoDuration() { + let date = Calendar.current.date( + byAdding: .day, + value: NotificationConfiguration.closeToExpiryTriggerInterval + 1, + to: Date() + ) + + let accountExpiry = AccountExpiry(expiryDate: date) + XCTAssertNil(accountExpiry.formattedDuration) + } +} diff --git a/ios/MullvadVPNTests/CustomDateComponentsFormattingTests.swift b/ios/MullvadVPNTests/CustomDateComponentsFormattingTests.swift index 58cb17d685c1..8c0a5a30c27a 100644 --- a/ios/MullvadVPNTests/CustomDateComponentsFormattingTests.swift +++ b/ios/MullvadVPNTests/CustomDateComponentsFormattingTests.swift @@ -38,7 +38,7 @@ class CustomDateComponentsFormattingTests: XCTestCase { unitsStyle: .full ) - XCTAssertEqual(result, "0 days") + XCTAssertEqual(result, "Less than a day") } func testLessThanTwoYearsFormatting() throws { @@ -71,7 +71,7 @@ class CustomDateComponentsFormattingTests: XCTestCase { unitsStyle: .full ) - XCTAssertEqual(result, "0 days") + XCTAssertEqual(result, "Less than a day") } private func makeDateRange(addingComponents dateComponents: DateComponents) -> (Date, Date) {