Skip to content

Commit

Permalink
Change account expiry text to display 'less than a day'
Browse files Browse the repository at this point in the history
  • Loading branch information
Jon Petersson committed Nov 7, 2023
1 parent 7e9bb04 commit 70f72cd
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 42 deletions.
4 changes: 4 additions & 0 deletions ios/MullvadVPN.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@
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 */; };
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 */; };
Expand Down Expand Up @@ -1523,6 +1524,7 @@
7A42DEC82A05164100B209BE /* SettingsInputCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInputCell.swift; sourceTree = "<group>"; };
7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableSettingsCell.swift; sourceTree = "<group>"; };
7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitorTimings.swift; sourceTree = "<group>"; };
7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiryTests.swift; sourceTree = "<group>"; };
7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTimeLaunch.swift; sourceTree = "<group>"; };
7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootConfiguration.swift; sourceTree = "<group>"; };
7A83C3FE2A55B72E00DFB83A /* MullvadVPNApp.xctestplan */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = MullvadVPNApp.xctestplan; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2456,6 +2458,7 @@
isa = PBXGroup;
children = (
A900E9BF2ACC661900C95F67 /* AccessTokenManager+Stubs.swift */,
7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */,
A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */,
A9CF11FC2A0518E7001D9565 /* AddressCacheTests.swift */,
A900E9BD2ACC654100C95F67 /* APIProxy+Stubs.swift */,
Expand Down Expand Up @@ -4098,6 +4101,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 */,
Expand Down
24 changes: 20 additions & 4 deletions ios/MullvadVPN/Classes/CustomDateComponentsFormatting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,39 @@ 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,
to end: Date,
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 !isLessThanADayLeft(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 isLessThanADayLeft(dateComponents: DateComponents) -> Bool {
let year = dateComponents.year ?? 0
let day = dateComponents.day ?? 0

return (year == 0) && (day == 0)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,42 @@ import Foundation
import MullvadSettings
import MullvadTypes

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: Date(),
to: expiryDate,
unitsStyle: .full
),
now >= triggerDate,
now < expiryDate
else {
return nil
}

return duration
}
}

final class AccountExpiryInAppNotificationProvider: NotificationProvider, InAppNotificationProvider {
private var accountExpiry: Date?
private var accountExpiry = AccountExpiry()
private var tunnelObserver: TunnelBlockObserver?
private var timer: DispatchSourceTimer?

Expand All @@ -38,31 +72,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,
Expand All @@ -83,30 +96,20 @@ 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()
invalidate()
}

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
}

Expand All @@ -127,7 +130,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()
Expand Down
49 changes: 49 additions & 0 deletions ios/MullvadVPNTests/AccountExpiryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// 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)
}

}
4 changes: 2 additions & 2 deletions ios/MullvadVPNTests/CustomDateComponentsFormattingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class CustomDateComponentsFormattingTests: XCTestCase {
unitsStyle: .full
)

XCTAssertEqual(result, "0 days")
XCTAssertEqual(result, "Less than a day")
}

func testLessThanTwoYearsFormatting() throws {
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit 70f72cd

Please sign in to comment.