Skip to content
Open
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
115 changes: 115 additions & 0 deletions Sources/OpenFeature/DeviceInfoAttributeDecorator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#if os(iOS)
import Foundation
import UIKit

/**
Helper class to produce device information attributes

The values appended to the attribute come primarily from the Bundle or UIDevice API

AppInfo contains:
- version: the version name of the app.
- build: the version code of the app.
- namespace: the package name of the app.

DeviceInfo contains:
- manufacturer: the manufacturer of the device.
- model: the model of the device.
- type: the type of the device.

OsInfo contains:
- name: the name of the OS.
- version: the version of the OS.

Locale contains:
- locale: the locale of the device.
- preferred_languages: the preferred languages of the device.

The attributes are only updated when the class is initialized and then static.
*/
public class DeviceInfoAttributeDecorator {
private let staticAttribute: Value

/// - Parameters:
/// - withDeviceInfo: If true, includes device hardware information
/// - withAppInfo: If true, includes application metadata
/// - withOsInfo: If true, includes operating system information
/// - withLocale: If true, includes locale and language preferences
public init(
withDeviceInfo: Bool = false,
withAppInfo: Bool = false,
withOsInfo: Bool = false,
withLocale: Bool = false
) {
var attributes: [String: Value] = [:]

if withDeviceInfo {
let device = UIDevice.current

attributes["device"] = .structure([
"manufacturer": .string("Apple"),
"model": .string(Self.getDeviceModelIdentifier()),
"type": .string(device.model),
])
}

if withAppInfo {
let currentVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
let currentBuild: String = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
let bundleId = Bundle.main.bundleIdentifier ?? ""

attributes["app"] = .structure([
"version": .string(currentVersion),
"build": .string(currentBuild),
"namespace": .string(bundleId),
])
}

if withOsInfo {
let device = UIDevice.current

attributes["os"] = .structure([
"name": .string(device.systemName),
"version": .string(device.systemVersion),
])
}

if withLocale {
let locale = Locale.current
let preferredLanguages = Locale.preferredLanguages

// Top level fields
attributes["locale"] = .string(locale.identifier) // Locale identifier (e.g., "en_US")
attributes["preferred_languages"] = .list(preferredLanguages.map { lang in
.string(lang)
})
}

self.staticAttribute = .structure(attributes)
}

/// Returns an attribute where values are decorated (appended) according to the configuration of the `DeviceInfoAttributeDecorator`.
/// Values provided in the `attributes` parameter take precedence over those appended by this class.
public func decorated(attributes attributesToDecorate: [String: Value]) -> [String: Value] {
var result = self.staticAttribute.asStructure() ?? [:]
attributesToDecorate.forEach { (key: String, value: Value) in
result[key] = value
}
return result
}


private static func getDeviceModelIdentifier() -> String {
var systemInfo = utsname()
uname(&systemInfo)
let machineMirror = Mirror(reflecting: systemInfo.machine)
let identifier = machineMirror.children
.compactMap { element in element.value as? Int8 }
.filter { $0 != 0 }
.map {
Character(UnicodeScalar(UInt8($0)))
}
return String(identifier)
}
}
#endif
67 changes: 67 additions & 0 deletions Tests/OpenFeatureTests/DeviceInfoAttributeDecoratorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import XCTest

@testable import OpenFeature

final class DeviceInfoAttributeDecoratorTests: XCTestCase {
func testEmptyConstructMakesNoOp() {
let result = DeviceInfoAttributeDecorator().decorated(attributes: [:])
XCTAssertEqual(result.count, 0)
}

func testAddDeviceInfo() {
let result = DeviceInfoAttributeDecorator(withDeviceInfo: true).decorated(attributes: [:])
XCTAssertEqual(result.count, 1)
XCTAssertNotNil(result["device"])
XCTAssertNotNil(result["device"]?.asStructure()?["model"])
XCTAssertNotNil(result["device"]?.asStructure()?["type"])
XCTAssertNotNil(result["device"]?.asStructure()?["manufacturer"])
}

func testAddLocale() {
let result = DeviceInfoAttributeDecorator(withLocale: true).decorated(attributes: [:])
XCTAssertEqual(result.count, 2)
XCTAssertNotNil(result["locale"])
XCTAssertNotNil(result["preferred_languages"])
}

func testAddAppInfo() {
let result = DeviceInfoAttributeDecorator(withAppInfo: true).decorated(attributes: [:])
XCTAssertEqual(result.count, 1)
XCTAssertNotNil(result["app"])
XCTAssertNotNil(result["app"]?.asStructure()?["version"])
XCTAssertNotNil(result["app"]?.asStructure()?["build"])
XCTAssertNotNil(result["app"]?.asStructure()?["namespace"])
}

func testAddOsInfo() {
let result = DeviceInfoAttributeDecorator(withOsInfo: true).decorated(attributes: [:])
XCTAssertEqual(result.count, 1)
XCTAssertNotNil(result["os"])
XCTAssertNotNil(result["os"]?.asStructure()?["name"])
XCTAssertNotNil(result["os"]?.asStructure()?["version"])
}

func testAppendsData() {
let result = DeviceInfoAttributeDecorator(
withDeviceInfo: true
).decorated(attributes: ["my_key": .double(42.0)])
XCTAssertEqual(result.count, 2)
XCTAssertEqual(result["my_key"]?.asDouble(), 42.0)
XCTAssertNotNil(result["device"])
}

func testCombinedAttributes() {
let result = DeviceInfoAttributeDecorator(
withDeviceInfo: true,
withAppInfo: true,
withOsInfo: true,
withLocale: true
).decorated(attributes: [:])

XCTAssertEqual(result.count, 5)
XCTAssertNotNil(result["device"])
XCTAssertNotNil(result["app"])
XCTAssertNotNil(result["os"])
XCTAssertNotNil(result["locale"])
}
}
Loading