Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Introduce ISODurationFormatter #4776

Merged
merged 3 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,8 @@
575A8EE32922C5E100936709 /* AsyncTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575A8EE02922C56300936709 /* AsyncTestHelpers.swift */; };
575A8EE52922C9F300936709 /* MockStoreKit2TransactionListenerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575A8EE42922C9F300936709 /* MockStoreKit2TransactionListenerDelegate.swift */; };
575A8EE62922C9F300936709 /* MockStoreKit2TransactionListenerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575A8EE42922C9F300936709 /* MockStoreKit2TransactionListenerDelegate.swift */; };
575F19B42D5A27EF0089A64F /* ISODurationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575F19B32D5A27E70089A64F /* ISODurationFormatter.swift */; };
575F19B62D5A298F0089A64F /* ISODurationFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575F19B52D5A29860089A64F /* ISODurationFormatterTests.swift */; };
5766AA3E283C750300FA6091 /* Operators+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5766AA3D283C750300FA6091 /* Operators+Extensions.swift */; };
5766AA42283C768600FA6091 /* OperatorExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5766AA41283C768600FA6091 /* OperatorExtensionsTests.swift */; };
5766AA56283D4C5400FA6091 /* IgnoreHashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5766AA55283D4C5400FA6091 /* IgnoreHashable.swift */; };
Expand Down Expand Up @@ -1952,6 +1954,8 @@
575A17AA2773A59300AA6F22 /* CurrentTestCaseTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentTestCaseTracker.swift; sourceTree = "<group>"; };
575A8EE02922C56300936709 /* AsyncTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTestHelpers.swift; sourceTree = "<group>"; };
575A8EE42922C9F300936709 /* MockStoreKit2TransactionListenerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreKit2TransactionListenerDelegate.swift; sourceTree = "<group>"; };
575F19B32D5A27E70089A64F /* ISODurationFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ISODurationFormatter.swift; sourceTree = "<group>"; };
575F19B52D5A29860089A64F /* ISODurationFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ISODurationFormatterTests.swift; sourceTree = "<group>"; };
5766AA3D283C750300FA6091 /* Operators+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Operators+Extensions.swift"; sourceTree = "<group>"; };
5766AA41283C768600FA6091 /* OperatorExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatorExtensionsTests.swift; sourceTree = "<group>"; };
5766AA55283D4C5400FA6091 /* IgnoreHashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IgnoreHashable.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3663,6 +3667,7 @@
35272E1A26D0023400F22C3B /* Misc */ = {
isa = PBXGroup;
children = (
575F19B52D5A29860089A64F /* ISODurationFormatterTests.swift */,
576C8A9027D180540058FA6E /* __Snapshots__ */,
37E35B9AC7A350CA2437049D /* ISOPeriodFormatterTests.swift */,
37E35EEE7783629CDE41B70C /* SystemInfoTests.swift */,
Expand Down Expand Up @@ -4545,6 +4550,7 @@
57F3C0CC29B7A0F30004FD7E /* DateAndTime */ = {
isa = PBXGroup;
children = (
575F19B32D5A27E70089A64F /* ISODurationFormatter.swift */,
578DAA472948EEAD001700FD /* Clock.swift */,
37E3567189CF6A746EE3CCC2 /* DateExtensions.swift */,
0313FD40268A506400168386 /* DateProvider.swift */,
Expand Down Expand Up @@ -6311,6 +6317,7 @@
4DBC30962B1DFA97001D33C7 /* StoreKitVersion.swift in Sources */,
57DE807328074C76008D6C6F /* SK2Storefront.swift in Sources */,
57A17727276A721D0052D3A8 /* Set+Extensions.swift in Sources */,
575F19B42D5A27EF0089A64F /* ISODurationFormatter.swift in Sources */,
03C72FBE2D34949600297FEC /* PaywallIconComponent.swift in Sources */,
4DC546272AD44BBE005CDB35 /* EncodedAppleReceipt.swift in Sources */,
37E350C67712B9E054FEF297 /* AttributionData.swift in Sources */,
Expand Down Expand Up @@ -6367,6 +6374,7 @@
2DDF41CF24F6F4C3005BC22D /* ReceiptParsing+TestsWithRealReceipts.swift in Sources */,
57488C2329CB89CC0000EE7E /* OfflineEntitlementsManagerTests.swift in Sources */,
57FDAA962846BDE2009A48F1 /* PurchasesTransactionHandlingTests.swift in Sources */,
575F19B62D5A298F0089A64F /* ISODurationFormatterTests.swift in Sources */,
575A17AB2773A59300AA6F22 /* CurrentTestCaseTracker.swift in Sources */,
351B514326D449C100BD2BD7 /* MockSubscriberAttributesManager.swift in Sources */,
B300E4C026D4371200B22262 /* SKPaymentTransactionExtensionsTests.swift in Sources */,
Expand Down
137 changes: 137 additions & 0 deletions Sources/Misc/DateAndTime/ISODurationFormatter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// ISODurationFormatter.swift
//
// Created by Facundo Menzella on 10/2/25.

import Foundation

/// A representation of an ISO 8601 duration.
///
/// This struct represents both date and time-based components of an ISO 8601 duration string.
/// ISO 8601 durations use the format `PnYnMnWnDTnHnMnS`, where each part is optional:
/// - `P` indicates the duration starts.
/// - `nY` for years.
/// - `nM` for months.
/// - `nW` for weeks.
/// - `nD` for days.
/// - `T` separates the date part from the time part.
/// - `nH` for hours.
/// - `nM` for minutes.
/// - `nS` for seconds.
///
/// Example duration strings:
/// - `"P1Y2M3DT4H5M6S"`: 1 year, 2 months, 3 days, 4 hours, 5 minutes, 6 seconds.
/// - `"P3W"`: 3 weeks.
/// - `"PT15M"`: 15 minutes.
struct ISODuration {
/// The number of years in the duration.
///
/// Example: For `"P1Y"`, this will be `1`.
let years: Int

/// The number of months in the duration.
///
/// Example: For `"P2M"`, this will be `2`.
let months: Int

/// The number of weeks in the duration.
///
/// Weeks will be converted to days if calculating a `TimeInterval`.
/// Example: For `"P3W"`, this will be `3`.
let weeks: Int

/// The number of days in the duration.
///
/// Example: For `"P4D"`, this will be `4`.
let days: Int

/// The number of hours in the duration.
///
/// Example: For `"PT5H"`, this will be `5`.
let hours: Int

/// The number of minutes in the duration.
///
/// Example: For `"PT6M"`, this will be `6`.
let minutes: Int

/// The number of seconds in the duration.
///
/// Example: For `"PT7S"`, this will be `7`.
let seconds: Int
}

@available(iOS 11.2, macOS 10.13.2, tvOS 11.2, *)
enum ISODurationFormatter {

// swiftlint:disable:next line_length
static let pattern = #"([-+]?)P(?:([-+]?\d+)Y)?(?:([-+]?\d+)M)?(?:([-+]?\d+)W)?(?:([-+]?\d+)D)?(?:T(?:([-+]?\d+)H)?(?:([-+]?\d+)M)?(?:([-+]?\d+)S)?)?"#

/// Parses an ISO 8601 duration string and returns an `ISODuration` object.
static func parse(from periodString: String) -> ISODuration? {
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else {
return nil
}

let nsString = periodString as NSString
let match = regex.firstMatch(
in: periodString,
options: [],
range: NSRange(location: 0, length: nsString.length))

guard let match = match else {
print("Failed to parse ISO duration: \(periodString)")
return nil
}

let negate = nsString.substring(with: match.range(at: 1)) == "-" ? -1 : 1

let years = getIntValue(from: nsString, match: match, at: 2) * negate
let months = getIntValue(from: nsString, match: match, at: 3) * negate
let weeks = getIntValue(from: nsString, match: match, at: 4) * negate
let days = getIntValue(from: nsString, match: match, at: 5) * negate
let hours = getIntValue(from: nsString, match: match, at: 6) * negate
let minutes = getIntValue(from: nsString, match: match, at: 7) * negate
let seconds = getIntValue(from: nsString, match: match, at: 8) * negate

return ISODuration(
years: years,
months: months,
weeks: weeks,
days: days,
hours: hours,
minutes: minutes,
seconds: seconds)
}

/// Converts an `ISODuration` object back to an ISO 8601 duration string.
static func string(from duration: ISODuration) -> String {
var result = "P"
if duration.years != 0 { result += "\(duration.years)Y" }
if duration.months != 0 { result += "\(duration.months)M" }
if duration.weeks != 0 { result += "\(duration.weeks)W" }
if duration.days != 0 { result += "\(duration.days)D" }
if duration.hours != 0 || duration.minutes != 0 || duration.seconds != 0 {
result += "T"
if duration.hours != 0 { result += "\(duration.hours)H" }
if duration.minutes != 0 { result += "\(duration.minutes)M" }
if duration.seconds != 0 { result += "\(duration.seconds)S" }
}
return result
}

private static func getIntValue(from nsString: NSString, match: NSTextCheckingResult, at index: Int) -> Int {
guard match.range(at: index).location != NSNotFound else {
return 0
}
return Int(nsString.substring(with: match.range(at: index))) ?? 0
}
}
30 changes: 13 additions & 17 deletions Sources/Misc/DateAndTime/ISOPeriodFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,20 @@ import StoreKit
enum ISOPeriodFormatter {

static func string(fromProductSubscriptionPeriod period: SubscriptionPeriod) -> String {
let unitString = Self.period(fromUnit: period.unit)
let stringResult = "P\(period.value)\(unitString)"
return stringResult
ISODurationFormatter.string(from: period.isoDuration)
}
}

private static func period(fromUnit unit: SubscriptionPeriod.Unit) -> String {
switch unit {
case .day:
return "D"
case .week:
return "W"
case .month:
return "M"
case .year:
return "Y"
@unknown default:
fatalError("New SKProduct.PeriodUnit \(unit) unaccounted for")
}
extension SubscriptionPeriod {
var isoDuration: ISODuration {
ISODuration(
years: unit == .year ? value : 0,
months: unit == .month ? value : 0,
weeks: unit == .week ? value : 0,
days: unit == .day ? value : 0,
hours: 0,
minutes: 0,
seconds: 0
)
}

}
109 changes: 109 additions & 0 deletions Tests/UnitTests/Misc/ISODurationFormatterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// ISODurationTests.swift
//
// Created by Facundo Menzella on 10/2/25.

import Nimble
import XCTest

@testable import RevenueCat

final class ISODurationFormatterTests: TestCase {

func testParseFullDuration() {
let durationString = "P1Y2M3W4DT5H6M7S"
guard let duration = ISODurationFormatter.parse(from: durationString) else {
XCTFail("Failed to parse full duration")
return
}

expect(duration.years) == 1
expect(duration.months) == 2
expect(duration.weeks) == 3
expect(duration.days) == 4
expect(duration.hours) == 5
expect(duration.minutes) == 6
expect(duration.seconds) == 7
}

func testParseDaysOnly() {
let durationString = "P10D"
guard let duration = ISODurationFormatter.parse(from: durationString) else {
XCTFail("Failed to parse days-only duration")
return
}

expect(duration.years) == 0
expect(duration.months) == 0
expect(duration.weeks) == 0
expect(duration.days) == 10
expect(duration.hours) == 0
expect(duration.minutes) == 0
expect(duration.seconds) == 0
}

func testParseWeeksOnly() {
let durationString = "P5W"
guard let duration = ISODurationFormatter.parse(from: durationString) else {
XCTFail("Failed to parse weeks-only duration")
return
}

expect(duration.weeks) == 5
expect(duration.years) == 0
expect(duration.months) == 0
expect(duration.hours) == 0
expect(duration.minutes) == 0
expect(duration.seconds) == 0
expect(duration.days) == 0
}

func testParseTimeOnly() {
let durationString = "PT3H45M20S"
guard let duration = ISODurationFormatter.parse(from: durationString) else {
XCTFail("Failed to parse time-only duration")
return
}

expect(duration.weeks) == 0
expect(duration.years) == 0
expect(duration.months) == 0
expect(duration.hours) == 3
expect(duration.minutes) == 45
expect(duration.seconds) == 20
expect(duration.days) == 0
}

func testStringFromDuration() {
let duration = ISODuration(years: 1, months: 2, weeks: 0, days: 4, hours: 5, minutes: 6, seconds: 7)
let durationString = ISODurationFormatter.string(from: duration)

expect(durationString) == "P1Y2M4DT5H6M7S"
}

func testEmptyDuration() {
let durationString = "P"
let duration = ISODurationFormatter.parse(from: durationString)

expect(duration).toNot(beNil())
expect(duration?.years) == 0
expect(duration?.months) == 0
expect(duration?.days) == 0
expect(duration?.hours) == 0
expect(duration?.minutes) == 0
expect(duration?.seconds) == 0
}

func testInvalidDuration() {
let durationString = "InvalidString"
expect(ISODurationFormatter.parse(from: durationString)).to(beNil())
}
}