From c5c9687381d49bcd308c483aa6927bcde3e24331 Mon Sep 17 00:00:00 2001 From: Daymon Date: Fri, 10 Oct 2025 17:23:53 -0500 Subject: [PATCH 01/10] Fix fractional seconds bug --- .../Types/Internal/ProtoDuration.swift | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift index 1dac21d6429..d4af71b0346 100644 --- a/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift +++ b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift @@ -107,6 +107,40 @@ extension ProtoDuration: Decodable { } self.seconds = secs - self.nanos = nanos + self.nanos = fractionalSecondsToNanoseconds(nanos, digits: nanoseconds.count) } } + +/// Cached powers of 10 for quickly mapping fractional seconds. +private let pow10: [Int32] = [ + 1, 10, 100, 1000, 10000, 100_000, + 1_000_000, 10_000_000, 100_000_000, 1_000_000_000, +] + +/// Converts a fractional second representing a nanosecond to a valid nanosecond value. +/// +/// ```swift +/// // 0.123456 +/// XCTAssertEqual( +/// fractionalSecondsToNanoseconds(123456, 6), +/// 123456000 +/// ) +/// +/// // 0.000123456 +/// XCTAssertEqual( +/// fractionalSecondsToNanoseconds(123456, 9), +/// 123456 +/// ) +/// +/// // 0.123456789 +/// XCTAssertEqual( +/// fractionalSecondsToNanoseconds(123456789, 9), +/// 123456789 +/// ) +/// ``` +private func fractionalSecondsToNanoseconds(_ value: Int32, digits: Int) -> Int32 { + precondition(digits >= 0 && digits <= 9, "A nanosecond value must fit within 0..9 digits") + precondition(value >= 0, "A nanosecond value must be positive") + + return Int32(truncatingIfNeeded: value) &* pow10[9 - digits] +} From dd934c38c6814c55dff75b4af9f4ac956e7e9318 Mon Sep 17 00:00:00 2001 From: Daymon Date: Fri, 10 Oct 2025 17:25:50 -0500 Subject: [PATCH 02/10] Add XCTAssertContains util --- .../Tests/Unit/TestUtilities/XCTUtil.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 FirebaseAI/Tests/Unit/TestUtilities/XCTUtil.swift diff --git a/FirebaseAI/Tests/Unit/TestUtilities/XCTUtil.swift b/FirebaseAI/Tests/Unit/TestUtilities/XCTUtil.swift new file mode 100644 index 00000000000..33ef11de6a7 --- /dev/null +++ b/FirebaseAI/Tests/Unit/TestUtilities/XCTUtil.swift @@ -0,0 +1,30 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest + +/// Asserts that a string contains another string. +/// +/// ```swift +/// XCTAssertContains("my name is", "name") +/// ``` +/// +/// - Parameters: +/// - string: The source string that should contain the other. +/// - contains: The string that should be contained in the source string. +func XCTAssertContains(_ string: String, _ contains: String) { + if !string.contains(contains) { + XCTFail("(\"\(string)\") does not contain (\"\(contains)\")") + } +} From 04ea411231d03bf6aaabbf6317820712137fb778 Mon Sep 17 00:00:00 2001 From: Daymon Date: Fri, 10 Oct 2025 17:26:00 -0500 Subject: [PATCH 03/10] Add tests for ProtoDuration --- .../Tests/Unit/Types/ProtoDurationTests.swift | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift diff --git a/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift b/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift new file mode 100644 index 00000000000..bddb50eef1d --- /dev/null +++ b/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift @@ -0,0 +1,99 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest + +@testable import FirebaseAI + +final class ProtoDurationTests: XCTestCase { + let decoder = JSONDecoder() + + private func decodeProtoDuration(_ jsonString: String) throws -> ProtoDuration { + let escapedString = "\"\(jsonString)\"" + let jsonData = try XCTUnwrap(escapedString.data(using: .utf8)) + + return try decoder.decode(ProtoDuration.self, from: jsonData) + } + + private func expectDecodeFailure(_ jsonString: String) throws -> DecodingError.Context? { + do { + let _ = try decodeProtoDuration(jsonString) + XCTFail("Expected decoding to fail") + return nil + } catch { + let decodingError = try XCTUnwrap(error as? DecodingError) + guard case let .dataCorrupted(dataCorrupted) = decodingError else { + XCTFail("Error was not a data corrupted error") + return nil + } + + return dataCorrupted + } + } + + func testDecodeProtoDuration_standardDuration() throws { + let duration = try decodeProtoDuration("120.000000123s") + XCTAssertEqual(duration.seconds, 120) + XCTAssertEqual(duration.nanos, 123) + + XCTAssertEqual(duration.timeInterval, 120.000000123) + } + + func testDecodeProtoDuration_withoutNanoseconds() throws { + let duration = try decodeProtoDuration("120s") + XCTAssertEqual(duration.seconds, 120) + XCTAssertEqual(duration.nanos, 0) + + XCTAssertEqual(duration.timeInterval, 120) + } + + func testDecodeProtoDuration_maxNanosecondDigits() throws { + let duration = try decodeProtoDuration("15.123456789s") + XCTAssertEqual(duration.seconds, 15) + XCTAssertEqual(duration.nanos, 123_456_789) + + XCTAssertEqual(duration.timeInterval, 15.123456789) + } + + func testDecodeProtoDuration_withMilliseconds() throws { + let duration = try decodeProtoDuration("15.123s") + XCTAssertEqual(duration.seconds, 15) + XCTAssertEqual(duration.nanos, 123_000_000) + + XCTAssertEqual(duration.timeInterval, 15.123000000) + } + + func testDecodeProtoDuration_invalidSeconds() throws { + guard let error = try expectDecodeFailure("invalid.123s") else { return } + XCTAssertContains(error.debugDescription, "Invalid proto duration seconds") + } + + func testDecodeProtoDuration_invalidNanoseconds() throws { + guard let error = try expectDecodeFailure("123.invalid") else { return } + XCTAssertContains(error.debugDescription, "Invalid proto duration nanoseconds") + } + + func testDecodeProtoDuration_tooManyDecimals() throws { + guard let error = try expectDecodeFailure("123.45.67") else { return } + XCTAssertContains(error.debugDescription, "Invalid proto duration string") + } + + func testDecodeProtoDuration_withoutSuffix() throws { + let duration = try decodeProtoDuration("123.456") + XCTAssertEqual(duration.seconds, 123) + XCTAssertEqual(duration.nanos, 456_000_000) + + XCTAssertEqual(duration.timeInterval, 123.456) + } +} From bb069c452f8548b910ec6bb36044bf640856b56c Mon Sep 17 00:00:00 2001 From: Daymon Date: Fri, 10 Oct 2025 17:26:45 -0500 Subject: [PATCH 04/10] Add changelog entry --- FirebaseAI/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index 4ee5c21b9a6..f2335e2f6ad 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased - [fixed] Fixed various links in the Live API doc comments not mapping correctly. +- [fixed] Fixed minor translation issue for nanosecond conversion when receiving + `LiveServerGoingAwayNotice`. (#????) # 12.4.0 - [feature] Added support for the URL context tool, which allows the model to access content From f4e097093820c255578541711d1d08be981e48df Mon Sep 17 00:00:00 2001 From: Daymon Date: Fri, 10 Oct 2025 17:30:21 -0500 Subject: [PATCH 05/10] Update PR number --- FirebaseAI/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index f2335e2f6ad..e9939f869e9 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,7 +1,7 @@ # Unreleased - [fixed] Fixed various links in the Live API doc comments not mapping correctly. - [fixed] Fixed minor translation issue for nanosecond conversion when receiving - `LiveServerGoingAwayNotice`. (#????) + `LiveServerGoingAwayNotice`. (#15410) # 12.4.0 - [feature] Added support for the URL context tool, which allows the model to access content From 40408ae5245300ba04c4e68811303319d8ade007 Mon Sep 17 00:00:00 2001 From: Daymon Date: Mon, 13 Oct 2025 11:02:03 -0500 Subject: [PATCH 06/10] Remove precondition --- FirebaseAI/Sources/Types/Internal/ProtoDuration.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift index d4af71b0346..49948aa33ea 100644 --- a/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift +++ b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift @@ -119,6 +119,8 @@ private let pow10: [Int32] = [ /// Converts a fractional second representing a nanosecond to a valid nanosecond value. /// +/// It's expected that both parameters are positive and that `digits` fits within 9 digits. +/// /// ```swift /// // 0.123456 /// XCTAssertEqual( @@ -139,8 +141,5 @@ private let pow10: [Int32] = [ /// ) /// ``` private func fractionalSecondsToNanoseconds(_ value: Int32, digits: Int) -> Int32 { - precondition(digits >= 0 && digits <= 9, "A nanosecond value must fit within 0..9 digits") - precondition(value >= 0, "A nanosecond value must be positive") - return Int32(truncatingIfNeeded: value) &* pow10[9 - digits] } From 5acb2e70a7f2b6802fc6b4a88532e6184e3a31b7 Mon Sep 17 00:00:00 2001 From: Daymon Date: Mon, 13 Oct 2025 11:18:35 -0500 Subject: [PATCH 07/10] Add guards for previous preconditions --- FirebaseAI/Sources/Types/Internal/ProtoDuration.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift index 49948aa33ea..07658da11fc 100644 --- a/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift +++ b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift @@ -141,5 +141,10 @@ private let pow10: [Int32] = [ /// ) /// ``` private func fractionalSecondsToNanoseconds(_ value: Int32, digits: Int) -> Int32 { + // preconditions we expect to be true, but we return a zero value instead of crashing + guard value >= 0, digits >= 0, digits <= 9 else { + return 0 + } + return Int32(truncatingIfNeeded: value) &* pow10[9 - digits] } From 87bc20608de6e44fee1526731161858a27de79c2 Mon Sep 17 00:00:00 2001 From: Daymon Date: Mon, 13 Oct 2025 11:24:05 -0500 Subject: [PATCH 08/10] Parse to Double and multiply instead --- .../Types/Internal/ProtoDuration.swift | 44 ++----------------- 1 file changed, 3 insertions(+), 41 deletions(-) diff --git a/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift index 07658da11fc..29490c4649b 100644 --- a/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift +++ b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift @@ -94,10 +94,10 @@ extension ProtoDuration: Decodable { )) } - guard let nanos = Int32(nanoseconds) else { + guard let fractionalSeconds = Double("0.\(nanoseconds)") else { AILog.warning( code: .decodedInvalidProtoDurationNanoseconds, - "Failed to parse the nanoseconds to an Int32: \(nanoseconds)." + "Failed to parse the nanoseconds to a Double: \(nanoseconds)." ) throw DecodingError.dataCorrupted(.init( @@ -107,44 +107,6 @@ extension ProtoDuration: Decodable { } self.seconds = secs - self.nanos = fractionalSecondsToNanoseconds(nanos, digits: nanoseconds.count) + self.nanos = Int32(fractionalSeconds * 1_000_000_000) } } - -/// Cached powers of 10 for quickly mapping fractional seconds. -private let pow10: [Int32] = [ - 1, 10, 100, 1000, 10000, 100_000, - 1_000_000, 10_000_000, 100_000_000, 1_000_000_000, -] - -/// Converts a fractional second representing a nanosecond to a valid nanosecond value. -/// -/// It's expected that both parameters are positive and that `digits` fits within 9 digits. -/// -/// ```swift -/// // 0.123456 -/// XCTAssertEqual( -/// fractionalSecondsToNanoseconds(123456, 6), -/// 123456000 -/// ) -/// -/// // 0.000123456 -/// XCTAssertEqual( -/// fractionalSecondsToNanoseconds(123456, 9), -/// 123456 -/// ) -/// -/// // 0.123456789 -/// XCTAssertEqual( -/// fractionalSecondsToNanoseconds(123456789, 9), -/// 123456789 -/// ) -/// ``` -private func fractionalSecondsToNanoseconds(_ value: Int32, digits: Int) -> Int32 { - // preconditions we expect to be true, but we return a zero value instead of crashing - guard value >= 0, digits >= 0, digits <= 9 else { - return 0 - } - - return Int32(truncatingIfNeeded: value) &* pow10[9 - digits] -} From 03e02079a3023af203b54f21b6a49bdfe3aecf0f Mon Sep 17 00:00:00 2001 From: Daymon Date: Mon, 13 Oct 2025 12:18:22 -0500 Subject: [PATCH 09/10] Use accuracy for comparisons --- FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift b/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift index bddb50eef1d..afe33740715 100644 --- a/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift +++ b/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift @@ -47,7 +47,7 @@ final class ProtoDurationTests: XCTestCase { XCTAssertEqual(duration.seconds, 120) XCTAssertEqual(duration.nanos, 123) - XCTAssertEqual(duration.timeInterval, 120.000000123) + XCTAssertEqual(duration.timeInterval, 120.000000123, accuracy: 1e-9) } func testDecodeProtoDuration_withoutNanoseconds() throws { @@ -55,7 +55,7 @@ final class ProtoDurationTests: XCTestCase { XCTAssertEqual(duration.seconds, 120) XCTAssertEqual(duration.nanos, 0) - XCTAssertEqual(duration.timeInterval, 120) + XCTAssertEqual(duration.timeInterval, 120, accuracy: 1e-9) } func testDecodeProtoDuration_maxNanosecondDigits() throws { @@ -63,7 +63,7 @@ final class ProtoDurationTests: XCTestCase { XCTAssertEqual(duration.seconds, 15) XCTAssertEqual(duration.nanos, 123_456_789) - XCTAssertEqual(duration.timeInterval, 15.123456789) + XCTAssertEqual(duration.timeInterval, 15.123456789, accuracy: 1e-9) } func testDecodeProtoDuration_withMilliseconds() throws { @@ -71,7 +71,7 @@ final class ProtoDurationTests: XCTestCase { XCTAssertEqual(duration.seconds, 15) XCTAssertEqual(duration.nanos, 123_000_000) - XCTAssertEqual(duration.timeInterval, 15.123000000) + XCTAssertEqual(duration.timeInterval, 15.123, accuracy: 1e-9) } func testDecodeProtoDuration_invalidSeconds() throws { @@ -94,6 +94,6 @@ final class ProtoDurationTests: XCTestCase { XCTAssertEqual(duration.seconds, 123) XCTAssertEqual(duration.nanos, 456_000_000) - XCTAssertEqual(duration.timeInterval, 123.456) + XCTAssertEqual(duration.timeInterval, 123.456, accuracy: 1e-9) } } From e78bbca80c64bd0514d4669dc397a28f021c900c Mon Sep 17 00:00:00 2001 From: Daymon Date: Mon, 13 Oct 2025 12:18:48 -0500 Subject: [PATCH 10/10] format --- FirebaseAI/Sources/Types/Internal/ProtoDuration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift index 29490c4649b..c2b6ad6f80f 100644 --- a/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift +++ b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift @@ -107,6 +107,6 @@ extension ProtoDuration: Decodable { } self.seconds = secs - self.nanos = Int32(fractionalSeconds * 1_000_000_000) + nanos = Int32(fractionalSeconds * 1_000_000_000) } }