Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@ import UIKit

actor DefaultDeviceMetadataProvider: DeviceMetadataProvider {

init(screen: UIScreen, device: UIDevice, bundle: Bundle, keychain: Keychain) {
init(
screen: UIScreen,
device: UIDevice,
bundle: Bundle,
keychain: Keychain,
timeZone: TimeZone = .autoupdatingCurrent
) {
self.screen = screen
self.device = device
self.bundle = bundle
self.keychain = keychain
self.timeZone = timeZone
}

// MARK: - DeviceMetadataProvider
Expand All @@ -28,7 +35,7 @@ actor DefaultDeviceMetadataProvider: DeviceMetadataProvider {
appLanguage: bundle.preferredLocalizations.first!, // swiftlint:disable:this force_unwrapping
appScreenWidth: Int(screen.nativeBounds.width), // Specified in pixels
appScreenHeight: Int(screen.nativeBounds.height),
appTimeZoneOffset: TimeZone.current.secondsFromGMT() / 60,
appTimezoneOffset: -timeZone.secondsFromGMT() / 60, // UTC - local, e.g. -60 for UTC+1
channel: "ios",
threedsSdk: "ios",
platform: "ios"
Expand All @@ -48,6 +55,7 @@ actor DefaultDeviceMetadataProvider: DeviceMetadataProvider {
private let screen: UIScreen
private let device: UIDevice
private let bundle: Bundle
private let timeZone: TimeZone
private let keychain: Keychain

private lazy var machineName: String? = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ struct DeviceMetadata: Encodable, Sendable {
/// Height of the screen in pixels.
let appScreenHeight: Int

/// Time zone offset in minutes.
let appTimeZoneOffset: Int
/// Difference in minutes between UTC and device local time (UTC - local), e.g. -60 for UTC+1.
/// Follows JavaScript `Date.getTimezoneOffset()` and EMVCo browser time zone semantics.
let appTimezoneOffset: Int

/// Device channel. Holds device system name. Legacy field, backend uses it for 3DS SDK
/// selection when `threedsSdk` is not set. Kept for backward compatibility.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ final class DefaultTelemetryService: TelemetryService, @unchecked Sendable {
)
let device: Telemetry.DeviceMetadata = await {
let metadata = await deviceMetadataProvider.deviceMetadata
return .init(language: metadata.appLanguage, model: metadata.model, timeZone: metadata.appTimeZoneOffset)
return .init(language: metadata.appLanguage, model: metadata.model, timeZone: metadata.appTimezoneOffset)
}()
return Telemetry.Metadata(application: application, device: device)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ struct StubDeviceMetadataProvider: DeviceMetadataProvider {
appLanguage: "en",
appScreenWidth: 1,
appScreenHeight: 2,
appTimeZoneOffset: 3,
appTimezoneOffset: 3,
channel: "test",
threedsSdk: "test",
platform: "test"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ struct DefaultHttpConnectorRequestMapperTests {

// Then
// swiftlint:disable:next line_length
let expectedBody = #"{"device":{"appLanguage":"en","appScreenHeight":2,"appScreenWidth":1,"appTimeZoneOffset":3,"channel":"test","id":"id","platform":"test","threedsSdk":"test"}}"#
let expectedBody = #"{"device":{"appLanguage":"en","appScreenHeight":2,"appScreenWidth":1,"appTimezoneOffset":3,"channel":"test","id":"id","platform":"test","threedsSdk":"test"}}"#
#expect(urlRequest.httpBody == Data(expectedBody.utf8))
}

Expand All @@ -81,7 +81,7 @@ struct DefaultHttpConnectorRequestMapperTests {

// Then
// swiftlint:disable:next line_length
let expectedBody = #"{"device":{"appLanguage":"en","appScreenHeight":2,"appScreenWidth":1,"appTimeZoneOffset":3,"channel":"test","id":"id","platform":"test","threedsSdk":"test"},"key":"value"}"#
let expectedBody = #"{"device":{"appLanguage":"en","appScreenHeight":2,"appScreenWidth":1,"appTimezoneOffset":3,"channel":"test","id":"id","platform":"test","threedsSdk":"test"},"key":"value"}"#
#expect(urlRequest.httpBody == Data(expectedBody.utf8))
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// DefaultDeviceMetadataProviderTests.swift
// ProcessOutTests
//
// Created by Andrii Vysotskyi on 06.11.2026.
//

import UIKit
import Testing
@testable @_spi(PO) import ProcessOut

@MainActor
struct DefaultDeviceMetadataProviderTests {

@Test(arguments: [(3600, -60), (-18000, 300), (19800, -330), (0, 0)])
func deviceMetadata_returnsUtcMinusLocalTimezoneOffset(secondsFromGmt: Int, expectedOffset: Int) async throws {
// Given
let timeZone = try #require(TimeZone(secondsFromGMT: secondsFromGmt))
let sut = createProvider(timeZone: timeZone)

// When
let metadata = await sut.deviceMetadata

// Then
#expect(metadata.appTimezoneOffset == expectedOffset)
}

@Test
func deviceMetadata_whenEncodedWithSnakeCase_usesAppTimezoneOffsetKey() async throws {
// Given
let timeZone = try #require(TimeZone(secondsFromGMT: 3600))
let sut = createProvider(timeZone: timeZone)
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase

// When
let data = try encoder.encode(await sut.deviceMetadata)

// Then
let json = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(json["app_timezone_offset"] as? Int == -60)
#expect(json["app_time_zone_offset"] == nil)
}

// MARK: - Private Methods

private func createProvider(timeZone: TimeZone) -> DefaultDeviceMetadataProvider {
DefaultDeviceMetadataProvider(
screen: .main,
device: .current,
bundle: .main,
keychain: Keychain(service: "com.processout.tests"),
timeZone: timeZone
)
}
}
Loading