diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLoggerPlatformProvider.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLoggerPlatformProvider.swift index 72f10bc26..ee85be188 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLoggerPlatformProvider.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLoggerPlatformProvider.swift @@ -51,6 +51,15 @@ final class OSLoggerPlatformProvider: ILoggerPlatformProvider { static let deviceManufacturer = "Apple" static let unknown = "unknown" static let osBuildName = "kern.osversion" + static let xcodeVersionInfoKey = "DTXcode" + static let xcodeVersionAttribute = "xcode_version" + static let xcodeBuildInfoKey = "DTXcodeBuild" + static let xcodeBuildAttribute = "xcode_build" + /// Catalyst bundles declare their floor as `LSMinimumSystemVersion`; iOS uses + /// `MinimumOSVersion`. First match wins. + static let minimumOSVersionInfoKeys = ["MinimumOSVersion", "LSMinimumSystemVersion"] + static let minimumOSVersionAttribute = "minimum_os_version" + static let applePlatformAttribute = "apple_platform" static let disabledLogLevel = "NONE" static let crashDirectoryComponents = ["onesignal", "logger", "crashes"] @@ -107,15 +116,17 @@ final class OSLoggerPlatformProvider: ILoggerPlatformProvider { let osBuildId = OSLoggerPlatformProvider.systemValue(named: Constants.osBuildName) let sdkWrapper = OneSignalWrapper.sdkType let sdkWrapperVersion = OneSignalWrapper.sdkVersion + /// Nil on iOS: this describes the *host app's* Kotlin stack, and an iOS app is + /// not a Kotlin host. The shared module's own provenance rides on + /// `ossdk.kmp_version` instead. let kotlinVersion: String? = nil + + /// Nil on iOS: there is no runtime API for the Swift language version, and anything + /// derivable would just re-encode `xcode_version` less precisely. let swiftVersion: String? = nil - let additionalVersionAttributes: [String: String] = { - #if targetEnvironment(macCatalyst) - return ["apple_platform": "mac_catalyst"] - #else - return [:] - #endif - }() + + let additionalVersionAttributes: [String: String] = + OSLoggerPlatformProvider.hostBuildAttributes() var enabledFeatureFlags: [String] { featureFlagsProvider() } @@ -189,6 +200,63 @@ final class OSLoggerPlatformProvider: ILoggerPlatformProvider { let apiBaseUrl = OS_API_SERVER_URL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + /// Toolchain and deployment target of the running executable, read from its + /// `Info.plist`. Read at runtime rather than with `#if swift(...)` because we ship a + /// prebuilt XCFramework — a compile-time check would describe OneSignal's build + /// machine, identically for every customer. + /// + /// `minimum_os_version` is what the host *commits to* supporting, which is the fact + /// that gates raising our own deployment target. The OS actually running is separate + /// (`os.name` / `os.version` / `os.build_id`) and routinely diverges from it. + static func hostBuildAttributes( + infoDictionary: [String: Any]? = Bundle.main.infoDictionary + ) -> [String: String] { + var attributes: [String: String] = [:] + #if targetEnvironment(macCatalyst) + attributes[Constants.applePlatformAttribute] = "mac_catalyst" + #endif + if let xcodeVersion = hostXcodeVersion(infoDictionary: infoDictionary) { + attributes[Constants.xcodeVersionAttribute] = xcodeVersion + } + if let xcodeBuild = infoValue(Constants.xcodeBuildInfoKey, in: infoDictionary) { + attributes[Constants.xcodeBuildAttribute] = xcodeBuild + } + if let minimumOSVersion = Constants.minimumOSVersionInfoKeys + .lazy + .compactMap({ infoValue($0, in: infoDictionary) }) + .first { + attributes[Constants.minimumOSVersionAttribute] = minimumOSVersion + } + return attributes + } + + private static func infoValue(_ key: String, in infoDictionary: [String: Any]?) -> String? { + guard let value = infoDictionary?[key] as? String, !value.isEmpty else { + return nil + } + return value + } + + static func hostXcodeVersion( + infoDictionary: [String: Any]? = Bundle.main.infoDictionary + ) -> String? { + guard let raw = infoDictionary?[Constants.xcodeVersionInfoKey] as? String else { + return nil + } + return decodeXcodeVersion(raw) + } + + /// `DTXcode` packs major/minor/patch into digits: `"2620"` is 26.2, `"0900"` is 9.0. + static func decodeXcodeVersion(_ raw: String) -> String? { + guard let packed = Int(raw.trimmingCharacters(in: .whitespaces)), packed > 0 else { + return nil + } + let major = packed / 100 + let minor = (packed / 10) % 10 + let patch = packed % 10 + return patch == 0 ? "\(major).\(minor)" : "\(major).\(minor).\(patch)" + } + private static func systemValue(named name: String) -> String { var size = 0 guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift index 0b2f2c433..3515cd0dc 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift @@ -428,6 +428,67 @@ final class OSLoggerAdaptersTests: XCTestCase { } } +/// Host build metadata read from the app's `Info.plist`, kept separate from the +/// adapter tests above so neither class outgrows the lint limit. +final class OSLoggerHostBuildAttributesTests: XCTestCase { + func testDecodeXcodeVersionUnpacksDTXcodeDigits() { + XCTAssertEqual(OSLoggerPlatformProvider.decodeXcodeVersion("2620"), "26.2") + XCTAssertEqual(OSLoggerPlatformProvider.decodeXcodeVersion("1600"), "16.0") + XCTAssertEqual(OSLoggerPlatformProvider.decodeXcodeVersion("0900"), "9.0") + XCTAssertEqual(OSLoggerPlatformProvider.decodeXcodeVersion("1632"), "16.3.2") + XCTAssertNil(OSLoggerPlatformProvider.decodeXcodeVersion("")) + XCTAssertNil(OSLoggerPlatformProvider.decodeXcodeVersion("not-a-number")) + } + + func testHostBuildAttributesMapsInfoPlistKeys() { + let attributes = OSLoggerPlatformProvider.hostBuildAttributes(infoDictionary: [ + "DTXcode": "2620", + "DTXcodeBuild": "17C52", + "MinimumOSVersion": "12.0", + // Present in every built Info.plist but deliberately not emitted: each is + // either constant across all apps or derivable from the fields above. + "DTCompiler": "com.apple.compilers.llvm.clang.1_0", + "DTPlatformName": "iphonesimulator", + "DTPlatformVersion": "26.2", + "DTPlatformBuild": "23C53", + "DTSDKName": "iphonesimulator26.2", + "DTSDKBuild": "23C53" + ]) + + XCTAssertEqual(attributes["xcode_version"], "26.2") + XCTAssertEqual(attributes["xcode_build"], "17C52") + // The host's declared deployment target, not the OS it happens to run on. + XCTAssertEqual(attributes["minimum_os_version"], "12.0") + XCTAssertEqual(attributes.count, 3) + } + + func testHostBuildAttributesFallsBackToCatalystMinimumSystemVersion() { + let attributes = OSLoggerPlatformProvider.hostBuildAttributes(infoDictionary: [ + "LSMinimumSystemVersion": "13.1" + ]) + + XCTAssertEqual(attributes["minimum_os_version"], "13.1") + } + + func testHostBuildAttributesPrefersMinimumOSVersionOverCatalystKey() { + let attributes = OSLoggerPlatformProvider.hostBuildAttributes(infoDictionary: [ + "MinimumOSVersion": "12.0", + "LSMinimumSystemVersion": "13.1" + ]) + + XCTAssertEqual(attributes["minimum_os_version"], "12.0") + } + + func testHostBuildAttributesOmitsBlankAndMissingValues() { + let attributes = OSLoggerPlatformProvider.hostBuildAttributes(infoDictionary: [ + "DTXcodeBuild": "", + "MinimumOSVersion": "" + ]) + + XCTAssertTrue(attributes.isEmpty) + } +} + private final class LoggerAdapterListener: NSObject, OSLogListener { var levels: [ONE_S_LOG_LEVEL] = []