diff --git a/.github/actions/notices_generation/Gemfile.lock b/.github/actions/notices_generation/Gemfile.lock index 78d9a193cfb..3d9d31a5ff3 100644 --- a/.github/actions/notices_generation/Gemfile.lock +++ b/.github/actions/notices_generation/Gemfile.lock @@ -98,7 +98,7 @@ GEM sawyer (~> 0.8.0, >= 0.5.3) plist (3.6.0) public_suffix (4.0.6) - rexml (3.4.1) + rexml (3.4.2) ruby-macho (2.5.1) ruby2_keywords (0.0.2) sawyer (0.8.2) diff --git a/.github/workflows/auth.yml b/.github/workflows/auth.yml index 700767b6ef2..7e945cc6477 100644 --- a/.github/workflows/auth.yml +++ b/.github/workflows/auth.yml @@ -29,69 +29,69 @@ concurrency: cancel-in-progress: true jobs: - # spm: - # uses: ./.github/workflows/common.yml - # with: - # target: AuthUnit - # buildonly_platforms: macOS + spm: + uses: ./.github/workflows/common.yml + with: + target: AuthUnit + buildonly_platforms: macOS - # catalyst: - # uses: ./.github/workflows/common_catalyst.yml - # with: - # product: FirebaseAuth - # target: FirebaseAuth-Unit-unit - # buildonly: true + catalyst: + uses: ./.github/workflows/common_catalyst.yml + with: + product: FirebaseAuth + target: FirebaseAuth-Unit-unit + buildonly: true - # pod_lib_lint: - # strategy: - # matrix: - # product: [FirebaseAuthInterop, FirebaseAuth] - # uses: ./.github/workflows/common_cocoapods.yml - # with: - # product: ${{ matrix.product }} - # buildonly_platforms: macOS + pod_lib_lint: + strategy: + matrix: + product: [FirebaseAuthInterop, FirebaseAuth] + uses: ./.github/workflows/common_cocoapods.yml + with: + product: ${{ matrix.product }} + buildonly_platforms: macOS - # integration-tests: - # # Don't run on private repo unless it is a PR. - # if: (github.repository == 'Firebase/firebase-ios-sdk' && github.event_name == 'schedule') || github.event_name == 'pull_request' - # needs: spm - # strategy: - # matrix: - # scheme: [ObjCApiTests, SwiftApiTests, AuthenticationExampleUITests] - # env: - # plist_secret: ${{ secrets.GHASecretsGPGPassphrase1 }} - # FIREBASECI_USE_LATEST_GOOGLEAPPMEASUREMENT: 1 - # runs-on: macos-15 - # steps: - # - uses: actions/checkout@v4 - # - uses: actions/cache/restore@v4 - # with: - # path: .build - # key: ${{ needs.spm.outputs.cache_key }} - # - name: Install Secrets - # run: | - # scripts/decrypt_gha_secret.sh scripts/gha-encrypted/AuthCredentials.h.gpg \ - # FirebaseAuth/Tests/SampleSwift/ObjCApiTests/AuthCredentials.h "$plist_secret" - # scripts/decrypt_gha_secret.sh scripts/gha-encrypted/AuthSample/SwiftApplication.plist.gpg \ - # FirebaseAuth/Tests/SampleSwift/AuthenticationExample/SwiftApplication.plist "$plist_secret" - # scripts/decrypt_gha_secret.sh scripts/gha-encrypted/AuthSample/AuthCredentials.h.gpg \ - # FirebaseAuth/Tests/SampleSwift/AuthCredentials.h "$plist_secret" - # scripts/decrypt_gha_secret.sh scripts/gha-encrypted/AuthSample/GoogleService-Info.plist.gpg \ - # FirebaseAuth/Tests/SampleSwift/GoogleService-Info.plist "$plist_secret" - # scripts/decrypt_gha_secret.sh scripts/gha-encrypted/AuthSample/GoogleService-Info_multi.plist.gpg \ - # FirebaseAuth/Tests/SampleSwift/GoogleService-Info_multi.plist "$plist_secret" - # scripts/decrypt_gha_secret.sh scripts/gha-encrypted/AuthSample/Sample.entitlements.gpg \ - # FirebaseAuth/Tests/SampleSwift/Sample.entitlements "$plist_secret" - # scripts/decrypt_gha_secret.sh scripts/gha-encrypted/AuthSample/Credentials.swift.gpg \ - # FirebaseAuth/Tests/SampleSwift/SwiftApiTests/Credentials.swift "$plist_secret" - # - name: Xcode - # run: sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer - # - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3 - # with: - # timeout_minutes: 15 - # max_attempts: 3 - # retry_wait_seconds: 120 - # command: ([ -z $plist_secret ] || scripts/build.sh Auth iOS ${{ matrix.scheme }}) + integration-tests: + # Don't run on private repo unless it is a PR. + if: (github.repository == 'Firebase/firebase-ios-sdk' && github.event_name == 'schedule') || github.event_name == 'pull_request' + needs: spm + strategy: + matrix: + scheme: [ObjCApiTests, SwiftApiTests, AuthenticationExampleUITests] + env: + plist_secret: ${{ secrets.GHASecretsGPGPassphrase1 }} + FIREBASECI_USE_LATEST_GOOGLEAPPMEASUREMENT: 1 + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - uses: actions/cache/restore@v4 + with: + path: .build + key: ${{ needs.spm.outputs.cache_key }} + - name: Install Secrets + run: | + scripts/decrypt_gha_secret.sh scripts/gha-encrypted/AuthCredentials.h.gpg \ + FirebaseAuth/Tests/SampleSwift/ObjCApiTests/AuthCredentials.h "$plist_secret" + scripts/decrypt_gha_secret.sh scripts/gha-encrypted/AuthSample/SwiftApplication.plist.gpg \ + FirebaseAuth/Tests/SampleSwift/AuthenticationExample/SwiftApplication.plist "$plist_secret" + scripts/decrypt_gha_secret.sh scripts/gha-encrypted/AuthSample/AuthCredentials.h.gpg \ + FirebaseAuth/Tests/SampleSwift/AuthCredentials.h "$plist_secret" + scripts/decrypt_gha_secret.sh scripts/gha-encrypted/AuthSample/GoogleService-Info.plist.gpg \ + FirebaseAuth/Tests/SampleSwift/GoogleService-Info.plist "$plist_secret" + scripts/decrypt_gha_secret.sh scripts/gha-encrypted/AuthSample/GoogleService-Info_multi.plist.gpg \ + FirebaseAuth/Tests/SampleSwift/GoogleService-Info_multi.plist "$plist_secret" + scripts/decrypt_gha_secret.sh scripts/gha-encrypted/AuthSample/Sample.entitlements.gpg \ + FirebaseAuth/Tests/SampleSwift/Sample.entitlements "$plist_secret" + scripts/decrypt_gha_secret.sh scripts/gha-encrypted/AuthSample/Credentials.swift.gpg \ + FirebaseAuth/Tests/SampleSwift/SwiftApiTests/Credentials.swift "$plist_secret" + - name: Xcode + run: sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer + - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3 + with: + timeout_minutes: 15 + max_attempts: 3 + retry_wait_seconds: 120 + command: ([ -z $plist_secret ] || scripts/build.sh Auth iOS ${{ matrix.scheme }}) quickstart: uses: ./.github/workflows/common_quickstart.yml @@ -132,13 +132,14 @@ jobs: # testapp_dir: quickstart-ios/build-for-testing # test_type: "xctest" - # auth-cron-only: - # needs: pod_lib_lint - # uses: ./.github/workflows/common_cocoapods_cron.yml - # with: - # product: FirebaseAuth - # platforms: '[ "ios", "tvos --skip-tests", "macos --skip-tests", "watchos --skip-tests" ]' - # flags: '[ "--use-static-frameworks" ]' - # setup_command: scripts/configure_test_keychain.sh - # secrets: - # plist_secret: ${{ secrets.GHASecretsGPGPassphrase1 }} + auth-cron-only: + needs: pod_lib_lint + uses: ./.github/workflows/common_cocoapods_cron.yml + with: + product: FirebaseAuth + platforms: '[ "ios", "tvos --skip-tests", "macos --skip-tests", "watchos --skip-tests" ]' + flags: '[ "--use-static-frameworks" ]' + setup_command: scripts/configure_test_keychain.sh + ignore_deprecation_warnings: true + secrets: + plist_secret: ${{ secrets.GHASecretsGPGPassphrase1 }} diff --git a/.github/workflows/common_cocoapods.yml b/.github/workflows/common_cocoapods.yml index a8d02d13a86..79562cab290 100644 --- a/.github/workflows/common_cocoapods.yml +++ b/.github/workflows/common_cocoapods.yml @@ -75,6 +75,12 @@ on: required: false default: true + # Whether to lint with `--verbose`. Defaults to false. + verbose: + type: boolean + required: false + default: false + # Whether to additionally build with Swift 6. Defaults to false. supports_swift6: type: boolean @@ -151,6 +157,7 @@ jobs: command: | scripts/pod_lib_lint.rb ${{ inputs.product }}.podspec --platforms=${{ matrix.platform }} \ ${{ inputs.allow_warnings == true && '--allow-warnings' || '' }} \ + ${{ inputs.verbose == true && '--verbose' || '' }} \ ${{ inputs.analyze == false && '--no-analyze' || '' }} \ ${{ inputs.test_specs != '' && format('--test-specs={0}', inputs.test_specs) || '' }} \ ${{ (contains(inputs.buildonly_platforms, matrix.platform) || contains(inputs.buildonly_platforms, 'all')) && '--skip-tests' || '' }} diff --git a/.github/workflows/common_cocoapods_cron.yml b/.github/workflows/common_cocoapods_cron.yml index 3fbebc89924..ade13db13b5 100644 --- a/.github/workflows/common_cocoapods_cron.yml +++ b/.github/workflows/common_cocoapods_cron.yml @@ -46,8 +46,11 @@ on: required: false default: "macos-15" -env: - FIREBASE_CI: true + # Whether to ignore deprecation warnings by setting FIREBASE_CI. + ignore_deprecation_warnings: + type: boolean + required: false + default: false jobs: cron-job: @@ -67,6 +70,9 @@ jobs: run: scripts/setup_bundler.sh - name: Xcode run: sudo xcode-select -s /Applications/${{ inputs.xcode }}.app/Contents/Developer + - name: Set FIREBASE_CI, if needed. + if: inputs.ignore_deprecation_warnings == true + run: echo "FIREBASE_CI=true" >> $GITHUB_ENV - name: Run setup command, if needed. if: inputs.setup_command != '' env: diff --git a/.github/workflows/crashlytics.yml b/.github/workflows/crashlytics.yml index 105ef5bdd7f..f381ed0966f 100644 --- a/.github/workflows/crashlytics.yml +++ b/.github/workflows/crashlytics.yml @@ -18,8 +18,8 @@ on: - 'Interop/Analytics/Public/*.h' - 'Gemfile*' schedule: - # Run every day at 7pm (PDT) / 10pm (EDT) - cron uses UTC times - - cron: '0 2 * * *' + # Run every day at 11pm (PDT) / 2am (EDT) - cron uses UTC times + - cron: '0 6 * * *' concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} diff --git a/.github/workflows/firebaseai.yml b/.github/workflows/firebaseai.yml index 21cdfb83540..d4f2ba1cce1 100644 --- a/.github/workflows/firebaseai.yml +++ b/.github/workflows/firebaseai.yml @@ -27,9 +27,12 @@ permissions: jobs: spm: + strategy: + matrix: + target: [FirebaseAILogicUnit, FirebaseAIUnit] uses: ./.github/workflows/common.yml with: - target: FirebaseAIUnit + target: ${{ matrix.target }} setup_command: scripts/update_vertexai_responses.sh testapp-integration: @@ -56,13 +59,13 @@ jobs: path: .build key: ${{ needs.spm.outputs.cache_key }} - name: Install Secret GoogleService-Info.plist - run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info.plist.gpg \ + run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg \ FirebaseAI/Tests/TestApp/Resources/GoogleService-Info.plist "$secrets_passphrase" - name: Install Secret GoogleService-Info-Spark.plist - run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info-Spark.plist.gpg \ + run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg \ FirebaseAI/Tests/TestApp/Resources/GoogleService-Info-Spark.plist "$secrets_passphrase" - name: Install Secret Credentials.swift - run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/VertexAI/TestApp-Credentials.swift.gpg \ + run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/FirebaseAI/TestApp-Credentials.swift.gpg \ FirebaseAI/Tests/TestApp/Tests/Integration/Credentials.swift "$secrets_passphrase" - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer @@ -77,9 +80,12 @@ jobs: retention-days: 2 pod_lib_lint: + strategy: + matrix: + product: [FirebaseAILogic, FirebaseAI] uses: ./.github/workflows/common_cocoapods.yml with: - product: FirebaseAI + product: ${{ matrix.product }} supports_swift6: true setup_command: scripts/update_vertexai_responses.sh diff --git a/.github/workflows/spm.yml b/.github/workflows/spm.yml index f4f8c05a539..eb0dba8c438 100644 --- a/.github/workflows/spm.yml +++ b/.github/workflows/spm.yml @@ -85,6 +85,12 @@ jobs: max_attempts: 3 retry_wait_seconds: 120 command: scripts/build.sh Firebase-Package iOS ${{ matrix.test }} + - uses: actions/upload-artifact@v4 + if: ${{ failure() }} + with: + name: spm-build-run-${{ matrix.os }}-${{ matrix.xcode }}-logs + path: xcodebuild-*.log + if-no-files-found: error # Test iOS Device build since some Firestore dependencies build different files. iOS-Device: @@ -113,6 +119,12 @@ jobs: run: scripts/setup_spm_tests.sh - name: iOS Device and Test Build run: scripts/third_party/travis/retry.sh ./scripts/build.sh Firebase-Package iOS-device spmbuildonly + - uses: actions/upload-artifact@v4 + if: ${{ failure() }} + with: + name: spm-ios-device-${{ matrix.os }}-${{ matrix.xcode }}-logs + path: xcodebuild-*.log + if-no-files-found: error platforms: # Don't run on private repo unless it is a PR. @@ -148,3 +160,10 @@ jobs: run: scripts/third_party/travis/retry.sh ./scripts/build.sh version-test ${{ matrix.target }} spm - name: Analytics Build Tests run: scripts/third_party/travis/retry.sh ./scripts/build.sh analytics-import-test ${{ matrix.target }} spm + - uses: actions/upload-artifact@v4 + if: ${{ failure() }} + with: + name: spm-platforms-${{ matrix.target }}-${{ matrix.os }}-${{ matrix.xcode }}-logs + path: xcodebuild-*.log + if-no-files-found: error + diff --git a/.github/workflows/zip.yml b/.github/workflows/zip.yml index 5ab70cc1cda..9ec81d3e44d 100644 --- a/.github/workflows/zip.yml +++ b/.github/workflows/zip.yml @@ -457,6 +457,57 @@ jobs: quickstart-ios/ !quickstart-ios/**/GoogleService-Info.plist + quickstart_framework_firebaseai: + needs: package-head + if: ${{ !cancelled() && (success() || github.event.inputs.zip_run_id != '') }} + env: + plist_secret: ${{ secrets.GHASecretsGPGPassphrase1 }} + SDK: "FirebaseAI" + # This is a workaround to use the FirebaseAIExampleZip scheme that does not have the SPM dependency. + SWIFT_SUFFIX: "Zip" + strategy: + matrix: + artifact: [Firebase-actions-dir, Firebase-actions-dir-dynamic] + build-env: + - os: macos-15 + xcode: Xcode_16.4 + runs-on: ${{ matrix.build-env.os }} + steps: + - uses: actions/checkout@v4 + - name: Get framework dir + uses: actions/download-artifact@v4.1.7 + with: + name: ${{ matrix.artifact }} + run-id: ${{ github.event.inputs.zip_run_id || github.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + - uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 # v1 + - name: Xcode + run: sudo xcode-select -s /Applications/${{ matrix.build-env.xcode }}.app/Contents/Developer + - name: Setup Bundler + run: ./scripts/setup_bundler.sh + - name: Move frameworks + run: | + mkdir -p "${HOME}"/ios_frameworks/ + find "${GITHUB_WORKSPACE}" -name "Firebase*latest.zip" -exec unzip -d "${HOME}"/ios_frameworks/ {} + + - uses: actions/checkout@v4 + - name: Setup quickstart + run: SAMPLE="$SDK" TARGET="${SDK}ExampleZip" scripts/setup_quickstart_framework.sh \ + "${HOME}"/ios_frameworks/Firebase/FirebaseAILogic/* \ + "${HOME}"/ios_frameworks/Firebase/FirebaseAnalytics/* + - name: Install Secret GoogleService-Info.plist + run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg \ + quickstart-ios/firebaseai/GoogleService-Info.plist "$plist_secret" + - name: Test Quickstart + run: ([ -z $plist_secret ] || scripts/third_party/travis/retry.sh scripts/test_quickstart_framework.sh "${SDK}") + - name: Remove data before upload + if: ${{ failure() }} + run: scripts/remove_data.sh firebaseai + - uses: actions/upload-artifact@v4 + if: ${{ failure() }} + with: + name: quickstart_artifacts_firebaseai + path: quickstart-ios/ + quickstart_framework_firestore: needs: packaging_done if: ${{ !cancelled() }} @@ -594,6 +645,12 @@ jobs: run: SAMPLE="$SDK" TARGET="${SDK}Example" scripts/setup_quickstart_framework.sh \ "${HOME}"/ios_frameworks/Firebase/FirebaseMessaging/* \ "${HOME}"/ios_frameworks/Firebase/FirebaseAnalytics/* + # - name: Setup swift quickstart + # run: SAMPLE="$SDK" TARGET="${SDK}ExampleSwift" scripts/setup_quickstart_framework.sh + # - name: Add frameworks to Crashlytics watchOS target + # run: | + # cd quickstart-ios/messaging + # "${GITHUB_WORKSPACE}"/quickstart-ios/scripts/add_framework_script.rb --sdk Messaging --target NotificationServiceExtension --framework_path Firebase/ - name: Install Secret GoogleService-Info.plist run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/qs-messaging.plist.gpg \ quickstart-ios/messaging/GoogleService-Info.plist "$plist_secret" diff --git a/Carthage.md b/Carthage.md index 8bf4b21c65c..b0e35f07d52 100644 --- a/Carthage.md +++ b/Carthage.md @@ -31,7 +31,7 @@ Firebase components that you want to include in your app. Note that ``` binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseABTestingBinary.json" -binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAIBinary.json" +binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAILogicBinary.json" binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAdMobBinary.json" binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAnalyticsBinary.json" binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAppCheckBinary.json" diff --git a/CoreOnly/NOTICES b/CoreOnly/NOTICES index 73c5af857ff..b77d58829f9 100644 --- a/CoreOnly/NOTICES +++ b/CoreOnly/NOTICES @@ -2,6 +2,7 @@ AppCheckCore Firebase FirebaseABTesting FirebaseAI +FirebaseAILogic FirebaseAppCheck FirebaseAppCheckInterop FirebaseAppDistribution diff --git a/Crashlytics/CHANGELOG.md b/Crashlytics/CHANGELOG.md index d7fb8b796f2..5f1d6c961df 100644 --- a/Crashlytics/CHANGELOG.md +++ b/Crashlytics/CHANGELOG.md @@ -1,3 +1,6 @@ +# 12.4.0 +- [fixed] Make set development platform APIs to chain on Crashlytics context init promise. + # 12.3.0 - [fixed] Add missing nanopb dependency to fix SwiftPM builds when building dynamically linked libraries. (#15276) diff --git a/Crashlytics/Crashlytics/FIRCrashlytics.m b/Crashlytics/Crashlytics/FIRCrashlytics.m index 171999717ac..09e733996eb 100644 --- a/Crashlytics/Crashlytics/FIRCrashlytics.m +++ b/Crashlytics/Crashlytics/FIRCrashlytics.m @@ -111,6 +111,8 @@ @interface FIRCrashlytics () +#import "Crashlytics/Crashlytics/Helpers/FIRCLSUtility.h" +#import "Crashlytics/Shared/FIRCLSByteUtility.h" +#import "Crashlytics/Shared/FIRCLSMachO/FIRCLSMachOSlice.h" -static void FIRCLSSafeHexToString(const uint8_t* value, size_t length, char* outputBuffer); -static NSString* FIRCLSNSDataToNSString(NSData* data); -static NSString* FIRCLSHashBytes(const void* bytes, size_t length); static NSString* FIRCLSHashNSString(NSString* value); @interface FIRCLSMachOBinary () @@ -116,58 +114,6 @@ + (NSString*)hashNSString:(NSString*)value { @end -// TODO: Functions copied from the SDK. We should figure out a way to share this. -static void FIRCLSSafeHexToString(const uint8_t* value, size_t length, char* outputBuffer) { - const char hex[] = "0123456789abcdef"; - - if (!value) { - outputBuffer[0] = '\0'; - return; - } - - for (size_t i = 0; i < length; ++i) { - unsigned char c = value[i]; - outputBuffer[i * 2] = hex[c >> 4]; - outputBuffer[i * 2 + 1] = hex[c & 0x0F]; - } - - outputBuffer[length * 2] = '\0'; // null terminate -} - -static NSString* FIRCLSNSDataToNSString(NSData* data) { - NSString* string; - char* buffer; - size_t size; - NSUInteger length; - - // we need 2 hex char for every byte of data, plus one more spot for a - // null terminator - length = [data length]; - size = (length * 2) + 1; - buffer = calloc(1, sizeof(char) * size); - - if (!buffer) { - return nil; - } - - FIRCLSSafeHexToString([data bytes], length, buffer); - - string = [NSString stringWithUTF8String:buffer]; - - free(buffer); - - return string; -} - -static NSString* FIRCLSHashBytes(const void* bytes, size_t length) { - uint8_t digest[CC_SHA1_DIGEST_LENGTH] = {0}; - CC_SHA1(bytes, (CC_LONG)length, digest); - - NSData* result = [NSData dataWithBytes:digest length:CC_SHA1_DIGEST_LENGTH]; - - return FIRCLSNSDataToNSString(result); -} - static NSString* FIRCLSHashNSString(NSString* value) { const char* s = [value cStringUsingEncoding:NSUTF8StringEncoding]; diff --git a/Crashlytics/UnitTests/FIRCLSContextManagerTests.m b/Crashlytics/UnitTests/FIRCLSContextManagerTests.m index af2bde64223..f4c863b05a5 100644 --- a/Crashlytics/UnitTests/FIRCLSContextManagerTests.m +++ b/Crashlytics/UnitTests/FIRCLSContextManagerTests.m @@ -122,4 +122,27 @@ - (void)test_settingSessionIDOutOfOrder_protoHasLastSessionID { XCTAssertEqualObjects(adapter.identity.app_quality_session_id, TestContextSessionID2); } +// This test is for chain on init promise for development platform related setters +- (void)test_promisesChainOnInitPromiseInOrder { + NSMutableArray *result = @[].mutableCopy; + NSMutableArray *expectation = @[].mutableCopy; + + for (int j = 0; j < 100; j++) { + [expectation addObject:[NSString stringWithFormat:@"%d", j]]; + } + + FBLPromise *promise = [self.contextManager setupContextWithReport:self.report + settings:self.mockSettings + fileManager:self.fileManager]; + + for (int i = 0; i < 100; i++) { + [promise then:^id _Nullable(id _Nullable value) { + [result addObject:[NSString stringWithFormat:@"%d", i]]; + if (i == 99) { + XCTAssertTrue([result isEqualToArray:expectation]); + } + return nil; + }]; + } +} @end diff --git a/Firebase.podspec b/Firebase.podspec index 8409677a944..43494852371 100644 --- a/Firebase.podspec +++ b/Firebase.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Firebase' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Firebase' s.description = <<-DESC @@ -36,14 +36,14 @@ Simplify your app development, grow your user base, and monetize more effectivel ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' ss.tvos.deployment_target = '15.0' - ss.ios.dependency 'FirebaseAnalytics', '~> 12.4.0' - ss.osx.dependency 'FirebaseAnalytics', '~> 12.4.0' - ss.tvos.dependency 'FirebaseAnalytics', '~> 12.4.0' + ss.ios.dependency 'FirebaseAnalytics', '~> 12.6.0' + ss.osx.dependency 'FirebaseAnalytics', '~> 12.6.0' + ss.tvos.dependency 'FirebaseAnalytics', '~> 12.6.0' ss.dependency 'Firebase/CoreOnly' end s.subspec 'CoreOnly' do |ss| - ss.dependency 'FirebaseCore', '~> 12.4.0' + ss.dependency 'FirebaseCore', '~> 12.6.0' ss.source_files = 'CoreOnly/Sources/Firebase.h' ss.preserve_paths = 'CoreOnly/Sources/module.modulemap' if ENV['FIREBASE_POD_REPO_FOR_DEV_POD'] then @@ -70,7 +70,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'ABTesting' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseABTesting', '~> 12.4.0' + ss.dependency 'FirebaseABTesting', '~> 12.6.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -80,13 +80,13 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'AppDistribution' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.ios.dependency 'FirebaseAppDistribution', '~> 12.4.0-beta' + ss.ios.dependency 'FirebaseAppDistribution', '~> 12.6.0-beta' ss.ios.deployment_target = '15.0' end s.subspec 'AppCheck' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseAppCheck', '~> 12.4.0' + ss.dependency 'FirebaseAppCheck', '~> 12.6.0' ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' ss.tvos.deployment_target = '15.0' @@ -95,7 +95,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Auth' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseAuth', '~> 12.4.0' + ss.dependency 'FirebaseAuth', '~> 12.6.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -105,7 +105,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Crashlytics' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseCrashlytics', '~> 12.4.0' + ss.dependency 'FirebaseCrashlytics', '~> 12.6.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -115,7 +115,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Database' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseDatabase', '~> 12.4.0' + ss.dependency 'FirebaseDatabase', '~> 12.6.0' # Standard platforms PLUS watchOS 7. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -125,7 +125,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Firestore' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseFirestore', '~> 12.4.0' + ss.dependency 'FirebaseFirestore', '~> 12.6.0' ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' ss.tvos.deployment_target = '15.0' @@ -133,7 +133,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Functions' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseFunctions', '~> 12.4.0' + ss.dependency 'FirebaseFunctions', '~> 12.6.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -143,20 +143,20 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'InAppMessaging' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.ios.dependency 'FirebaseInAppMessaging', '~> 12.4.0-beta' - ss.tvos.dependency 'FirebaseInAppMessaging', '~> 12.4.0-beta' + ss.ios.dependency 'FirebaseInAppMessaging', '~> 12.6.0-beta' + ss.tvos.dependency 'FirebaseInAppMessaging', '~> 12.6.0-beta' ss.ios.deployment_target = '15.0' ss.tvos.deployment_target = '15.0' end s.subspec 'Installations' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseInstallations', '~> 12.4.0' + ss.dependency 'FirebaseInstallations', '~> 12.6.0' end s.subspec 'Messaging' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseMessaging', '~> 12.4.0' + ss.dependency 'FirebaseMessaging', '~> 12.6.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -166,7 +166,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'MLModelDownloader' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseMLModelDownloader', '~> 12.4.0-beta' + ss.dependency 'FirebaseMLModelDownloader', '~> 12.6.0-beta' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -176,15 +176,15 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Performance' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.ios.dependency 'FirebasePerformance', '~> 12.4.0' - ss.tvos.dependency 'FirebasePerformance', '~> 12.4.0' + ss.ios.dependency 'FirebasePerformance', '~> 12.6.0' + ss.tvos.dependency 'FirebasePerformance', '~> 12.6.0' ss.ios.deployment_target = '15.0' ss.tvos.deployment_target = '15.0' end s.subspec 'RemoteConfig' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseRemoteConfig', '~> 12.4.0' + ss.dependency 'FirebaseRemoteConfig', '~> 12.6.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -194,7 +194,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Storage' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseStorage', '~> 12.4.0' + ss.dependency 'FirebaseStorage', '~> 12.6.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' diff --git a/FirebaseABTesting.podspec b/FirebaseABTesting.podspec index 6e7eb0112e0..9da362ecada 100644 --- a/FirebaseABTesting.podspec +++ b/FirebaseABTesting.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseABTesting' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Firebase ABTesting' s.description = <<-DESC @@ -51,7 +51,7 @@ Firebase Cloud Messaging and Firebase Remote Config in your app. s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' } - s.dependency 'FirebaseCore', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.6.0' s.test_spec 'unit' do |unit_tests| unit_tests.scheme = { :code_coverage => true } diff --git a/FirebaseAI.podspec b/FirebaseAI.podspec index 00e087a75ce..ba0fb9f5453 100644 --- a/FirebaseAI.podspec +++ b/FirebaseAI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAI' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Firebase AI SDK' s.description = <<-DESC @@ -32,7 +32,7 @@ Build AI-powered apps and features with the Gemini API using the Firebase AI SDK s.prefix_header_file = false s.source_files = [ - 'FirebaseAI/Sources/**/*.swift', + 'FirebaseAI/Wrapper/Sources/**/*.swift', ] s.swift_version = '5.9' @@ -43,13 +43,11 @@ Build AI-powered apps and features with the Gemini API using the Firebase AI SDK s.tvos.framework = 'UIKit' s.watchos.framework = 'WatchKit' - s.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' - s.dependency 'FirebaseAuthInterop', '~> 12.4.0' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseCoreExtension', '~> 12.4.0' + s.dependency 'FirebaseAILogic', '12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' s.test_spec 'unit' do |unit_tests| - unit_tests_dir = 'FirebaseAI/Tests/Unit/' + unit_tests_dir = 'FirebaseAI/Wrapper/Tests/' unit_tests.scheme = { :code_coverage => true } unit_tests.platforms = { :ios => ios_deployment_target, @@ -59,12 +57,5 @@ Build AI-powered apps and features with the Gemini API using the Firebase AI SDK unit_tests.source_files = [ unit_tests_dir + '**/*.swift', ] - unit_tests.exclude_files = [ - unit_tests_dir + 'Snippets/**/*.swift', - ] - unit_tests.resources = [ - unit_tests_dir + 'vertexai-sdk-test-data/mock-responses', - unit_tests_dir + 'Resources/**/*', - ] end end diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index d1a00c824c3..67c5aed67b4 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,6 +1,21 @@ -# Unreleased +# 12.5.0 +- [fixed] Fixed a nanoseconds parsing issue in the Live API when receiving a + `LiveServerGoingAwayNotice` message. (#15410) +- [feature] Added support for sending video frames with the Live API via the `sendVideoRealtime` + method on [`LiveSession`](https://firebase.google.com/docs/reference/swift/firebaseai/api/reference/Classes/LiveSession). + (#15432) + +# 12.4.0 +- [feature] Added support for the URL context tool, which allows the model to access content + from provided public web URLs to inform and enhance its responses. (#15221) - [changed] Using Firebase AI Logic with the Gemini Developer API is now Generally Available (GA). - [changed] Using Firebase AI Logic with the Imagen generation APIs is now Generally Available (GA). +- [feature] Added support for the Live API, which allows bidirectional + communication with the model in realtime. + + To get started with the Live API, see the Firebase docs on + [Bidirectional streaming using the Gemini Live API](https://firebase.google.com/docs/ai-logic/live-api). + (#15309) # 12.3.0 - [feature] Added support for the Code Execution tool, which enables the model diff --git a/FirebaseAI/Sources/AILog.swift b/FirebaseAI/Sources/AILog.swift index fe04716384a..345451bf07f 100644 --- a/FirebaseAI/Sources/AILog.swift +++ b/FirebaseAI/Sources/AILog.swift @@ -66,12 +66,28 @@ enum AILog { case codeExecutionResultUnrecognizedOutcome = 3015 case executableCodeUnrecognizedLanguage = 3016 case fallbackValueUsed = 3017 + case urlMetadataUnrecognizedURLRetrievalStatus = 3018 + case liveSessionUnsupportedMessage = 3019 + case liveSessionUnsupportedMessagePayload = 3020 + case liveSessionFailedToEncodeClientMessage = 3021 + case liveSessionFailedToEncodeClientMessagePayload = 3022 + case liveSessionFailedToSendClientMessage = 3023 + case liveSessionUnexpectedResponse = 3024 + case liveSessionGoingAwaySoon = 3025 + case liveSessionClosedDuringSetup = 3026 + case decodedMissingProtoDurationSuffix = 3027 + case decodedInvalidProtoDurationString = 3028 + case decodedInvalidProtoDurationSeconds = 3029 + case decodedInvalidProtoDurationNanoseconds = 3030 // SDK State Errors case generateContentResponseNoCandidates = 4000 case generateContentResponseNoText = 4001 case appCheckTokenFetchFailed = 4002 case generateContentResponseEmptyCandidates = 4003 + case invalidWebsocketURL = 4004 + case duplicateLiveSessionSetupComplete = 4005 + case malformedURL = 4006 // SDK Debugging case loadRequestStreamResponseLine = 5000 @@ -123,6 +139,17 @@ enum AILog { log(level: .debug, code: code, message) } + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + static func makeInternalError(message: String, code: MessageCode) -> GenerateContentError { + let error = GenerateContentError.internalError(underlying: NSError( + domain: "\(Constants.baseErrorDomain).Internal", + code: code.rawValue, + userInfo: [NSLocalizedDescriptionKey: message] + )) + AILog.error(code: code, message) + return error + } + /// Returns `true` if additional logging has been enabled via a launch argument. static func additionalLoggingEnabled() -> Bool { return ProcessInfo.processInfo.arguments.contains(enableArgumentKey) diff --git a/FirebaseAI/Sources/Chat.swift b/FirebaseAI/Sources/Chat.swift index 80e908a8f57..99c6fb13367 100644 --- a/FirebaseAI/Sources/Chat.swift +++ b/FirebaseAI/Sources/Chat.swift @@ -19,35 +19,21 @@ import Foundation @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public final class Chat: Sendable { private let model: GenerativeModel + private let _history: History - /// Initializes a new chat representing a 1:1 conversation between model and user. init(model: GenerativeModel, history: [ModelContent]) { self.model = model - self.history = history + _history = History(history: history) } - private let historyLock = NSLock() - private nonisolated(unsafe) var _history: [ModelContent] = [] /// The previous content from the chat that has been successfully sent and received from the /// model. This will be provided to the model for each message sent as context for the discussion. public var history: [ModelContent] { get { - historyLock.withLock { _history } + return _history.history } set { - historyLock.withLock { _history = newValue } - } - } - - private func appendHistory(contentsOf: [ModelContent]) { - historyLock.withLock { - _history.append(contentsOf: contentsOf) - } - } - - private func appendHistory(_ newElement: ModelContent) { - historyLock.withLock { - _history.append(newElement) + _history.history = newValue } } @@ -87,8 +73,8 @@ public final class Chat: Sendable { let toAdd = ModelContent(role: "model", parts: reply.parts) // Append the request and successful result to history, then return the value. - appendHistory(contentsOf: newContent) - appendHistory(toAdd) + _history.append(contentsOf: newContent) + _history.append(toAdd) return result } @@ -136,63 +122,16 @@ public final class Chat: Sendable { } // Save the request. - appendHistory(contentsOf: newContent) + _history.append(contentsOf: newContent) // Aggregate the content to add it to the history before we finish. - let aggregated = self.aggregatedChunks(aggregatedContent) - self.appendHistory(aggregated) + let aggregated = self._history.aggregatedChunks(aggregatedContent) + self._history.append(aggregated) continuation.finish() } } } - private func aggregatedChunks(_ chunks: [ModelContent]) -> ModelContent { - var parts: [InternalPart] = [] - var combinedText = "" - var combinedThoughts = "" - - func flush() { - if !combinedThoughts.isEmpty { - parts.append(InternalPart(.text(combinedThoughts), isThought: true, thoughtSignature: nil)) - combinedThoughts = "" - } - if !combinedText.isEmpty { - parts.append(InternalPart(.text(combinedText), isThought: nil, thoughtSignature: nil)) - combinedText = "" - } - } - - // Loop through all the parts, aggregating the text. - for part in chunks.flatMap({ $0.internalParts }) { - // Only text parts may be combined. - if case let .text(text) = part.data, part.thoughtSignature == nil { - // Thought summaries must not be combined with regular text. - if part.isThought ?? false { - // If we were combining regular text, flush it before handling "thoughts". - if !combinedText.isEmpty { - flush() - } - combinedThoughts += text - } else { - // If we were combining "thoughts", flush it before handling regular text. - if !combinedThoughts.isEmpty { - flush() - } - combinedText += text - } - } else { - // This is a non-combinable part (not text), flush any pending text. - flush() - parts.append(part) - } - } - - // Flush any remaining text. - flush() - - return ModelContent(role: "model", parts: parts) - } - /// Populates the `role` field with `user` if it doesn't exist. Required in chat sessions. private func populateContentRole(_ content: ModelContent) -> ModelContent { if content.role != nil { diff --git a/FirebaseAI/Sources/FirebaseAI.swift b/FirebaseAI/Sources/FirebaseAI.swift index ecd9a92077e..40cf38590cf 100644 --- a/FirebaseAI/Sources/FirebaseAI.swift +++ b/FirebaseAI/Sources/FirebaseAI.swift @@ -47,7 +47,6 @@ public final class FirebaseAI: Sendable { useLimitedUseAppCheckTokens: Bool = false) -> FirebaseAI { let instance = createInstance( app: app, - location: backend.location, apiConfig: backend.apiConfig, useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens ) @@ -64,7 +63,7 @@ public final class FirebaseAI: Sendable { /// guidance on choosing an appropriate model for your use case. /// /// - Parameters: - /// - modelName: The name of the model to use, for example `"gemini-1.5-flash"`; see + /// - modelName: The name of the model to use; see /// [available model names /// ](https://firebase.google.com/docs/vertex-ai/gemini-models#available-model-names) for a /// list of supported model names. @@ -107,12 +106,11 @@ public final class FirebaseAI: Sendable { /// Initializes an ``ImagenModel`` with the given parameters. /// - /// > Important: Only Imagen 3 models (named `imagen-3.0-*`) are supported. + /// - Note: Refer to [Imagen models](https://firebase.google.com/docs/vertex-ai/models) for + /// guidance on choosing an appropriate model for your use case. /// /// - Parameters: - /// - modelName: The name of the Imagen 3 model to use, for example `"imagen-3.0-generate-002"`; - /// see [model versions](https://firebase.google.com/docs/vertex-ai/models) for a list of - /// supported Imagen 3 models. + /// - modelName: The name of the Imagen 3 model to use. /// - generationConfig: Configuration options for generating images with Imagen. /// - safetySettings: Settings describing what types of potentially harmful content your model /// should allow. @@ -137,6 +135,66 @@ public final class FirebaseAI: Sendable { ) } + /// Initializes a new `TemplateGenerativeModel`. + /// + /// - Returns: A new `TemplateGenerativeModel` instance. + public func templateGenerativeModel() -> TemplateGenerativeModel { + return TemplateGenerativeModel( + generativeAIService: GenerativeAIService(firebaseInfo: firebaseInfo, + urlSession: GenAIURLSession.default), + apiConfig: apiConfig + ) + } + + /// Initializes a new `TemplateImagenModel`. + /// + /// - Returns: A new `TemplateImagenModel` instance. + public func templateImagenModel() -> TemplateImagenModel { + return TemplateImagenModel( + generativeAIService: GenerativeAIService(firebaseInfo: firebaseInfo, + urlSession: GenAIURLSession.default), + apiConfig: apiConfig + ) + } + + /// **[Public Preview]** Initializes a ``LiveGenerativeModel`` with the given parameters. + /// + /// - Note: Refer to [the Firebase docs on the Live + /// API](https://firebase.google.com/docs/ai-logic/live-api#models-that-support-capability) for + /// guidance on choosing an appropriate model for your use case. + /// + /// > Warning: Using the Firebase AI Logic SDKs with the Gemini Live API is in Public + /// Preview, which means that the feature is not subject to any SLA or deprecation policy and + /// could change in backwards-incompatible ways. + /// + /// - Parameters: + /// - modelName: The name of the model to use. + /// - generationConfig: The content generation parameters your model should use. + /// - tools: A list of ``Tool`` objects that the model may use to generate the next response. + /// - toolConfig: Tool configuration for any ``Tool`` specified in the request. + /// - systemInstruction: Instructions that direct the model to behave a certain way; currently + /// only text content is supported. + /// - requestOptions: Configuration parameters for sending requests to the backend. + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) + @available(watchOS, unavailable) + public func liveModel(modelName: String, + generationConfig: LiveGenerationConfig? = nil, + tools: [Tool]? = nil, + toolConfig: ToolConfig? = nil, + systemInstruction: ModelContent? = nil, + requestOptions: RequestOptions = RequestOptions()) -> LiveGenerativeModel { + return LiveGenerativeModel( + modelResourceName: modelResourceName(modelName: modelName), + firebaseInfo: firebaseInfo, + apiConfig: apiConfig, + generationConfig: generationConfig, + tools: tools, + toolConfig: toolConfig, + systemInstruction: systemInstruction, + requestOptions: requestOptions + ) + } + /// Class to enable FirebaseAI to register via the Objective-C based Firebase component system /// to include FirebaseAI in the userAgent. @objc(FIRVertexAIComponent) class FirebaseVertexAIComponent: NSObject {} @@ -148,21 +206,14 @@ public final class FirebaseAI: Sendable { let apiConfig: APIConfig - /// A map of active `FirebaseAI` instances keyed by the `FirebaseApp` name and the `location`, - /// in the format `appName:location`. + /// A map of active `FirebaseAI` instances keyed by the `FirebaseApp`, the `APIConfig`, and + /// `useLimitedUseAppCheckTokens`. private nonisolated(unsafe) static var instances: [InstanceKey: FirebaseAI] = [:] /// Lock to manage access to the `instances` array to avoid race conditions. private nonisolated(unsafe) static var instancesLock: os_unfair_lock = .init() - let location: String? - - static let defaultVertexAIAPIConfig = APIConfig( - service: .vertexAI(endpoint: .firebaseProxyProd), - version: .v1beta - ) - - static func createInstance(app: FirebaseApp?, location: String?, + static func createInstance(app: FirebaseApp?, apiConfig: APIConfig, useLimitedUseAppCheckTokens: Bool) -> FirebaseAI { guard let app = app ?? FirebaseApp.app() else { @@ -176,7 +227,6 @@ public final class FirebaseAI: Sendable { let instanceKey = InstanceKey( appName: app.name, - location: location, apiConfig: apiConfig, useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens ) @@ -185,7 +235,6 @@ public final class FirebaseAI: Sendable { } let newInstance = FirebaseAI( app: app, - location: location, apiConfig: apiConfig, useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens ) @@ -193,7 +242,7 @@ public final class FirebaseAI: Sendable { return newInstance } - init(app: FirebaseApp, location: String?, apiConfig: APIConfig, + init(app: FirebaseApp, apiConfig: APIConfig, useLimitedUseAppCheckTokens: Bool) { guard let projectID = app.options.projectID else { fatalError("The Firebase app named \"\(app.name)\" has no project ID in its configuration.") @@ -214,7 +263,6 @@ public final class FirebaseAI: Sendable { useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens ) self.apiConfig = apiConfig - self.location = location } func modelResourceName(modelName: String) -> String { @@ -228,17 +276,14 @@ public final class FirebaseAI: Sendable { } switch apiConfig.service { - case .vertexAI: - return vertexAIModelResourceName(modelName: modelName) + case let .vertexAI(endpoint: _, location: location): + return vertexAIModelResourceName(modelName: modelName, location: location) case .googleAI: return developerModelResourceName(modelName: modelName) } } - private func vertexAIModelResourceName(modelName: String) -> String { - guard let location else { - fatalError("Location must be specified for the Firebase AI service.") - } + private func vertexAIModelResourceName(modelName: String, location: String) -> String { guard !location.isEmpty && location .allSatisfy({ !$0.isWhitespace && !$0.isNewline && $0 != "/" }) else { fatalError(""" @@ -267,7 +312,6 @@ public final class FirebaseAI: Sendable { /// This type is `Hashable` so that it can be used as a key in the `instances` dictionary. private struct InstanceKey: Sendable, Hashable { let appName: String - let location: String? let apiConfig: APIConfig let useLimitedUseAppCheckTokens: Bool } diff --git a/FirebaseAI/Sources/GenerateContentRequest.swift b/FirebaseAI/Sources/GenerateContentRequest.swift index 21acd502a75..bc4e9797760 100644 --- a/FirebaseAI/Sources/GenerateContentRequest.swift +++ b/FirebaseAI/Sources/GenerateContentRequest.swift @@ -73,15 +73,23 @@ extension GenerateContentRequest { extension GenerateContentRequest: GenerativeAIRequest { typealias Response = GenerateContentResponse - var url: URL { + func getURL() throws -> URL { let modelURL = "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/\(model)" + let urlString: String switch apiMethod { case .generateContent: - return URL(string: "\(modelURL):\(apiMethod.rawValue)")! + urlString = "\(modelURL):\(apiMethod.rawValue)" case .streamGenerateContent: - return URL(string: "\(modelURL):\(apiMethod.rawValue)?alt=sse")! + urlString = "\(modelURL):\(apiMethod.rawValue)?alt=sse" case .countTokens: - fatalError("\(Self.self) should be a property of \(CountTokensRequest.self).") + throw AILog.makeInternalError( + message: "\(Self.self) should be a property of \(CountTokensRequest.self).", + code: .malformedURL + ) } + guard let url = URL(string: urlString) else { + throw AILog.makeInternalError(message: "Malformed URL: \(urlString)", code: .malformedURL) + } + return url } } diff --git a/FirebaseAI/Sources/GenerateContentResponse.swift b/FirebaseAI/Sources/GenerateContentResponse.swift index 015d5dae56c..a7d7da85d67 100644 --- a/FirebaseAI/Sources/GenerateContentResponse.swift +++ b/FirebaseAI/Sources/GenerateContentResponse.swift @@ -26,6 +26,9 @@ public struct GenerateContentResponse: Sendable { /// The total number of tokens across the generated response candidates. public let candidatesTokenCount: Int + /// The number of tokens used by tools. + public let toolUsePromptTokenCount: Int + /// The number of tokens used by the model's internal "thinking" process. /// /// For models that support thinking (like Gemini 2.5 Pro and Flash), this represents the actual @@ -39,11 +42,15 @@ public struct GenerateContentResponse: Sendable { /// The total number of tokens in both the request and response. public let totalTokenCount: Int - /// The breakdown, by modality, of how many tokens are consumed by the prompt + /// The breakdown, by modality, of how many tokens are consumed by the prompt. public let promptTokensDetails: [ModalityTokenCount] /// The breakdown, by modality, of how many tokens are consumed by the candidates public let candidatesTokensDetails: [ModalityTokenCount] + + /// The breakdown, by modality, of how many tokens were consumed by the tools used to process + /// the request. + public let toolUsePromptTokensDetails: [ModalityTokenCount] } /// A list of candidate response content, ordered from best to worst. @@ -154,20 +161,26 @@ public struct Candidate: Sendable { public let groundingMetadata: GroundingMetadata? + /// Metadata related to the ``URLContext`` tool. + public let urlContextMetadata: URLContextMetadata? + /// Initializer for SwiftUI previews or tests. public init(content: ModelContent, safetyRatings: [SafetyRating], finishReason: FinishReason?, - citationMetadata: CitationMetadata?, groundingMetadata: GroundingMetadata? = nil) { + citationMetadata: CitationMetadata?, groundingMetadata: GroundingMetadata? = nil, + urlContextMetadata: URLContextMetadata? = nil) { self.content = content self.safetyRatings = safetyRatings self.finishReason = finishReason self.citationMetadata = citationMetadata self.groundingMetadata = groundingMetadata + self.urlContextMetadata = urlContextMetadata } // Returns `true` if the candidate contains no information that a developer could use. var isEmpty: Bool { content.parts - .isEmpty && finishReason == nil && citationMetadata == nil && groundingMetadata == nil + .isEmpty && finishReason == nil && citationMetadata == nil && groundingMetadata == nil && + urlContextMetadata == nil } } @@ -469,10 +482,12 @@ extension GenerateContentResponse.UsageMetadata: Decodable { enum CodingKeys: CodingKey { case promptTokenCount case candidatesTokenCount + case toolUsePromptTokenCount case thoughtsTokenCount case totalTokenCount case promptTokensDetails case candidatesTokensDetails + case toolUsePromptTokensDetails } public init(from decoder: any Decoder) throws { @@ -480,6 +495,8 @@ extension GenerateContentResponse.UsageMetadata: Decodable { promptTokenCount = try container.decodeIfPresent(Int.self, forKey: .promptTokenCount) ?? 0 candidatesTokenCount = try container.decodeIfPresent(Int.self, forKey: .candidatesTokenCount) ?? 0 + toolUsePromptTokenCount = + try container.decodeIfPresent(Int.self, forKey: .toolUsePromptTokenCount) ?? 0 thoughtsTokenCount = try container.decodeIfPresent(Int.self, forKey: .thoughtsTokenCount) ?? 0 totalTokenCount = try container.decodeIfPresent(Int.self, forKey: .totalTokenCount) ?? 0 promptTokensDetails = @@ -488,6 +505,9 @@ extension GenerateContentResponse.UsageMetadata: Decodable { [ModalityTokenCount].self, forKey: .candidatesTokensDetails ) ?? [] + toolUsePromptTokensDetails = try container.decodeIfPresent( + [ModalityTokenCount].self, forKey: .toolUsePromptTokensDetails + ) ?? [] } } @@ -499,6 +519,7 @@ extension Candidate: Decodable { case finishReason case citationMetadata case groundingMetadata + case urlContextMetadata } /// Initializes a response from a decoder. Used for decoding server responses; not for public @@ -540,6 +561,14 @@ extension Candidate: Decodable { GroundingMetadata.self, forKey: .groundingMetadata ) + + if let urlContextMetadata = + try container.decodeIfPresent(URLContextMetadata.self, forKey: .urlContextMetadata), + !urlContextMetadata.urlMetadata.isEmpty { + self.urlContextMetadata = urlContextMetadata + } else { + urlContextMetadata = nil + } } } diff --git a/FirebaseAI/Sources/GenerationConfig.swift b/FirebaseAI/Sources/GenerationConfig.swift index 27c4310f12d..fe2b6963e22 100644 --- a/FirebaseAI/Sources/GenerationConfig.swift +++ b/FirebaseAI/Sources/GenerationConfig.swift @@ -48,6 +48,11 @@ public struct GenerationConfig: Sendable { /// Output schema of the generated candidate text. let responseSchema: Schema? + /// Output schema of the generated response in [JSON Schema](https://json-schema.org/) format. + /// + /// If set, `responseSchema` must be omitted and `responseMIMEType` is required. + let responseJSONSchema: JSONObject? + /// Supported modalities of the response. let responseModalities: [ResponseModality]? @@ -175,6 +180,26 @@ public struct GenerationConfig: Sendable { self.stopSequences = stopSequences self.responseMIMEType = responseMIMEType self.responseSchema = responseSchema + responseJSONSchema = nil + self.responseModalities = responseModalities + self.thinkingConfig = thinkingConfig + } + + init(temperature: Float? = nil, topP: Float? = nil, topK: Int? = nil, candidateCount: Int? = nil, + maxOutputTokens: Int? = nil, presencePenalty: Float? = nil, frequencyPenalty: Float? = nil, + stopSequences: [String]? = nil, responseMIMEType: String, responseJSONSchema: JSONObject, + responseModalities: [ResponseModality]? = nil, thinkingConfig: ThinkingConfig? = nil) { + self.temperature = temperature + self.topP = topP + self.topK = topK + self.candidateCount = candidateCount + self.maxOutputTokens = maxOutputTokens + self.presencePenalty = presencePenalty + self.frequencyPenalty = frequencyPenalty + self.stopSequences = stopSequences + self.responseMIMEType = responseMIMEType + responseSchema = nil + self.responseJSONSchema = responseJSONSchema self.responseModalities = responseModalities self.thinkingConfig = thinkingConfig } @@ -195,6 +220,7 @@ extension GenerationConfig: Encodable { case stopSequences case responseMIMEType = "responseMimeType" case responseSchema + case responseJSONSchema = "responseJsonSchema" case responseModalities case thinkingConfig } diff --git a/FirebaseAI/Sources/GenerativeAIRequest.swift b/FirebaseAI/Sources/GenerativeAIRequest.swift index 148e989db40..192de607137 100644 --- a/FirebaseAI/Sources/GenerativeAIRequest.swift +++ b/FirebaseAI/Sources/GenerativeAIRequest.swift @@ -18,7 +18,7 @@ import Foundation protocol GenerativeAIRequest: Sendable, Encodable { associatedtype Response: Sendable, Decodable - var url: URL { get } + func getURL() throws -> URL var options: RequestOptions { get } } diff --git a/FirebaseAI/Sources/GenerativeAIService.swift b/FirebaseAI/Sources/GenerativeAIService.swift index 8056d4172b8..ed385f942a0 100644 --- a/FirebaseAI/Sources/GenerativeAIService.swift +++ b/FirebaseAI/Sources/GenerativeAIService.swift @@ -26,7 +26,7 @@ struct GenerativeAIService { /// The Firebase SDK version in the format `fire/`. static let firebaseVersionTag = "fire/\(FirebaseVersion())" - private let firebaseInfo: FirebaseInfo + let firebaseInfo: FirebaseInfo private let urlSession: URLSession @@ -167,7 +167,7 @@ struct GenerativeAIService { // MARK: - Private Helpers private func urlRequest(request: T) async throws -> URLRequest { - var urlRequest = URLRequest(url: request.url) + var urlRequest = try URLRequest(url: request.getURL()) urlRequest.httpMethod = "POST" urlRequest.setValue(firebaseInfo.apiKey, forHTTPHeaderField: "x-goog-api-key") urlRequest.setValue( @@ -177,7 +177,10 @@ struct GenerativeAIService { urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") if let appCheck = firebaseInfo.appCheck { - let tokenResult = try await fetchAppCheckToken(appCheck: appCheck) + let tokenResult = try await appCheck.fetchAppCheckToken( + limitedUse: firebaseInfo.useLimitedUseAppCheckTokens, + domain: "GenerativeAIService" + ) urlRequest.setValue(tokenResult.token, forHTTPHeaderField: "X-Firebase-AppCheck") if let error = tokenResult.error { AILog.error( @@ -207,53 +210,6 @@ struct GenerativeAIService { return urlRequest } - private func fetchAppCheckToken(appCheck: AppCheckInterop) async throws - -> FIRAppCheckTokenResultInterop { - if firebaseInfo.useLimitedUseAppCheckTokens { - if let token = await getLimitedUseAppCheckToken(appCheck: appCheck) { - return token - } - - let errorMessage = - "The provided App Check token provider doesn't implement getLimitedUseToken(), but requireLimitedUseTokens was enabled." - - #if Debug - fatalError(errorMessage) - #else - throw NSError( - domain: "\(Constants.baseErrorDomain).\(Self.self)", - code: AILog.MessageCode.appCheckTokenFetchFailed.rawValue, - userInfo: [NSLocalizedDescriptionKey: errorMessage] - ) - #endif - } - - return await appCheck.getToken(forcingRefresh: false) - } - - private func getLimitedUseAppCheckToken(appCheck: AppCheckInterop) async - -> FIRAppCheckTokenResultInterop? { - // At the moment, `await` doesn’t get along with Objective-C’s optional protocol methods. - await withCheckedContinuation { (continuation: CheckedContinuation< - FIRAppCheckTokenResultInterop?, - Never - >) in - guard - firebaseInfo.useLimitedUseAppCheckTokens, - // `getLimitedUseToken(completion:)` is an optional protocol method. Optional binding - // is performed to make sure `continuation` is called even if the method’s not implemented. - let limitedUseTokenClosure = appCheck.getLimitedUseToken - else { - return continuation.resume(returning: nil) - } - - limitedUseTokenClosure { tokenResult in - // The placeholder token should be used in the case of App Check error. - continuation.resume(returning: tokenResult) - } - } - } - private func httpResponse(urlResponse: URLResponse) throws -> HTTPURLResponse { // The following condition should always be true: "Whenever you make HTTP URL load requests, any // response objects you get back from the URLSession, NSURLConnection, or NSURLDownload class diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index 428e1fe6f26..e3f905793ad 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -59,7 +59,7 @@ public final class GenerativeModel: Sendable { /// Initializes a new remote model with the given parameters. /// /// - Parameters: - /// - modelName: The name of the model, for example "gemini-2.0-flash". + /// - modelName: The name of the model. /// - modelResourceName: The model resource name corresponding with `modelName` in the backend. /// The form depends on the backend and will be one of: /// - Vertex AI via Firebase AI SDK: diff --git a/FirebaseAI/Sources/History.swift b/FirebaseAI/Sources/History.swift new file mode 100644 index 00000000000..827f7df5b46 --- /dev/null +++ b/FirebaseAI/Sources/History.swift @@ -0,0 +1,94 @@ +// 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 Foundation + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class History: Sendable { + private let historyLock = NSLock() + private nonisolated(unsafe) var _history: [ModelContent] = [] + /// The previous content from the chat that has been successfully sent and received from the + /// model. This will be provided to the model for each message sent as context for the discussion. + public var history: [ModelContent] { + get { + historyLock.withLock { _history } + } + set { + historyLock.withLock { _history = newValue } + } + } + + init(history: [ModelContent]) { + self.history = history + } + + func append(contentsOf: [ModelContent]) { + historyLock.withLock { + _history.append(contentsOf: contentsOf) + } + } + + func append(_ newElement: ModelContent) { + historyLock.withLock { + _history.append(newElement) + } + } + + func aggregatedChunks(_ chunks: [ModelContent]) -> ModelContent { + var parts: [InternalPart] = [] + var combinedText = "" + var combinedThoughts = "" + + func flush() { + if !combinedThoughts.isEmpty { + parts.append(InternalPart(.text(combinedThoughts), isThought: true, thoughtSignature: nil)) + combinedThoughts = "" + } + if !combinedText.isEmpty { + parts.append(InternalPart(.text(combinedText), isThought: nil, thoughtSignature: nil)) + combinedText = "" + } + } + + // Loop through all the parts, aggregating the text. + for part in chunks.flatMap({ $0.internalParts }) { + // Only text parts may be combined. + if case let .text(text) = part.data, part.thoughtSignature == nil { + // Thought summaries must not be combined with regular text. + if part.isThought ?? false { + // If we were combining regular text, flush it before handling "thoughts". + if !combinedText.isEmpty { + flush() + } + combinedThoughts += text + } else { + // If we were combining "thoughts", flush it before handling regular text. + if !combinedThoughts.isEmpty { + flush() + } + combinedText += text + } + } else { + // This is a non-combinable part (not text), flush any pending text. + flush() + parts.append(part) + } + } + + // Flush any remaining text. + flush() + + return ModelContent(role: "model", parts: parts) + } +} diff --git a/FirebaseAI/Sources/TemplateChatSession.swift b/FirebaseAI/Sources/TemplateChatSession.swift new file mode 100644 index 00000000000..abba669a1dd --- /dev/null +++ b/FirebaseAI/Sources/TemplateChatSession.swift @@ -0,0 +1,176 @@ +// 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 Foundation + +// TODO: Restore `public` to class and methods when determined to be releaseable. + +/// A chat session that allows for conversation with a model. +/// +/// **Public Preview**: This API is a public preview and may be subject to change. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class TemplateChatSession: Sendable { + private let model: TemplateGenerativeModel + private let templateID: String + private let _history: History + + init(model: TemplateGenerativeModel, templateID: String, history: [ModelContent]) { + self.model = model + self.templateID = templateID + _history = History(history: history) + } + + public var history: [ModelContent] { + get { + return _history.history + } + set { + _history.history = newValue + } + } + + /// Sends a message to the model and returns the response. + /// + /// **Public Preview**: This API is a public preview and may be subject to change. + /// + /// - Parameters: + /// - content: The message to send to the model. + /// - inputs: A dictionary of variables to substitute into the template. + /// - options: The ``RequestOptions`` for the request, currently used to override default + /// request timeout. + /// - Returns: The content generated by the model. + /// - Throws: A ``GenerateContentError`` if the request failed. + func sendMessage(_ content: [ModelContent], + inputs: [String: Any], + options: RequestOptions = RequestOptions()) async throws + -> GenerateContentResponse { + let templateInputs = try inputs.mapValues { try TemplateInput(value: $0) } + let newContent = content.map(populateContentRole) + let response = try await model.generateContentWithHistory( + history: _history.history + newContent, + template: templateID, + inputs: templateInputs, + options: options + ) + _history.append(contentsOf: newContent) + if let modelResponse = response.candidates.first { + _history.append(modelResponse.content) + } + return response + } + + /// Sends a message to the model and returns the response. + /// + /// **Public Preview**: This API is a public preview and may be subject to change. + /// + /// - Parameters: + /// - message: The message to send to the model. + /// - inputs: A dictionary of variables to substitute into the template. + /// - options: The ``RequestOptions`` for the request, currently used to override default + /// request timeout. + /// - Returns: The content generated by the model. + /// - Throws: A ``GenerateContentError`` if the request failed. + func sendMessage(_ message: any PartsRepresentable, + inputs: [String: Any], + options: RequestOptions = RequestOptions()) async throws + -> GenerateContentResponse { + return try await sendMessage([ModelContent(parts: message.partsValue)], + inputs: inputs, + options: options) + } + + /// Sends a message to the model and returns the response as a stream of + /// `GenerateContentResponse`s. + /// + /// **Public Preview**: This API is a public preview and may be subject to change. + /// + /// - Parameters: + /// - content: The message to send to the model. + /// - inputs: A dictionary of variables to substitute into the template. + /// - options: The ``RequestOptions`` for the request, currently used to override default + /// request timeout. + /// - Returns: An `AsyncThrowingStream` that yields `GenerateContentResponse` objects. + /// - Throws: A ``GenerateContentError`` if the request failed. + func sendMessageStream(_ content: [ModelContent], + inputs: [String: Any], + options: RequestOptions = RequestOptions()) throws + -> AsyncThrowingStream { + let templateInputs = try inputs.mapValues { try TemplateInput(value: $0) } + let newContent = content.map(populateContentRole) + let stream = try model.generateContentStreamWithHistory( + history: _history.history + newContent, + template: templateID, + inputs: templateInputs, + options: options + ) + return AsyncThrowingStream { continuation in + Task { + var aggregatedContent: [ModelContent] = [] + + do { + for try await chunk in stream { + // Capture any content that's streaming. This should be populated if there's no error. + if let chunkContent = chunk.candidates.first?.content { + aggregatedContent.append(chunkContent) + } + + // Pass along the chunk. + continuation.yield(chunk) + } + } catch { + // Rethrow the error that the underlying stream threw. Don't add anything to history. + continuation.finish(throwing: error) + return + } + + // Save the request. + _history.append(contentsOf: newContent) + + // Aggregate the content to add it to the history before we finish. + let aggregated = _history.aggregatedChunks(aggregatedContent) + _history.append(aggregated) + continuation.finish() + } + } + } + + /// Sends a message to the model and returns the response as a stream of + /// `GenerateContentResponse`s. + /// + /// **Public Preview**: This API is a public preview and may be subject to change. + /// + /// - Parameters: + /// - message: The message to send to the model. + /// - inputs: A dictionary of variables to substitute into the template. + /// - options: The ``RequestOptions`` for the request, currently used to override default + /// request timeout. + /// - Returns: An `AsyncThrowingStream` that yields `GenerateContentResponse` objects. + /// - Throws: A ``GenerateContentError`` if the request failed. + func sendMessageStream(_ message: any PartsRepresentable, + inputs: [String: Any], + options: RequestOptions = RequestOptions()) throws + -> AsyncThrowingStream { + return try sendMessageStream([ModelContent(parts: message.partsValue)], + inputs: inputs, + options: options) + } + + private func populateContentRole(_ content: ModelContent) -> ModelContent { + if content.role != nil { + return content + } else { + return ModelContent(role: "user", parts: content.parts) + } + } +} diff --git a/FirebaseAI/Sources/TemplateGenerateContentRequest.swift b/FirebaseAI/Sources/TemplateGenerateContentRequest.swift new file mode 100644 index 00000000000..20ba84b3571 --- /dev/null +++ b/FirebaseAI/Sources/TemplateGenerateContentRequest.swift @@ -0,0 +1,63 @@ +// 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 Foundation + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +struct TemplateGenerateContentRequest: Sendable { + let template: String + let inputs: [String: TemplateInput] + let history: [ModelContent] + let projectID: String + let stream: Bool + let apiConfig: APIConfig + let options: RequestOptions +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension TemplateGenerateContentRequest: Encodable { + enum CodingKeys: String, CodingKey { + case inputs + case history + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(inputs, forKey: .inputs) + try container.encode(history, forKey: .history) + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension TemplateGenerateContentRequest: GenerativeAIRequest { + typealias Response = GenerateContentResponse + + func getURL() throws -> URL { + var urlString = + "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/projects/\(projectID)" + if case let .vertexAI(_, location) = apiConfig.service { + urlString += "/locations/\(location)" + } + + if stream { + urlString += "/templates/\(template):templateStreamGenerateContent?alt=sse" + } else { + urlString += "/templates/\(template):templateGenerateContent" + } + guard let url = URL(string: urlString) else { + throw AILog.makeInternalError(message: "Malformed URL: \(urlString)", code: .malformedURL) + } + return url + } +} diff --git a/FirebaseAI/Sources/TemplateGenerativeModel.swift b/FirebaseAI/Sources/TemplateGenerativeModel.swift new file mode 100644 index 00000000000..bf727021c0f --- /dev/null +++ b/FirebaseAI/Sources/TemplateGenerativeModel.swift @@ -0,0 +1,141 @@ +// 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 Foundation + +/// A type that represents a remote multimodal model (like Gemini), with the ability to generate +/// content based on various input types. +/// +/// **Public Preview**: This API is a public preview and may be subject to change. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public final class TemplateGenerativeModel: Sendable { + let generativeAIService: GenerativeAIService + let apiConfig: APIConfig + + init(generativeAIService: GenerativeAIService, apiConfig: APIConfig) { + self.generativeAIService = generativeAIService + self.apiConfig = apiConfig + } + + /// Generates content from a prompt template and inputs. + /// + /// **Public Preview**: This API is a public preview and may be subject to change. + /// + /// - Parameters: + /// - templateID: The ID of the prompt template to use. + /// - inputs: A dictionary of variables to substitute into the template. + /// - options: The ``RequestOptions`` for the request, currently used to override default + /// request timeout. + /// - Returns: The content generated by the model. + /// - Throws: A ``GenerateContentError`` if the request failed. + public func generateContent(templateID: String, + inputs: [String: Any], + options: RequestOptions = RequestOptions()) async throws + -> GenerateContentResponse { + let templateInputs = try inputs.mapValues { try TemplateInput(value: $0) } + return try await generateContentWithHistory( + history: [], + template: templateID, + inputs: templateInputs, + options: options + ) + } + + /// Generates content from a prompt template, inputs, and history. + /// + /// - Parameters: + /// - history: The conversation history to use. + /// - template: The prompt template to use. + /// - inputs: A dictionary of variables to substitute into the template. + /// - Returns: The content generated by the model. + /// - Throws: A ``GenerateContentError`` if the request failed. + func generateContentWithHistory(history: [ModelContent], template: String, + inputs: [String: TemplateInput], + options: RequestOptions = RequestOptions()) async throws + -> GenerateContentResponse { + let request = TemplateGenerateContentRequest( + template: template, + inputs: inputs, + history: history, + projectID: generativeAIService.firebaseInfo.projectID, + stream: false, + apiConfig: apiConfig, + options: options + ) + let response: GenerateContentResponse = try await generativeAIService + .loadRequest(request: request) + return response + } + + /// Generates content from a prompt template and inputs, with streaming responses. + /// + /// **Public Preview**: This API is a public preview and may be subject to change. + /// + /// - Parameters: + /// - templateID: The ID of the prompt template to use. + /// - inputs: A dictionary of variables to substitute into the template. + /// - options: The ``RequestOptions`` for the request, currently used to override default + /// request timeout. + /// - Returns: An `AsyncThrowingStream` that yields `GenerateContentResponse` objects. + /// - Throws: A ``GenerateContentError`` if the request failed. + public func generateContentStream(templateID: String, + inputs: [String: Any], + options: RequestOptions = RequestOptions()) throws + -> AsyncThrowingStream { + let templateInputs = try inputs.mapValues { try TemplateInput(value: $0) } + let request = TemplateGenerateContentRequest( + template: templateID, + inputs: templateInputs, + history: [], + projectID: generativeAIService.firebaseInfo.projectID, + stream: true, + apiConfig: apiConfig, + options: options + ) + return generativeAIService.loadRequestStream(request: request) + } + + func generateContentStreamWithHistory(history: [ModelContent], template: String, + inputs: [String: TemplateInput], + options: RequestOptions = RequestOptions()) throws + -> AsyncThrowingStream { + let request = TemplateGenerateContentRequest( + template: template, + inputs: inputs, + history: history, + projectID: generativeAIService.firebaseInfo.projectID, + stream: true, + apiConfig: apiConfig, + options: options + ) + return generativeAIService.loadRequestStream(request: request) + } + + // TODO: Restore `public` determined to be releaseable along with the contents of TemplateChatSession. + + /// Creates a new chat conversation using this model with the provided history and template. + /// + /// - Parameters: + /// - templateID: The ID of the prompt template to use. + /// - history: The conversation history to use. + /// - Returns: A new ``TemplateChatSession`` instance. + func startChat(templateID: String, + history: [ModelContent] = []) -> TemplateChatSession { + return TemplateChatSession( + model: self, + templateID: templateID, + history: history + ) + } +} diff --git a/FirebaseAI/Sources/TemplateImagenGenerationRequest.swift b/FirebaseAI/Sources/TemplateImagenGenerationRequest.swift new file mode 100644 index 00000000000..c155b66fe55 --- /dev/null +++ b/FirebaseAI/Sources/TemplateImagenGenerationRequest.swift @@ -0,0 +1,67 @@ +// 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 Foundation + +enum ImageAPIMethod: String { + case generateImages = "templatePredict" +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +struct TemplateImagenGenerationRequest: Sendable { + typealias Response = ImagenGenerationResponse + + let template: String + let inputs: [String: TemplateInput] + let projectID: String + let apiConfig: APIConfig + let options: RequestOptions + + init(template: String, inputs: [String: TemplateInput], projectID: String, + apiConfig: APIConfig, options: RequestOptions) { + self.template = template + self.inputs = inputs + self.projectID = projectID + self.apiConfig = apiConfig + self.options = options + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension TemplateImagenGenerationRequest: GenerativeAIRequest where ImageType: Decodable { + func getURL() throws -> URL { + var urlString = + "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/projects/\(projectID)" + if case let .vertexAI(_, location) = apiConfig.service { + urlString += "/locations/\(location)" + } + urlString += "/templates/\(template):\(ImageAPIMethod.generateImages.rawValue)" + guard let url = URL(string: urlString) else { + throw AILog.makeInternalError(message: "Malformed URL: \(urlString)", code: .malformedURL) + } + return url + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension TemplateImagenGenerationRequest: Encodable { + enum CodingKeys: String, CodingKey { + case inputs + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(inputs, forKey: .inputs) + } +} diff --git a/FirebaseAI/Sources/TemplateImagenModel.swift b/FirebaseAI/Sources/TemplateImagenModel.swift new file mode 100644 index 00000000000..794965364bd --- /dev/null +++ b/FirebaseAI/Sources/TemplateImagenModel.swift @@ -0,0 +1,56 @@ +// 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 Foundation + +/// A type that represents a remote image generation model (like Imagen), with the ability to +/// generate +/// images based on various input types. +/// +/// **Public Preview**: This API is a public preview and may be subject to change. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public final class TemplateImagenModel: Sendable { + let generativeAIService: GenerativeAIService + let apiConfig: APIConfig + + init(generativeAIService: GenerativeAIService, apiConfig: APIConfig) { + self.generativeAIService = generativeAIService + self.apiConfig = apiConfig + } + + /// Generates images from a prompt template and variables. + /// + /// - Parameters: + /// - template: The prompt template to use. + /// - variables: A dictionary of variables to substitute into the template. + /// - options: The ``RequestOptions`` for the request, currently used to override default + /// request timeout. + /// - Returns: The images generated by the model. + /// - Throws: An error if the request failed. + public func generateImages(templateID: String, + inputs: [String: Any], + options: RequestOptions = RequestOptions()) async throws + -> ImagenGenerationResponse { + let templateInputs = try inputs.mapValues { try TemplateInput(value: $0) } + let projectID = generativeAIService.firebaseInfo.projectID + let request = TemplateImagenGenerationRequest( + template: templateID, + inputs: templateInputs, + projectID: projectID, + apiConfig: apiConfig, + options: options + ) + return try await generativeAIService.loadRequest(request: request) + } +} diff --git a/FirebaseAI/Sources/TemplateInput.swift b/FirebaseAI/Sources/TemplateInput.swift new file mode 100644 index 00000000000..606150ed824 --- /dev/null +++ b/FirebaseAI/Sources/TemplateInput.swift @@ -0,0 +1,66 @@ +// 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 Foundation + +enum TemplateInput: Encodable, Sendable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case array([TemplateInput]) + case dictionary([String: TemplateInput]) + + init(value: Any) throws { + switch value { + case let value as String: + self = .string(value) + case let value as Int: + self = .int(value) + case let value as Double: + self = .double(value) + case let value as Float: + self = .double(Double(value)) + case let value as Bool: + self = .bool(value) + case let value as [Any]: + self = try .array(value.map { try TemplateInput(value: $0) }) + case let value as [String: Any]: + self = try .dictionary(value.mapValues { try TemplateInput(value: $0) }) + default: + throw EncodingError.invalidValue( + value, + EncodingError.Context(codingPath: [], debugDescription: "Invalid value") + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .string(value): + try container.encode(value) + case let .int(value): + try container.encode(value) + case let .double(value): + try container.encode(value) + case let .bool(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case let .dictionary(value): + try container.encode(value) + } + } +} diff --git a/FirebaseAI/Sources/Tool.swift b/FirebaseAI/Sources/Tool.swift index 78dc8ef9443..e051b3b5ea4 100644 --- a/FirebaseAI/Sources/Tool.swift +++ b/FirebaseAI/Sources/Tool.swift @@ -76,12 +76,15 @@ public struct Tool: Sendable { let googleSearch: GoogleSearch? let codeExecution: CodeExecution? + let urlContext: URLContext? init(functionDeclarations: [FunctionDeclaration]? = nil, googleSearch: GoogleSearch? = nil, + urlContext: URLContext? = nil, codeExecution: CodeExecution? = nil) { self.functionDeclarations = functionDeclarations self.googleSearch = googleSearch + self.urlContext = urlContext self.codeExecution = codeExecution } @@ -128,6 +131,18 @@ public struct Tool: Sendable { return self.init(googleSearch: googleSearch) } + /// Creates a tool that allows you to provide additional context to the models in the form of + /// public web URLs. + /// + /// By including URLs in your request, the Gemini model will access the content from those pages + /// to inform and enhance its response. + /// + /// > Warning: URL context is a **Public Preview** feature, which means that it is not subject to + /// > any SLA or deprecation policy and could change in backwards-incompatible ways. + public static func urlContext() -> Tool { + return self.init(urlContext: URLContext()) + } + /// Creates a tool that allows the model to execute code. /// /// For more details, see ``CodeExecution``. diff --git a/FirebaseAI/Sources/Types/Internal/APIConfig.swift b/FirebaseAI/Sources/Types/Internal/APIConfig.swift index f9c5d32c779..97a8615e98a 100644 --- a/FirebaseAI/Sources/Types/Internal/APIConfig.swift +++ b/FirebaseAI/Sources/Types/Internal/APIConfig.swift @@ -45,7 +45,7 @@ extension APIConfig { /// See the [Cloud /// docs](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference) for /// more details. - case vertexAI(endpoint: Endpoint) + case vertexAI(endpoint: Endpoint, location: String) /// The Gemini Developer API provided by Google AI. /// @@ -57,7 +57,7 @@ extension APIConfig { /// This must correspond with the API set in `service`. var endpoint: Endpoint { switch self { - case let .vertexAI(endpoint: endpoint): + case let .vertexAI(endpoint: endpoint, _): return endpoint case let .googleAI(endpoint: endpoint): return endpoint @@ -68,6 +68,7 @@ extension APIConfig { extension APIConfig.Service { /// Network addresses for generative AI API services. + // TODO: maybe remove the https:// prefix and just add it as needed? websockets use these too. enum Endpoint: String, Encodable { /// The Firebase proxy production endpoint. /// diff --git a/FirebaseAI/Sources/Types/Internal/AppCheck.swift b/FirebaseAI/Sources/Types/Internal/AppCheck.swift new file mode 100644 index 00000000000..3b6d784f636 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/AppCheck.swift @@ -0,0 +1,74 @@ +// 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 FirebaseAppCheckInterop + +/// Internal helper extension for fetching app check tokens. +/// +/// Provides a common means for fetching limited use tokens, and falling back to standard tokens +/// when it's disabled (or in debug mode). This also centrializes the error, since this method is +/// used in multiple places. +extension AppCheckInterop { + /// Fetch the appcheck token. + /// + /// - Parameters: + /// - limitedUse: Should the token be a limited-use token, or a standard token. + /// - domain: A string dictating where this method is being called from. Used in any thrown + /// errors, to avoid hard-to-parse traces. + func fetchAppCheckToken(limitedUse: Bool, + domain: String) async throws -> FIRAppCheckTokenResultInterop { + if limitedUse { + if let token = await getLimitedUseTokenAsync() { + return token + } + + let errorMessage = + "The provided App Check token provider doesn't implement getLimitedUseToken(), but requireLimitedUseTokens was enabled." + + #if Debug + fatalError(errorMessage) + #else + throw NSError( + domain: "\(Constants.baseErrorDomain).\(domain)", + code: AILog.MessageCode.appCheckTokenFetchFailed.rawValue, + userInfo: [NSLocalizedDescriptionKey: errorMessage] + ) + #endif + } + + return await getToken(forcingRefresh: false) + } + + private func getLimitedUseTokenAsync() async + -> FIRAppCheckTokenResultInterop? { + // At the moment, `await` doesn’t get along with Objective-C’s optional protocol methods. + await withCheckedContinuation { (continuation: CheckedContinuation< + FIRAppCheckTokenResultInterop?, + Never + >) in + guard + // `getLimitedUseToken(completion:)` is an optional protocol method. Optional binding + // is performed to make sure `continuation` is called even if the method’s not implemented. + let limitedUseTokenClosure = getLimitedUseToken + else { + return continuation.resume(returning: nil) + } + + limitedUseTokenClosure { tokenResult in + // The placeholder token should be used in the case of App Check error. + continuation.resume(returning: tokenResult) + } + } + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Imagen/ImagenGenerationRequest.swift b/FirebaseAI/Sources/Types/Internal/Imagen/ImagenGenerationRequest.swift index ffb0e8bcf57..9f5a76137d3 100644 --- a/FirebaseAI/Sources/Types/Internal/Imagen/ImagenGenerationRequest.swift +++ b/FirebaseAI/Sources/Types/Internal/Imagen/ImagenGenerationRequest.swift @@ -39,9 +39,13 @@ struct ImagenGenerationRequest: Sendable { extension ImagenGenerationRequest: GenerativeAIRequest where ImageType: Decodable { typealias Response = ImagenGenerationResponse - var url: URL { - return URL(string: - "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/\(model):predict")! + func getURL() throws -> URL { + let urlString = + "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/\(model):predict" + guard let url = URL(string: urlString) else { + throw AILog.makeInternalError(message: "Malformed URL: \(urlString)", code: .malformedURL) + } + return url } } diff --git a/FirebaseAI/Sources/Types/Internal/InternalPart.swift b/FirebaseAI/Sources/Types/Internal/InternalPart.swift index a8afe4439c3..a9d5a2eb810 100644 --- a/FirebaseAI/Sources/Types/Internal/InternalPart.swift +++ b/FirebaseAI/Sources/Types/Internal/InternalPart.swift @@ -45,10 +45,12 @@ struct FileData: Codable, Equatable, Sendable { struct FunctionCall: Equatable, Sendable { let name: String let args: JSONObject + let id: String? - init(name: String, args: JSONObject) { + init(name: String, args: JSONObject, id: String?) { self.name = name self.args = args + self.id = id } } @@ -56,10 +58,12 @@ struct FunctionCall: Equatable, Sendable { struct FunctionResponse: Codable, Equatable, Sendable { let name: String let response: JSONObject + let id: String? - init(name: String, response: JSONObject) { + init(name: String, response: JSONObject, id: String? = nil) { self.name = name self.response = response + self.id = id } } @@ -135,6 +139,7 @@ extension FunctionCall: Codable { } else { args = JSONObject() } + id = try container.decodeIfPresent(String.self, forKey: .id) } } diff --git a/FirebaseAI/Sources/Types/Internal/Live/AsyncWebSocket.swift b/FirebaseAI/Sources/Types/Internal/Live/AsyncWebSocket.swift new file mode 100644 index 00000000000..81c1c337258 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/AsyncWebSocket.swift @@ -0,0 +1,149 @@ +// 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 Foundation +private import FirebaseCoreInternal + +/// Async API for interacting with web sockets. +/// +/// Internally, this just wraps around a `URLSessionWebSocketTask`, and provides a more async +/// friendly interface for sending and consuming data from it. +/// +/// Also surfaces a more fine-grained ``WebSocketClosedError`` for when the web socket is closed. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +final class AsyncWebSocket: Sendable { + private let webSocketTask: URLSessionWebSocketTask + private let stream: AsyncThrowingStream + private let continuation: AsyncThrowingStream.Continuation + private let continuationFinished = UnfairLock(false) + private let closeError: UnfairLock + + init(urlSession: URLSession = GenAIURLSession.default, urlRequest: URLRequest) { + webSocketTask = urlSession.webSocketTask(with: urlRequest) + (stream, continuation) = AsyncThrowingStream + .makeStream() + closeError = UnfairLock(nil) + } + + deinit { + disconnect() + } + + /// Starts a connection to the backend, returning a stream for the websocket responses. + func connect() -> AsyncThrowingStream { + webSocketTask.resume() + closeError.withLock { $0 = nil } + startReceiving() + return stream + } + + /// Closes the websocket, if it's not already closed. + func disconnect() { + guard closeError.value() == nil else { return } + + close(code: .goingAway, reason: nil) + } + + /// Sends a message to the server, through the websocket. + /// + /// If the web socket is closed, this method will throw the error it was closed with. + func send(_ message: URLSessionWebSocketTask.Message) async throws { + if let closeError = closeError.value() { + throw closeError + } + try await webSocketTask.send(message) + } + + private func startReceiving() { + Task { + while !Task.isCancelled && self.webSocketTask.isOpen && self.closeError.value() == nil { + do { + let message = try await webSocketTask.receive() + continuation.yield(message) + } catch { + if let error = webSocketTask.error as? NSError { + close( + code: webSocketTask.closeCode, + reason: webSocketTask.closeReason, + underlyingError: error + ) + } else { + close(code: webSocketTask.closeCode, reason: webSocketTask.closeReason) + } + } + } + } + } + + private func close(code: URLSessionWebSocketTask.CloseCode, + reason: Data?, + underlyingError: Error? = nil) { + let error = WebSocketClosedError( + closeCode: code, + closeReason: reason, + underlyingError: underlyingError + ) + closeError.withLock { + $0 = error + } + + webSocketTask.cancel(with: code, reason: reason) + + continuationFinished.withLock { isFinished in + guard !isFinished else { return } + self.continuation.finish(throwing: error) + isFinished = true + } + } +} + +private extension URLSessionWebSocketTask { + var isOpen: Bool { + return closeCode == .invalid + } +} + +/// The websocket was closed. +/// +/// See the `closeReason` for why, or the `errorCode` for the corresponding +/// `URLSessionWebSocketTask.CloseCode`. +/// +/// In some cases, the `NSUnderlyingErrorKey` key may be populated with an +/// error for additional context. +struct WebSocketClosedError: Error, Sendable, CustomNSError { + let closeCode: URLSessionWebSocketTask.CloseCode + let closeReason: String + let underlyingError: Error? + + init(closeCode: URLSessionWebSocketTask.CloseCode, closeReason: Data?, + underlyingError: Error? = nil) { + self.closeCode = closeCode + self.closeReason = closeReason + .flatMap { String(data: $0, encoding: .utf8) } ?? "Unknown reason." + self.underlyingError = underlyingError + } + + var errorCode: Int { closeCode.rawValue } + + var errorUserInfo: [String: Any] { + var userInfo: [String: Any] = [ + NSLocalizedDescriptionKey: "WebSocket closed with code \(closeCode.rawValue). Reason: \(closeReason)", + ] + if let underlyingError { + userInfo[NSUnderlyingErrorKey] = underlyingError + } + return userInfo + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentClientContent.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentClientContent.swift new file mode 100644 index 00000000000..459aa258cc3 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentClientContent.swift @@ -0,0 +1,36 @@ +// 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 Foundation + +/// Incremental update of the current conversation delivered from the client. +/// All the content here is unconditionally appended to the conversation +/// history and used as part of the prompt to the model to generate content. +/// +/// A message here will interrupt any current model generation. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentClientContent: Encodable { + /// The content appended to the current conversation with the model. + /// + /// For single-turn queries, this is a single instance. For multi-turn + /// queries, this is a repeated field that contains conversation history and + /// latest request. + let turns: [ModelContent]? + + /// If true, indicates that the server content generation should start with + /// the currently accumulated prompt. Otherwise, the server will await + /// additional messages before starting generation. + let turnComplete: Bool? +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentClientMessage.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentClientMessage.swift new file mode 100644 index 00000000000..758d75e2cc7 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentClientMessage.swift @@ -0,0 +1,57 @@ +// 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 Foundation + +/// Messages sent by the client in the BidiGenerateContent RPC call. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +enum BidiGenerateContentClientMessage { + /// Message to be sent in the first and only first client message. + case setup(BidiGenerateContentSetup) + + /// Incremental update of the current conversation delivered from the client. + case clientContent(BidiGenerateContentClientContent) + + /// User input that is sent in real time. + case realtimeInput(BidiGenerateContentRealtimeInput) + + /// Response to a `ToolCallMessage` received from the server. + case toolResponse(BidiGenerateContentToolResponse) +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +extension BidiGenerateContentClientMessage: Encodable { + enum CodingKeys: CodingKey { + case setup + case clientContent + case realtimeInput + case toolResponse + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .setup(setup): + try container.encode(setup, forKey: .setup) + case let .clientContent(clientContent): + try container.encode(clientContent, forKey: .clientContent) + case let .realtimeInput(realtimeInput): + try container.encode(realtimeInput, forKey: .realtimeInput) + case let .toolResponse(toolResponse): + try container.encode(toolResponse, forKey: .toolResponse) + } + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentRealtimeInput.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentRealtimeInput.swift new file mode 100644 index 00000000000..753a9a3fb15 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentRealtimeInput.swift @@ -0,0 +1,76 @@ +// 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 Foundation + +/// User input that is sent in real time. +/// +/// This is different from `ClientContentUpdate` in a few ways: +/// +/// - Can be sent continuously without interruption to model generation. +/// - If there is a need to mix data interleaved across the +/// `ClientContentUpdate` and the `RealtimeUpdate`, server attempts to +/// optimize for best response, but there are no guarantees. +/// - End of turn is not explicitly specified, but is rather derived from user +/// activity (for example, end of speech). +/// - Even before the end of turn, the data is processed incrementally +/// to optimize for a fast start of the response from the model. +/// - Is always assumed to be the user's input (cannot be used to populate +/// conversation history). +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentRealtimeInput: Encodable { + /// These form the realtime audio input stream. + let audio: InlineData? + + /// Indicates that the audio stream has ended, e.g. because the microphone was + /// turned off. + /// + /// This should only be sent when automatic activity detection is enabled + /// (which is the default). + /// + /// The client can reopen the stream by sending an audio message. + let audioStreamEnd: Bool? + + /// These form the realtime video input stream. + let video: InlineData? + + /// These form the realtime text input stream. + let text: String? + + /// Marks the start of user activity. + struct ActivityStart: Encodable {} + + /// Marks the start of user activity. This can only be sent if automatic + /// (i.e. server-side) activity detection is disabled. + let activityStart: ActivityStart? + + /// Marks the end of user activity. + struct ActivityEnd: Encodable {} + + /// Marks the end of user activity. This can only be sent if automatic (i.e. + /// server-side) activity detection is disabled. + let activityEnd: ActivityEnd? + + init(audio: InlineData? = nil, video: InlineData? = nil, text: String? = nil, + activityStart: ActivityStart? = nil, activityEnd: ActivityEnd? = nil, + audioStreamEnd: Bool? = nil) { + self.audio = audio + self.video = video + self.text = text + self.activityStart = activityStart + self.activityEnd = activityEnd + self.audioStreamEnd = audioStreamEnd + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentServerContent.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentServerContent.swift new file mode 100644 index 00000000000..648d7a09ed8 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentServerContent.swift @@ -0,0 +1,58 @@ +// 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 Foundation + +/// Incremental server update generated by the model in response to client +/// messages. +/// +/// Content is generated as quickly as possible, and not in realtime. Clients +/// may choose to buffer and play it out in realtime. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentServerContent: Decodable, Sendable { + /// The content that the model has generated as part of the current + /// conversation with the user. + let modelTurn: ModelContent? + + /// If true, indicates that the model is done generating. Generation will only + /// start in response to additional client messages. Can be set alongside + /// `content`, indicating that the `content` is the last in the turn. + let turnComplete: Bool? + + /// If true, indicates that a client message has interrupted current model + /// generation. If the client is playing out the content in realtime, this is a + /// good signal to stop and empty the current queue. If the client is playing + /// out the content in realtime, this is a good signal to stop and empty the + /// current playback queue. + let interrupted: Bool? + + /// If true, indicates that the model is done generating. + /// + /// When model is interrupted while generating there will be no + /// 'generation_complete' message in interrupted turn, it will go through + /// 'interrupted > turn_complete'. + /// + /// When model assumes realtime playback there will be delay between + /// generation_complete and turn_complete that is caused by model waiting for + /// playback to finish. + let generationComplete: Bool? + + /// Metadata specifies sources used to ground generated content. + let groundingMetadata: GroundingMetadata? + + let inputTranscription: BidiGenerateContentTranscription? + + let outputTranscription: BidiGenerateContentTranscription? +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentServerMessage.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentServerMessage.swift new file mode 100644 index 00000000000..8c7c628ebdb --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentServerMessage.swift @@ -0,0 +1,105 @@ +// 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 Foundation + +/// Response message for BidiGenerateContent RPC call. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentServerMessage: Sendable { + /// The type of the message. + enum MessageType: Sendable { + /// Sent in response to a `BidiGenerateContentSetup` message from the client. + case setupComplete(BidiGenerateContentSetupComplete) + + /// Content generated by the model in response to client messages. + case serverContent(BidiGenerateContentServerContent) + + /// Request for the client to execute the `function_calls` and return the + /// responses with the matching `id`s. + case toolCall(BidiGenerateContentToolCall) + + /// Notification for the client that a previously issued + /// `ToolCallMessage` with the specified `id`s should have been not executed + /// and should be cancelled. + case toolCallCancellation(BidiGenerateContentToolCallCancellation) + + /// Server will disconnect soon. + case goAway(GoAway) + } + + /// The message type. + let messageType: MessageType + + /// Usage metadata about the response(s). + let usageMetadata: GenerateContentResponse.UsageMetadata? +} + +// MARK: - Decodable + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +extension BidiGenerateContentServerMessage: Decodable { + enum CodingKeys: String, CodingKey { + case setupComplete + case serverContent + case toolCall + case toolCallCancellation + case goAway + case usageMetadata + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let setupComplete = try container.decodeIfPresent( + BidiGenerateContentSetupComplete.self, + forKey: .setupComplete + ) { + messageType = .setupComplete(setupComplete) + } else if let serverContent = try container.decodeIfPresent( + BidiGenerateContentServerContent.self, + forKey: .serverContent + ) { + messageType = .serverContent(serverContent) + } else if let toolCall = try container.decodeIfPresent( + BidiGenerateContentToolCall.self, + forKey: .toolCall + ) { + messageType = .toolCall(toolCall) + } else if let toolCallCancellation = try container.decodeIfPresent( + BidiGenerateContentToolCallCancellation.self, + forKey: .toolCallCancellation + ) { + messageType = .toolCallCancellation(toolCallCancellation) + } else if let goAway = try container.decodeIfPresent(GoAway.self, forKey: .goAway) { + messageType = .goAway(goAway) + } else { + throw InvalidMessageTypeError() + } + + usageMetadata = try container.decodeIfPresent( + GenerateContentResponse.UsageMetadata.self, + forKey: .usageMetadata + ) + } +} + +struct InvalidMessageTypeError: Error, Sendable, CustomNSError { + public var errorUserInfo: [String: Any] { + [ + NSLocalizedDescriptionKey: "Missing server message type.", + ] + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentSetup.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentSetup.swift new file mode 100644 index 00000000000..15dc8889a0b --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentSetup.swift @@ -0,0 +1,78 @@ +// 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 Foundation + +/// Message to be sent in the first and only first +/// `BidiGenerateContentClientMessage`. Contains configuration that will apply +/// for the duration of the streaming RPC. +/// +/// Clients should wait for a `BidiGenerateContentSetupComplete` message before +/// sending any additional messages. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentSetup: Encodable { + /// The fully qualified name of the publisher model. + /// + /// Publisher model format: + /// `projects/{project}/locations/{location}/publishers/*/models/*` + let model: String + + /// Generation config. + let generationConfig: BidiGenerationConfig? + + /// The user provided system instructions for the model. + /// Note: only text should be used in parts and content in each part will be + /// in a separate paragraph. + let systemInstruction: ModelContent? + + /// A list of `Tools` the model may use to generate the next response. + /// + /// A `Tool` is a piece of code that enables the system to interact with + /// external systems to perform an action, or set of actions, outside of + /// knowledge and scope of the model. + let tools: [Tool]? + + let toolConfig: ToolConfig? + + /// Input transcription. The transcription is independent to the model turn + /// which means it doesn't imply any ordering between transcription and model + /// turn. + let inputAudioTranscription: BidiAudioTranscriptionConfig? + + /// Output transcription. The transcription is independent to the model turn + /// which means it doesn't imply any ordering between transcription and model + /// turn. + let outputAudioTranscription: BidiAudioTranscriptionConfig? + + init(model: String, + generationConfig: BidiGenerationConfig? = nil, + systemInstruction: ModelContent? = nil, + tools: [Tool]? = nil, + toolConfig: ToolConfig? = nil, + inputAudioTranscription: BidiAudioTranscriptionConfig? = nil, + outputAudioTranscription: BidiAudioTranscriptionConfig? = nil) { + self.model = model + self.generationConfig = generationConfig + self.systemInstruction = systemInstruction + self.tools = tools + self.toolConfig = toolConfig + self.inputAudioTranscription = inputAudioTranscription + self.outputAudioTranscription = outputAudioTranscription + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiAudioTranscriptionConfig: Encodable {} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentSetupComplete.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentSetupComplete.swift new file mode 100644 index 00000000000..54449782060 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentSetupComplete.swift @@ -0,0 +1,20 @@ +// 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 Foundation + +/// Sent in response to a `BidiGenerateContentSetup` message from the client. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentSetupComplete: Decodable, Sendable {} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolCall.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolCall.swift new file mode 100644 index 00000000000..4c34e6367e9 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolCall.swift @@ -0,0 +1,24 @@ +// 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 Foundation + +/// Request for the client to execute the `function_calls` and return the +/// responses with the matching `id`s. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentToolCall: Decodable, Sendable { + /// The function call to be executed. + let functionCalls: [FunctionCall]? +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolCallCancellation.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolCallCancellation.swift new file mode 100644 index 00000000000..48bc991c1fa --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolCallCancellation.swift @@ -0,0 +1,27 @@ +// 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 Foundation + +/// Notification for the client that a previously issued `ToolCallMessage` +/// with the specified `id`s should have been not executed and should be +/// cancelled. If there were side-effects to those tool calls, clients may +/// attempt to undo the tool calls. This message occurs only in cases where the +/// clients interrupt server turns. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentToolCallCancellation: Decodable, Sendable { + /// The ids of the tool calls to be cancelled. + let ids: [String]? +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolResponse.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolResponse.swift new file mode 100644 index 00000000000..c9d2506895b --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolResponse.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 Foundation + +/// Client generated response to a `ToolCall` received from the server. +/// Individual `FunctionResponse` objects are matched to the respective +/// `FunctionCall` objects by the `id` field. +/// +/// Note that in the unary and server-streaming GenerateContent APIs function +/// calling happens by exchanging the `Content` parts, while in the bidi +/// GenerateContent APIs function calling happens over these dedicated set of +/// messages. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentToolResponse: Encodable { + /// The response to the function calls. + let functionResponses: [FunctionResponse]? +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentTranscription.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentTranscription.swift new file mode 100644 index 00000000000..652799edf9d --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentTranscription.swift @@ -0,0 +1,19 @@ +// 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. + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentTranscription: Decodable, Sendable { + let text: String? +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerationConfig.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerationConfig.swift new file mode 100644 index 00000000000..a3a3e8a9f99 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerationConfig.swift @@ -0,0 +1,46 @@ +// 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 Foundation + +/// Configuration options for live content generation. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerationConfig: Encodable, Sendable { + let temperature: Float? + let topP: Float? + let topK: Int? + let candidateCount: Int? + let maxOutputTokens: Int? + let presencePenalty: Float? + let frequencyPenalty: Float? + let responseModalities: [ResponseModality]? + let speechConfig: BidiSpeechConfig? + + init(temperature: Float? = nil, topP: Float? = nil, topK: Int? = nil, + candidateCount: Int? = nil, maxOutputTokens: Int? = nil, + presencePenalty: Float? = nil, frequencyPenalty: Float? = nil, + responseModalities: [ResponseModality]? = nil, + speechConfig: BidiSpeechConfig? = nil) { + self.temperature = temperature + self.topP = topP + self.topK = topK + self.candidateCount = candidateCount + self.maxOutputTokens = maxOutputTokens + self.presencePenalty = presencePenalty + self.frequencyPenalty = frequencyPenalty + self.responseModalities = responseModalities + self.speechConfig = speechConfig + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiSpeechConfig.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiSpeechConfig.swift new file mode 100644 index 00000000000..80e7d341ef7 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiSpeechConfig.swift @@ -0,0 +1,31 @@ +// 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 Foundation + +/// Speech generation config. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiSpeechConfig: Encodable, Sendable { + /// The configuration for the speaker to use. + let voiceConfig: VoiceConfig + + /// Language code (ISO 639. e.g. en-US) for the speech synthesization. + let languageCode: String? + + init(voiceConfig: VoiceConfig, languageCode: String?) { + self.voiceConfig = voiceConfig + self.languageCode = languageCode + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/GoAway.swift b/FirebaseAI/Sources/Types/Internal/Live/GoAway.swift new file mode 100644 index 00000000000..f5c858b8b45 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/GoAway.swift @@ -0,0 +1,25 @@ +// 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 Foundation + +/// Server will not be able to service client soon. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct GoAway: Decodable, Sendable { + /// The remaining time before the connection will be terminated as ABORTED. + /// The minimal time returned here is specified differently together with + /// the rate limits for a given model. + let timeLeft: ProtoDuration? +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift b/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift new file mode 100644 index 00000000000..05ec6918cc5 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift @@ -0,0 +1,437 @@ +// 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 Foundation + +// TODO: remove @preconcurrency when we update to Swift 6 +// for context, see +// https://forums.swift.org/t/why-does-sending-a-sendable-value-risk-causing-data-races/73074 +@preconcurrency import FirebaseAppCheckInterop +@preconcurrency import FirebaseAuthInterop + +/// Facilitates communication with the backend for a ``LiveSession``. +/// +/// Using an actor will make it easier to adopt session resumption, as we have an isolated place for +/// maintaining mutability, which is backed by Swift concurrency implicitly; allowing us to avoid +/// various edge-case issues with dead-locks and data races. +/// +/// This mainly comes into play when we don't want to block developers from sending messages while a +/// session is being reloaded. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +actor LiveSessionService { + let responses: AsyncThrowingStream + private let responseContinuation: AsyncThrowingStream + .Continuation + + // to ensure messages are sent in order, since swift actors are reentrant + private let messageQueue: AsyncStream + private let messageQueueContinuation: AsyncStream.Continuation + + let modelResourceName: String + let generationConfig: LiveGenerationConfig? + let urlSession: URLSession + let apiConfig: APIConfig + let firebaseInfo: FirebaseInfo + let requestOptions: RequestOptions + let tools: [Tool]? + let toolConfig: ToolConfig? + let systemInstruction: ModelContent? + + var webSocket: AsyncWebSocket? + + private let jsonEncoder = JSONEncoder() + private let jsonDecoder = JSONDecoder() + + /// Long running task that that wraps around the websocket, propagating messages through the + /// public stream. + private var responsesTask: Task? + + /// Long running task that consumes user messages from the ``messageQueue`` and sends them through + /// the websocket. + private var messageQueueTask: Task? + + init(modelResourceName: String, + generationConfig: LiveGenerationConfig?, + urlSession: URLSession, + apiConfig: APIConfig, + firebaseInfo: FirebaseInfo, + tools: [Tool]?, + toolConfig: ToolConfig?, + systemInstruction: ModelContent?, + requestOptions: RequestOptions) { + (responses, responseContinuation) = AsyncThrowingStream.makeStream() + (messageQueue, messageQueueContinuation) = AsyncStream.makeStream() + self.modelResourceName = modelResourceName + self.generationConfig = generationConfig + self.urlSession = urlSession + self.apiConfig = apiConfig + self.firebaseInfo = firebaseInfo + self.tools = tools + self.toolConfig = toolConfig + self.systemInstruction = systemInstruction + self.requestOptions = requestOptions + } + + deinit { + responsesTask?.cancel() + messageQueueTask?.cancel() + webSocket?.disconnect() + + webSocket = nil + responsesTask = nil + messageQueueTask = nil + } + + /// Queue a message to be sent to the model. + /// + /// If there's any issues while sending the message, details about the issue will be logged. + /// + /// Since messages are queued synchronously, they are sent in-order. + func send(_ message: BidiGenerateContentClientMessage) { + messageQueueContinuation.yield(message) + } + + /// Start a new connection to the backend. + /// + /// Separated into its own function to make it easier to surface a way to call it separately when + /// resuming the same session. + /// + /// This function will yield until the websocket is ready to communicate with the client. + func connect() async throws { + close() + + let stream = try await setupWebsocket() + try await waitForSetupComplete(stream: stream) + spawnMessageTasks(stream: stream) + } + + /// Cancel any running tasks and close the websocket. + /// + /// This method is idempotent; if it's already ran once, it will effectively be a no-op. + func close() { + responsesTask?.cancel() + messageQueueTask?.cancel() + webSocket?.disconnect() + + webSocket = nil + responsesTask = nil + messageQueueTask = nil + } + + /// Performs the initial setup procedure for the model. + /// + /// The setup procedure with the model follows the procedure: + /// + /// - Client sends `BidiGenerateContentSetup` + /// - Server sends back `BidiGenerateContentSetupComplete` when it's ready + /// + /// This function will yield until the setup is complete. + private func waitForSetupComplete(stream: MappedStream< + URLSessionWebSocketTask.Message, + Data + >) async throws { + guard let webSocket else { return } + + do { + let setup = BidiGenerateContentSetup( + model: modelResourceName, + generationConfig: generationConfig?.bidiGenerationConfig, + systemInstruction: systemInstruction, + tools: tools, + toolConfig: toolConfig, + inputAudioTranscription: generationConfig?.inputAudioTranscription, + outputAudioTranscription: generationConfig?.outputAudioTranscription + ) + let data = try jsonEncoder.encode(BidiGenerateContentClientMessage.setup(setup)) + try await webSocket.send(.data(data)) + } catch { + let error = LiveSessionSetupError(underlyingError: error) + close() + throw error + } + + do { + for try await message in stream { + let response = try decodeServerMessage(message) + if case .setupComplete = response.messageType { + break + } else { + AILog.error( + code: .liveSessionUnexpectedResponse, + "The model sent us a message that wasn't a setup complete: \(response)" + ) + } + } + } catch { + if let error = mapWebsocketError(error) { + close() + throw error + } + // the user called close while setup was running + // this can't currently happen, but could when we add automatic session resumption + // in such case, we don't want to raise an error. this log is more-so to catch any edge cases + AILog.debug( + code: .liveSessionClosedDuringSetup, + "The live session was closed before setup could complete: \(error.localizedDescription)" + ) + } + } + + /// Performs the initial setup procedure for a websocket. + /// + /// This includes creating the websocket url and connecting it. + /// + /// - Returns: A stream of `Data` frames from the websocket. + private func setupWebsocket() async throws + -> MappedStream { + do { + let webSocket = try await createWebsocket() + self.webSocket = webSocket + + let stream = webSocket.connect() + + // remove the uncommon (and unexpected) frames from the stream, to make normal path cleaner + return stream.compactMap { message in + switch message { + case let .string(string): + AILog.error(code: .liveSessionUnexpectedResponse, "Unexpected string response: \(string)") + case let .data(data): + return data + @unknown default: + AILog.error(code: .liveSessionUnexpectedResponse, "Unknown message received: \(message)") + } + return nil + } + } catch { + let error = LiveSessionSetupError(underlyingError: error) + close() + throw error + } + } + + /// Spawn tasks for interacting with the model. + /// + /// The following tasks will be spawned: + /// + /// - `responsesTask`: Listen to messages from the server and yield them through `responses`. + /// - `messageQueueTask`: Listen to messages from the client and send them through the websocket. + private func spawnMessageTasks(stream: MappedStream) { + guard let webSocket else { return } + + responsesTask = Task { + do { + for try await message in stream { + let response = try decodeServerMessage(message) + + if case .setupComplete = response.messageType { + AILog.debug( + code: .duplicateLiveSessionSetupComplete, + "Setup complete was received multiple times; this may be a bug in the model." + ) + } else if let liveMessage = LiveServerMessage(from: response) { + if case let .goingAwayNotice(message) = liveMessage.payload { + // TODO: (b/444045023) When auto session resumption is enabled, call `connect` again + AILog.debug( + code: .liveSessionGoingAwaySoon, + "Session expires in: \(message.goAway.timeLeft?.timeInterval ?? 0)" + ) + } + + responseContinuation.yield(liveMessage) + } + } + } catch { + if let error = mapWebsocketError(error) { + close() + responseContinuation.finish(throwing: error) + } + } + } + + messageQueueTask = Task { + for await message in messageQueue { + guard let data = encodeClientMessage(message) else { continue } + + do { + try await webSocket.send(.data(data)) + } catch { + AILog.error(code: .liveSessionFailedToSendClientMessage, error.localizedDescription) + } + } + } + } + + /// Checks if an error should be propagated up, and maps it accordingly. + /// + /// Some errors have public api alternatives. This function will ensure they're mapped + /// accordingly. + private func mapWebsocketError(_ error: Error) -> Error? { + if let error = error as? WebSocketClosedError { + // only raise an error if the session didn't close normally (ie; the user calling close) + if error.closeCode == .goingAway { + return nil + } + + let closureError: Error + + if let error = error.underlyingError as? NSError, error.domain == NSURLErrorDomain, + error.code == NSURLErrorNetworkConnectionLost { + closureError = LiveSessionLostConnectionError(underlyingError: error) + } else { + closureError = LiveSessionUnexpectedClosureError(underlyingError: error) + } + + return closureError + } + + return error + } + + /// Decodes a message from the server's websocket into a valid `BidiGenerateContentServerMessage`. + /// + /// Will throw an error if decoding fails. + private func decodeServerMessage(_ message: Data) throws -> BidiGenerateContentServerMessage { + do { + return try jsonDecoder.decode( + BidiGenerateContentServerMessage.self, + from: message + ) + } catch { + // only log the json if it wasn't a decoding error, but an unsupported message type + if error is InvalidMessageTypeError { + AILog.error( + code: .liveSessionUnsupportedMessage, + "The server sent a message that we don't currently have a mapping for." + ) + AILog.debug( + code: .liveSessionUnsupportedMessagePayload, + message.encodeToJsonString() ?? "\(message)" + ) + } + + throw LiveSessionUnsupportedMessageError(underlyingError: error) + } + } + + /// Encodes a message from the client into `Data` that can be sent through a websocket data frame. + /// + /// Will return `nil` if decoding fails, and log an error describing why. + private func encodeClientMessage(_ message: BidiGenerateContentClientMessage) -> Data? { + do { + return try jsonEncoder.encode(message) + } catch { + AILog.error(code: .liveSessionFailedToEncodeClientMessage, error.localizedDescription) + AILog.debug( + code: .liveSessionFailedToEncodeClientMessagePayload, + String(describing: message) + ) + } + + return nil + } + + /// Creates a websocket pointing to the backend. + /// + /// Will apply the required app check and auth headers, as the backend expects them. + private nonisolated func createWebsocket() async throws -> AsyncWebSocket { + let host = apiConfig.service.endpoint.rawValue.withoutPrefix("https://") + let urlString = switch apiConfig.service { + case let .vertexAI(_, location: location): + "wss://\(host)/ws/google.firebase.vertexai.\(apiConfig.version.rawValue).LlmBidiService/BidiGenerateContent/locations/\(location)" + case .googleAI: + "wss://\(host)/ws/google.firebase.vertexai.\(apiConfig.version.rawValue).GenerativeService/BidiGenerateContent" + } + guard let url = URL(string: urlString) else { + throw NSError( + domain: "\(Constants.baseErrorDomain).\(Self.self)", + code: AILog.MessageCode.invalidWebsocketURL.rawValue, + userInfo: [ + NSLocalizedDescriptionKey: "The live API websocket URL is not a valid URL", + ] + ) + } + var urlRequest = URLRequest(url: url) + urlRequest.timeoutInterval = requestOptions.timeout + urlRequest.setValue(firebaseInfo.apiKey, forHTTPHeaderField: "x-goog-api-key") + urlRequest.setValue( + "\(GenerativeAIService.languageTag) \(GenerativeAIService.firebaseVersionTag)", + forHTTPHeaderField: "x-goog-api-client" + ) + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let appCheck = firebaseInfo.appCheck { + let tokenResult = try await appCheck.fetchAppCheckToken( + limitedUse: firebaseInfo.useLimitedUseAppCheckTokens, + domain: "LiveSessionService" + ) + urlRequest.setValue(tokenResult.token, forHTTPHeaderField: "X-Firebase-AppCheck") + if let error = tokenResult.error { + AILog.error( + code: .appCheckTokenFetchFailed, + "Failed to fetch AppCheck token. Error: \(error)" + ) + } + } + + if let auth = firebaseInfo.auth, let authToken = try await auth.getToken( + forcingRefresh: false + ) { + urlRequest.setValue("Firebase \(authToken)", forHTTPHeaderField: "Authorization") + } + + if firebaseInfo.app.isDataCollectionDefaultEnabled { + urlRequest.setValue(firebaseInfo.firebaseAppID, forHTTPHeaderField: "X-Firebase-AppId") + if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + urlRequest.setValue(appVersion, forHTTPHeaderField: "X-Firebase-AppVersion") + } + } + + return AsyncWebSocket(urlSession: urlSession, urlRequest: urlRequest) + } +} + +private extension Data { + /// Encodes this into a raw json string, with no regard to specific keys. + /// + /// Will return `nil` if this data doesn't represent a valid json object. + func encodeToJsonString() -> String? { + do { + let object = try JSONSerialization.jsonObject(with: self) + let data = try JSONSerialization.data(withJSONObject: object) + + return String(data: data, encoding: .utf8) + } catch { + return nil + } + } +} + +private extension String { + /// Create a new string with the given prefix removed, if it's present. + /// + /// If the prefix isn't present, this string will be returned instead. + func withoutPrefix(_ prefix: String) -> String { + if let index = range(of: prefix, options: .anchored) { + return String(self[index.upperBound...]) + } else { + return self + } + } +} + +/// Helper alias for a compact mapped throwing stream. +/// +/// We use this to make signatures easier to read, since we can't support `AsyncSequence` quite yet. +private typealias MappedStream = AsyncCompactMapSequence, V> diff --git a/FirebaseAI/Sources/Types/Internal/Live/VoiceConfig.swift b/FirebaseAI/Sources/Types/Internal/Live/VoiceConfig.swift new file mode 100644 index 00000000000..0e6790c03f2 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/VoiceConfig.swift @@ -0,0 +1,74 @@ +// 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 Foundation + +/// Configuration for the speaker to use. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +enum VoiceConfig { + /// Configuration for the prebuilt voice to use. + case prebuiltVoiceConfig(PrebuiltVoiceConfig) + + /// Configuration for the custom voice to use. + case customVoiceConfig(CustomVoiceConfig) +} + +/// The configuration for the prebuilt speaker to use. +/// +/// Not just a string on the parent proto, because there'll likely be a lot +/// more options here. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct PrebuiltVoiceConfig: Encodable, Sendable { + /// The name of the preset voice to use. + let voiceName: String + + init(voiceName: String) { + self.voiceName = voiceName + } +} + +/// The configuration for the custom voice to use. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct CustomVoiceConfig: Encodable, Sendable { + /// The sample of the custom voice, in pcm16 s16e format. + let customVoiceSample: Data + + init(customVoiceSample: Data) { + self.customVoiceSample = customVoiceSample + } +} + +// MARK: - Encodable conformance + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +extension VoiceConfig: Encodable { + enum CodingKeys: CodingKey { + case prebuiltVoiceConfig + case customVoiceConfig + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .prebuiltVoiceConfig(setup): + try container.encode(setup, forKey: .prebuiltVoiceConfig) + case let .customVoiceConfig(clientContent): + try container.encode(clientContent, forKey: .customVoiceConfig) + } + } +} diff --git a/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift new file mode 100644 index 00000000000..c2b6ad6f80f --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift @@ -0,0 +1,112 @@ +// Copyright 2024 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 Foundation + +/// Represents a signed, fixed-length span of time represented +/// as a count of seconds and fractions of seconds at nanosecond +/// resolution. +/// +/// This represents a +/// [`google.protobuf.duration`](https://protobuf.dev/reference/protobuf/google.protobuf/#duration). +struct ProtoDuration { + /// Signed seconds of the span of time. + /// + /// Must be from -315,576,000,000 to +315,576,000,000 inclusive. + /// + /// Note: these bounds are computed from: + /// 60 sec/min * 60 min/hr * 24 hr/day * 365.25 days/year * 10000 years + let seconds: Int64 + + /// Signed fractions of a second at nanosecond resolution of the span of time. + /// + /// Durations less than one second are represented with a 0 + /// `seconds` field and a positive or negative `nanos` field. + /// + /// For durations of one second or more, a non-zero value for the `nanos` field must be + /// of the same sign as the `seconds` field. Must be from -999,999,999 + /// to +999,999,999 inclusive. + let nanos: Int32 + + /// Returns a `TimeInterval` representation of the `ProtoDuration`. + var timeInterval: TimeInterval { + return TimeInterval(seconds) + TimeInterval(nanos) / 1_000_000_000 + } +} + +// MARK: - Codable Conformance + +extension ProtoDuration: Decodable { + init(from decoder: any Decoder) throws { + var text = try decoder.singleValueContainer().decode(String.self) + if text.last != "s" { + AILog.warning( + code: .decodedMissingProtoDurationSuffix, + "Missing 's' at end of proto duration: \(text)." + ) + } else { + text.removeLast() + } + + let seconds: String + let nanoseconds: String + + let maybeSplit = text.split(separator: ".") + if maybeSplit.count > 2 { + AILog.warning( + code: .decodedInvalidProtoDurationString, + "Too many decimal places in proto duration (expected only 1): \(maybeSplit)." + ) + throw DecodingError.dataCorrupted(.init( + codingPath: [], + debugDescription: "Invalid proto duration string: \(text)" + )) + } + + if maybeSplit.count == 2 { + seconds = String(maybeSplit[0]) + nanoseconds = String(maybeSplit[1]) + } else { + seconds = text + nanoseconds = "0" + } + + guard let secs = Int64(seconds) else { + AILog.warning( + code: .decodedInvalidProtoDurationSeconds, + "Failed to parse the seconds to an Int64: \(seconds)." + ) + + throw DecodingError.dataCorrupted(.init( + codingPath: [], + debugDescription: "Invalid proto duration seconds: \(text)" + )) + } + + guard let fractionalSeconds = Double("0.\(nanoseconds)") else { + AILog.warning( + code: .decodedInvalidProtoDurationNanoseconds, + "Failed to parse the nanoseconds to a Double: \(nanoseconds)." + ) + + throw DecodingError.dataCorrupted(.init( + codingPath: [], + debugDescription: "Invalid proto duration nanoseconds: \(text)" + )) + } + + self.seconds = secs + nanos = Int32(fractionalSeconds * 1_000_000_000) + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Requests/CountTokensRequest.swift b/FirebaseAI/Sources/Types/Internal/Requests/CountTokensRequest.swift index f282b02096b..be3e09c3060 100644 --- a/FirebaseAI/Sources/Types/Internal/Requests/CountTokensRequest.swift +++ b/FirebaseAI/Sources/Types/Internal/Requests/CountTokensRequest.swift @@ -29,10 +29,14 @@ extension CountTokensRequest: GenerativeAIRequest { var apiConfig: APIConfig { generateContentRequest.apiConfig } - var url: URL { + func getURL() throws -> URL { let version = apiConfig.version.rawValue let endpoint = apiConfig.service.endpoint.rawValue - return URL(string: "\(endpoint)/\(version)/\(modelResourceName):countTokens")! + let urlString = "\(endpoint)/\(version)/\(modelResourceName):countTokens" + guard let url = URL(string: urlString) else { + throw AILog.makeInternalError(message: "Malformed URL: \(urlString)", code: .malformedURL) + } + return url } } diff --git a/FirebaseAI/Sources/Types/Internal/Tools/URLContext.swift b/FirebaseAI/Sources/Types/Internal/Tools/URLContext.swift new file mode 100644 index 00000000000..2033bb940f1 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Tools/URLContext.swift @@ -0,0 +1,18 @@ +// 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. + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +struct URLContext: Sendable, Encodable { + init() {} +} diff --git a/FirebaseAI/Sources/Types/Public/Backend.swift b/FirebaseAI/Sources/Types/Public/Backend.swift index 132f3a2cd72..b4b55699494 100644 --- a/FirebaseAI/Sources/Types/Public/Backend.swift +++ b/FirebaseAI/Sources/Types/Public/Backend.swift @@ -25,26 +25,28 @@ public struct Backend { /// for a list of supported locations. public static func vertexAI(location: String = "us-central1") -> Backend { return Backend( - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta), - location: location + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: location), + version: .v1beta + ) ) } /// Initializes a `Backend` configured for the Google Developer API. public static func googleAI() -> Backend { return Backend( - apiConfig: APIConfig(service: .googleAI(endpoint: .firebaseProxyProd), version: .v1beta), - location: nil + apiConfig: APIConfig( + service: .googleAI(endpoint: .firebaseProxyProd), + version: .v1beta + ) ) } // MARK: - Internal let apiConfig: APIConfig - let location: String? - init(apiConfig: APIConfig, location: String?) { + init(apiConfig: APIConfig) { self.apiConfig = apiConfig - self.location = location } } diff --git a/FirebaseAI/Sources/Types/Public/Live/AudioTranscriptionConfig.swift b/FirebaseAI/Sources/Types/Public/Live/AudioTranscriptionConfig.swift new file mode 100644 index 00000000000..365afebc5da --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/AudioTranscriptionConfig.swift @@ -0,0 +1,33 @@ +// 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. + +/// Configuration options for audio transcriptions when communicating with a model that supports the +/// Gemini Live API. +/// +/// While there are not currently any options, this will likely change in the future. For now, just +/// providing an instance of this struct will enable audio transcriptions for the corresponding +/// input or output fields. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct AudioTranscriptionConfig: Sendable { + let audioTranscriptionConfig: BidiAudioTranscriptionConfig + + init(_ audioTranscriptionConfig: BidiAudioTranscriptionConfig) { + self.audioTranscriptionConfig = audioTranscriptionConfig + } + + public init() { + self.init(BidiAudioTranscriptionConfig()) + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveAudioTranscription.swift b/FirebaseAI/Sources/Types/Public/Live/LiveAudioTranscription.swift new file mode 100644 index 00000000000..76dc112ee03 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveAudioTranscription.swift @@ -0,0 +1,26 @@ +// 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. + +/// Text transcription of some audio form during a live interaction with the model. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveAudioTranscription: Sendable { + let transcript: BidiGenerateContentTranscription + /// Text representing the model's interpretation of what the audio said. + public var text: String? { transcript.text } + + init(_ transcript: BidiGenerateContentTranscription) { + self.transcript = transcript + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveGenerationConfig.swift b/FirebaseAI/Sources/Types/Public/Live/LiveGenerationConfig.swift new file mode 100644 index 00000000000..c7033567a91 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveGenerationConfig.swift @@ -0,0 +1,153 @@ +// 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 Foundation + +/// Configuration options for live content generation. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveGenerationConfig: Sendable { + let bidiGenerationConfig: BidiGenerationConfig + let inputAudioTranscription: BidiAudioTranscriptionConfig? + let outputAudioTranscription: BidiAudioTranscriptionConfig? + + /// Creates a new ``LiveGenerationConfig`` value. + /// + /// See the + /// [Configure model parameters](https://firebase.google.com/docs/vertex-ai/model-parameters) + /// guide and the + /// [Cloud documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#generationconfig) + /// for more details. + /// + /// - Parameters: + /// - temperature:Controls the randomness of the language model's output. Higher values (for + /// example, 1.0) make the text more random and creative, while lower values (for example, + /// 0.1) make it more focused and deterministic. + /// + /// > Note: A temperature of 0 means that the highest probability tokens are always selected. + /// > In this case, responses for a given prompt are mostly deterministic, but a small amount + /// > of variation is still possible. + /// + /// > Important: The range of supported temperature values depends on the model; see the + /// > [documentation](https://firebase.google.com/docs/vertex-ai/model-parameters?platform=ios#temperature) + /// > for more details. + /// - topP: Controls diversity of generated text. Higher values (e.g., 0.9) produce more diverse + /// text, while lower values (e.g., 0.5) make the output more focused. + /// + /// The supported range is 0.0 to 1.0. + /// + /// > Important: The default `topP` value depends on the model; see the + /// > [documentation](https://firebase.google.com/docs/vertex-ai/model-parameters?platform=ios#top-p) + /// > for more details. + /// - topK: Limits the number of highest probability words the model considers when generating + /// text. For example, a topK of 40 means only the 40 most likely words are considered for the + /// next token. A higher value increases diversity, while a lower value makes the output more + /// deterministic. + /// + /// The supported range is 1 to 40. + /// + /// > Important: Support for `topK` and the default value depends on the model; see the + /// [documentation](https://firebase.google.com/docs/vertex-ai/model-parameters?platform=ios#top-k) + /// for more details. + /// - candidateCount: The number of response variations to return; defaults to 1 if not set. + /// Support for multiple candidates depends on the model; see the + /// [Cloud documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#generationconfig) + /// for more details. + /// - maxOutputTokens: Maximum number of tokens that can be generated in the response. + /// See the configure model parameters [documentation](https://firebase.google.com/docs/vertex-ai/model-parameters?platform=ios#max-output-tokens) + /// for more details. + /// - presencePenalty: Controls the likelihood of repeating the same words or phrases already + /// generated in the text. Higher values increase the penalty of repetition, resulting in more + /// diverse output. + /// + /// > Note: While both `presencePenalty` and `frequencyPenalty` discourage repetition, + /// > `presencePenalty` applies the same penalty regardless of how many times the word/phrase + /// > has already appeared, whereas `frequencyPenalty` increases the penalty for *each* + /// > repetition of a word/phrase. + /// + /// > Important: The range of supported `presencePenalty` values depends on the model; see the + /// > [Cloud documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#generationconfig) + /// > for more details + /// - frequencyPenalty: Controls the likelihood of repeating words or phrases, with the penalty + /// increasing for each repetition. Higher values increase the penalty of repetition, + /// resulting in more diverse output. + /// + /// > Note: While both `frequencyPenalty` and `presencePenalty` discourage repetition, + /// > `frequencyPenalty` increases the penalty for *each* repetition of a word/phrase, whereas + /// > `presencePenalty` applies the same penalty regardless of how many times the word/phrase + /// > has already appeared. + /// + /// > Important: The range of supported `frequencyPenalty` values depends on the model; see + /// > the + /// > [Cloud documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#generationconfig) + /// > for more details + /// - responseModalities: The data types (modalities) that may be returned in model responses. + /// + /// See the [multimodal + /// responses](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal-response-generation) + /// documentation for more details. + /// + /// > Warning: Specifying response modalities is a **Public Preview** feature, which means + /// > that it is not subject to any SLA or deprecation policy and could change in + /// > backwards-incompatible ways. + /// - speech: Controls the voice of the model, when streaming `audio` via + /// ``ResponseModality``. + /// - inputAudioTranscription: Configures (and enables) input transcriptions when streaming to + /// the model. + /// + /// Input transcripts are the model's interpretation of audio data sent to it, and they are + /// populated in model responses via ``LiveServerContent/inputAudioTranscription``. When this + /// field is set to `nil`, input transcripts are not populated in model responses. + /// - outputAudioTranscription: Configures (and enables) output transcriptions when streaming to + /// the model. + /// + /// Output transcripts are text representations of the audio the model is sending to the + /// client, and they are populated in model responses via + /// ``LiveServerContent/outputAudioTranscription``. When this + /// field is set to `nil`, output transcripts are not populated in model responses. + /// + /// > Important: Transcripts are independent to the model turn. This means transcripts may + /// > come earlier or later than when the model sends the corresponding audio responses. + public init(temperature: Float? = nil, topP: Float? = nil, topK: Int? = nil, + candidateCount: Int? = nil, maxOutputTokens: Int? = nil, + presencePenalty: Float? = nil, frequencyPenalty: Float? = nil, + responseModalities: [ResponseModality]? = nil, + speech: SpeechConfig? = nil, + inputAudioTranscription: AudioTranscriptionConfig? = nil, + outputAudioTranscription: AudioTranscriptionConfig? = nil) { + self.init( + BidiGenerationConfig( + temperature: temperature, + topP: topP, + topK: topK, + candidateCount: candidateCount, + maxOutputTokens: maxOutputTokens, + presencePenalty: presencePenalty, + frequencyPenalty: frequencyPenalty, + responseModalities: responseModalities, + speechConfig: speech?.speechConfig + ), + inputAudioTranscription: inputAudioTranscription?.audioTranscriptionConfig, + outputAudioTranscription: outputAudioTranscription?.audioTranscriptionConfig + ) + } + + init(_ bidiGenerationConfig: BidiGenerationConfig, + inputAudioTranscription: BidiAudioTranscriptionConfig? = nil, + outputAudioTranscription: BidiAudioTranscriptionConfig? = nil) { + self.bidiGenerationConfig = bidiGenerationConfig + self.inputAudioTranscription = inputAudioTranscription + self.outputAudioTranscription = outputAudioTranscription + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveGenerativeModel.swift b/FirebaseAI/Sources/Types/Public/Live/LiveGenerativeModel.swift new file mode 100644 index 00000000000..3a8236cb1d5 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveGenerativeModel.swift @@ -0,0 +1,74 @@ +// 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 Foundation + +/// A multimodal model (like Gemini) capable of real-time content generation based on +/// various input types, supporting bidirectional streaming. +/// +/// You can create a new session via ``LiveGenerativeModel/connect()``. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public final class LiveGenerativeModel { + let modelResourceName: String + let firebaseInfo: FirebaseInfo + let apiConfig: APIConfig + let generationConfig: LiveGenerationConfig? + let tools: [Tool]? + let toolConfig: ToolConfig? + let systemInstruction: ModelContent? + let urlSession: URLSession + let requestOptions: RequestOptions + + init(modelResourceName: String, + firebaseInfo: FirebaseInfo, + apiConfig: APIConfig, + generationConfig: LiveGenerationConfig? = nil, + tools: [Tool]? = nil, + toolConfig: ToolConfig? = nil, + systemInstruction: ModelContent? = nil, + urlSession: URLSession = GenAIURLSession.default, + requestOptions: RequestOptions) { + self.modelResourceName = modelResourceName + self.firebaseInfo = firebaseInfo + self.apiConfig = apiConfig + self.generationConfig = generationConfig + self.tools = tools + self.toolConfig = toolConfig + self.systemInstruction = systemInstruction + self.urlSession = urlSession + self.requestOptions = requestOptions + } + + /// Start a ``LiveSession`` with the server for bidirectional streaming. + /// + /// - Returns: A new ``LiveSession`` that you can use to stream messages to and from the server. + public func connect() async throws -> LiveSession { + let service = LiveSessionService( + modelResourceName: modelResourceName, + generationConfig: generationConfig, + urlSession: urlSession, + apiConfig: apiConfig, + firebaseInfo: firebaseInfo, + tools: tools, + toolConfig: toolConfig, + systemInstruction: systemInstruction, + requestOptions: requestOptions + ) + + try await service.connect() + + return LiveSession(service: service) + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveServerContent.swift b/FirebaseAI/Sources/Types/Public/Live/LiveServerContent.swift new file mode 100644 index 00000000000..15a8b310cf6 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveServerContent.swift @@ -0,0 +1,84 @@ +// 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. + +/// Incremental server update generated by the model in response to client +/// messages. +/// +/// Content is generated as quickly as possible, and not in realtime. Clients +/// may choose to buffer and play it out in realtime. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveServerContent: Sendable { + let serverContent: BidiGenerateContentServerContent + + /// The content that the model has generated as part of the current + /// conversation with the user. + /// + /// This can be `nil` if the message signifies something else (such + /// as the turn ending). + public var modelTurn: ModelContent? { serverContent.modelTurn } + + /// The model has finished sending data in the current turn. + /// + /// Generation will only start in response to additional client messages. + /// + /// Can be set alongside ``modelTurn``, indicating that the content is the last in the turn. + public var isTurnComplete: Bool { serverContent.turnComplete ?? false } + + /// The model was interrupted by a client message while generating data. + /// + /// If the client is playing out the content in realtime, this is a + /// good signal to stop and empty the current queue. + public var wasInterrupted: Bool { serverContent.interrupted ?? false } + + /// The model has finished _generating_ data for the current turn. + /// + /// For realtime playback, there will be a delay between when the model finishes generating + /// content and the client has finished playing back the generated content. + /// ``LiveServerContent/isGenerationComplete`` indicates that the model is done generating data, + /// while ``LiveServerContent/isTurnComplete`` indicates the model is waiting for additional + /// client messages. Sending a message during this delay may cause a + /// ``LiveServerContent/wasInterrupted`` message to be sent. + /// + /// > Important: If the model ``LiveServerContent/wasInterrupted``, this will not be set. The + /// > model will go from ``LiveServerContent/wasInterrupted`` -> + /// > ``LiveServerContent/isTurnComplete``. + public var isGenerationComplete: Bool { serverContent.generationComplete ?? false } + + /// Metadata specifying the sources used to ground generated content. + public var groundingMetadata: GroundingMetadata? { serverContent.groundingMetadata } + + /// The model's interpretation of what the client said in an audio message. + /// + /// This field is only populated when an ``AudioTranscriptionConfig`` is provided to + /// the `inputAudioTranscription` field in ``LiveGenerationConfig``. + public var inputAudioTranscription: LiveAudioTranscription? { + serverContent.inputTranscription.map { LiveAudioTranscription($0) } + } + + /// Transcription matching the model's audio response. + /// + /// This field is only populated when an ``AudioTranscriptionConfig`` is provided to + /// the `outputAudioTranscription` field in ``LiveGenerationConfig``. + /// + /// > Important: Transcripts are independent to the model turn. This means transcripts may + /// > come earlier or later than when the model sends the corresponding audio responses. + public var outputAudioTranscription: LiveAudioTranscription? { + serverContent.outputTranscription.map { LiveAudioTranscription($0) } + } + + init(_ serverContent: BidiGenerateContentServerContent) { + self.serverContent = serverContent + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveServerGoingAwayNotice.swift b/FirebaseAI/Sources/Types/Public/Live/LiveServerGoingAwayNotice.swift new file mode 100644 index 00000000000..981ddf0c251 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveServerGoingAwayNotice.swift @@ -0,0 +1,33 @@ +// 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 Foundation + +/// Server will not be able to service client soon. +/// +/// To learn more about session limits, see the docs on [Maximum session duration](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/multimodal-live#maximum-session-duration)\. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveServerGoingAwayNotice: Sendable { + let goAway: GoAway + /// The remaining time before the connection will be terminated as ABORTED. + /// + /// The minimal time returned here is specified differently together with + /// the rate limits for a given model. + public var timeLeft: TimeInterval? { goAway.timeLeft?.timeInterval } + + init(_ goAway: GoAway) { + self.goAway = goAway + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveServerMessage.swift b/FirebaseAI/Sources/Types/Public/Live/LiveServerMessage.swift new file mode 100644 index 00000000000..af6caca90c1 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveServerMessage.swift @@ -0,0 +1,81 @@ +// 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. + +/// Update from the server, generated from the model in response to client messages. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveServerMessage: Sendable { + let serverMessage: BidiGenerateContentServerMessage + + /// The type of message sent from the server. + /// - Important: Potential future additions to the ``Payload`` enum may not + /// trigger a semantic versioning major version update. If ensure forward + /// compatibility, client code should avoid exhaustive switch statements + /// over this enum by adding a default case. + public enum Payload: Sendable { + /// Content generated by the model in response to client messages. + case content(LiveServerContent) + + /// Request for the client to execute the provided functions. + case toolCall(LiveServerToolCall) + + /// Notification for the client that a previously issued ``LiveServerToolCall`` should be + /// cancelled. + case toolCallCancellation(LiveServerToolCallCancellation) + + /// Server will disconnect soon. + case goingAwayNotice(LiveServerGoingAwayNotice) + } + + /// The message sent from the server. + public let payload: Payload + + /// Metadata on the usage of the cached content. + public var usageMetadata: GenerateContentResponse.UsageMetadata? { serverMessage.usageMetadata } +} + +// MARK: - Internal parsing + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +extension LiveServerMessage { + init?(from serverMessage: BidiGenerateContentServerMessage) { + guard let payload = LiveServerMessage.Payload(from: serverMessage.messageType) else { + return nil + } + + self.serverMessage = serverMessage + self.payload = payload + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +extension LiveServerMessage.Payload { + init?(from serverMessage: BidiGenerateContentServerMessage.MessageType) { + switch serverMessage { + case .setupComplete: + // this is handled internally, and should not be surfaced to users + return nil + case let .serverContent(msg): + self = .content(LiveServerContent(msg)) + case let .toolCall(msg): + self = .toolCall(LiveServerToolCall(msg)) + case let .toolCallCancellation(msg): + self = .toolCallCancellation(LiveServerToolCallCancellation(msg)) + case let .goAway(msg): + self = .goingAwayNotice(LiveServerGoingAwayNotice(msg)) + } + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCall.swift b/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCall.swift new file mode 100644 index 00000000000..6c55ee5ff4c --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCall.swift @@ -0,0 +1,32 @@ +// 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. + +/// Request for the client to execute the provided ``functionCalls``. +/// +/// The client should return matching ``FunctionResponsePart``, where the +/// ``FunctionResponsePart/functionId`` fields correspond to individual ``FunctionCallPart``s. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +public struct LiveServerToolCall: Sendable { + let serverToolCall: BidiGenerateContentToolCall + + /// A list of ``FunctionCallPart`` to run and return responses for. + public var functionCalls: [FunctionCallPart]? { + serverToolCall.functionCalls?.map { FunctionCallPart($0) } + } + + init(_ serverToolCall: BidiGenerateContentToolCall) { + self.serverToolCall = serverToolCall + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCallCancellation.swift b/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCallCancellation.swift new file mode 100644 index 00000000000..1572c30c5bc --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCallCancellation.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. + +/// Notification for the client to cancel a previous function call from ``LiveServerToolCall``. +/// +/// The client does not need to send ``FunctionResponsePart``s for the cancelled +/// ``FunctionCallPart``s. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveServerToolCallCancellation: Sendable { + let serverToolCallCancellation: BidiGenerateContentToolCallCancellation + /// A list of function ids matching the ``FunctionCallPart/functionId`` provided in a previous + /// ``LiveServerToolCall``, where only the provided ids should be cancelled. + public var ids: [String]? { serverToolCallCancellation.ids } + + init(_ serverToolCallCancellation: BidiGenerateContentToolCallCancellation) { + self.serverToolCallCancellation = serverToolCallCancellation + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift b/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift new file mode 100644 index 00000000000..d0d0046d035 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift @@ -0,0 +1,150 @@ +// 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 Foundation + +/// A live WebSocket session, capable of streaming content to and from the model. +/// +/// Messages are streamed through ``LiveSession/responses``, and can be sent through either the +/// dedicated realtime API function (such as ``LiveSession/sendAudioRealtime(_:)`` and +/// ``LiveSession/sendTextRealtime(_:)``), or through the incremental API (such as +/// ``LiveSession/sendContent(_:turnComplete:)-6x3ae``). +/// +/// To create an instance of this class, see ``LiveGenerativeModel``. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +public final class LiveSession: Sendable { + private let service: LiveSessionService + + /// An asynchronous stream of messages from the server. + /// + /// These messages from the incremental updates from the model, for the current conversation. + public var responses: AsyncThrowingStream { service.responses } + + init(service: LiveSessionService) { + self.service = service + } + + /// Response to a ``LiveServerToolCall`` received from the server. + /// + /// This method is used both for the realtime API and the incremental API. + /// + /// - Parameters: + /// - responses: Client generated function results, matched to their respective + /// ``FunctionCallPart`` by the ``FunctionCallPart/functionId`` field. + public func sendFunctionResponses(_ responses: [FunctionResponsePart]) async { + let message = BidiGenerateContentToolResponse( + functionResponses: responses.map { $0.functionResponse } + ) + await service.send(.toolResponse(message)) + } + + /// Sends an audio input stream to the model, using the realtime API. + /// + /// To learn more about audio formats, and the required state they should be provided in, see the + /// docs on + /// [Supported audio formats](https://cloud.google.com/vertex-ai/generative-ai/docs/live-api#supported-audio-formats). + /// + /// - Parameters: + /// - audio: Raw 16-bit PCM audio at 16Hz, used to update the model on the client's + /// conversation. + public func sendAudioRealtime(_ audio: Data) async { + // TODO: (b/443984790) address when we add RealtimeInputConfig support + let message = BidiGenerateContentRealtimeInput( + audio: InlineData(data: audio, mimeType: "audio/pcm") + ) + await service.send(.realtimeInput(message)) + } + + /// Sends a video frame to the model, using the realtime API. + /// + /// Instead of raw video data, the model expects individual frames of the video, + /// sent as images. + /// + /// If your video has audio, send it separately through ``LiveSession/sendAudioRealtime(_:)``. + /// + /// For better performance, frames can also be sent at a lower rate than the video; + /// even as low as 1 frame per second. + /// + /// - Parameters: + /// - video: Encoded image data extracted from a frame of the video, used to update the model on + /// the client's conversation. + /// - mimeType: The IANA standard MIME type of the video frame data (eg; `images/png`, + /// `images/jpeg`etc.,). + public func sendVideoRealtime(_ video: Data, mimeType: String) async { + let message = BidiGenerateContentRealtimeInput( + video: InlineData(data: video, mimeType: mimeType) + ) + await service.send(.realtimeInput(message)) + } + + /// Sends a text input stream to the model, using the realtime API. + /// + /// - Parameters: + /// - text: Text content to append to the current client's conversation. + public func sendTextRealtime(_ text: String) async { + let message = BidiGenerateContentRealtimeInput(text: text) + await service.send(.realtimeInput(message)) + } + + /// Incremental update of the current conversation. + /// + /// The content is unconditionally appended to the conversation history and used as part of the + /// prompt to the model to generate content. + /// + /// Sending this message will also cause an interruption, if the server is actively generating + /// content. + /// + /// - Parameters: + /// - content: Content to append to the current conversation with the model. + /// - turnComplete: Whether the server should start generating content with the currently + /// accumulated prompt, or await additional messages before starting generation. By default, + /// the server will await additional messages. + public func sendContent(_ content: [ModelContent], turnComplete: Bool = false) async { + let message = BidiGenerateContentClientContent(turns: content, turnComplete: turnComplete) + await service.send(.clientContent(message)) + } + + /// Incremental update of the current conversation. + /// + /// The content is unconditionally appended to the conversation history and used as part of the + /// prompt to the model to generate content. + /// + /// Sending this message will also cause an interruption, if the server is actively generating + /// content. + /// + /// - Parameters: + /// - content: Content to append to the current conversation with the model (see + /// ``PartsRepresentable`` for conforming types). + /// - turnComplete: Whether the server should start generating content with the currently + /// accumulated prompt, or await additional messages before starting generation. By default, + /// the server will await additional messages. + public func sendContent(_ parts: any PartsRepresentable..., + turnComplete: Bool = false) async { + await sendContent([ModelContent(parts: parts)], turnComplete: turnComplete) + } + + /// Permanently stop the conversation with the model, and close the connection to the server + /// + /// This method will be called automatically when the ``LiveSession`` is deinitialized, but this + /// method can be called manually to explicitly end the session. + /// + /// Attempting to receive content from a closed session will cause a + /// ``LiveSessionUnexpectedClosureError`` error to be thrown. + public func close() async { + await service.close() + } + + // TODO: b(445716402) Add a start method when we support session resumption +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveSessionErrors.swift b/FirebaseAI/Sources/Types/Public/Live/LiveSessionErrors.swift new file mode 100644 index 00000000000..59a1e920e84 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveSessionErrors.swift @@ -0,0 +1,106 @@ +// 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 Foundation + +/// The model sent a message that the SDK failed to parse. +/// +/// This may indicate that the SDK version needs updating, a model is too old for the current SDK +/// version, or that the model is just +/// not supported. +/// +/// Check the `NSUnderlyingErrorKey` entry in ``LiveSessionUnsupportedMessageError/errorUserInfo`` +/// for the error that caused this. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveSessionUnsupportedMessageError: Error, Sendable, CustomNSError { + let underlyingError: Error + + init(underlyingError: Error) { + self.underlyingError = underlyingError + } + + public var errorUserInfo: [String: Any] { + [ + NSLocalizedDescriptionKey: "Failed to parse a live message from the model. Cause: \(underlyingError.localizedDescription)", + NSUnderlyingErrorKey: underlyingError, + ] + } +} + +/// The live session was closed, because the network connection was lost. +/// +/// Check the `NSUnderlyingErrorKey` entry in ``LiveSessionLostConnectionError/errorUserInfo`` for +/// the error that caused this. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveSessionLostConnectionError: Error, Sendable, CustomNSError { + let underlyingError: Error + + init(underlyingError: Error) { + self.underlyingError = underlyingError + } + + public var errorUserInfo: [String: Any] { + [ + NSLocalizedDescriptionKey: "The live session lost connection to the server. Cause: \(underlyingError.localizedDescription)", + NSUnderlyingErrorKey: underlyingError, + ] + } +} + +/// The live session was closed, but not for a reason the SDK expected. +/// +/// Check the `NSUnderlyingErrorKey` entry in ``LiveSessionUnexpectedClosureError/errorUserInfo`` +/// for the error that caused this. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveSessionUnexpectedClosureError: Error, Sendable, CustomNSError { + let underlyingError: WebSocketClosedError + + init(underlyingError: WebSocketClosedError) { + self.underlyingError = underlyingError + } + + public var errorUserInfo: [String: Any] { + [ + NSLocalizedDescriptionKey: "The live session was closed for some unexpected reason. Cause: \(underlyingError.localizedDescription)", + NSUnderlyingErrorKey: underlyingError, + ] + } +} + +/// The model refused our request to setup a live session. +/// +/// This can occur due to the model not supporting the requested response modalities, the project +/// not having access to the model, the model being invalid, or some internal error. +/// +/// Check the `NSUnderlyingErrorKey` entry in ``LiveSessionSetupError/errorUserInfo`` for the error +/// that caused this. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveSessionSetupError: Error, Sendable, CustomNSError { + let underlyingError: Error + + init(underlyingError: Error) { + self.underlyingError = underlyingError + } + + public var errorUserInfo: [String: Any] { + [ + NSLocalizedDescriptionKey: "The model did not accept the live session request. Reason: \(underlyingError.localizedDescription)", + NSUnderlyingErrorKey: underlyingError, + ] + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/SpeechConfig.swift b/FirebaseAI/Sources/Types/Public/Live/SpeechConfig.swift new file mode 100644 index 00000000000..a8e291d62f3 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/SpeechConfig.swift @@ -0,0 +1,47 @@ +// 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 Foundation + +/// Configuration for controlling the voice of the model during conversation. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct SpeechConfig: Sendable { + let speechConfig: BidiSpeechConfig + + init(_ speechConfig: BidiSpeechConfig) { + self.speechConfig = speechConfig + } + + /// Creates a new ``SpeechConfig`` value. + /// + /// - Parameters: + /// - voiceName: The name of the prebuilt voice to be used for the model's speech response. + /// + /// To learn more about the available voices, see the docs on + /// [Voice options](https://ai.google.dev/gemini-api/docs/speech-generation#voices)\. + /// - languageCode: ISO-639 language code to use when parsing text sent from the client, instead + /// of audio. By default, the model will attempt to detect the input language automatically. + /// + /// To learn which codes are supported, see the docs on + /// [Supported languages](https://ai.google.dev/gemini-api/docs/speech-generation#languages)\. + public init(voiceName: String, languageCode: String? = nil) { + self.init( + BidiSpeechConfig( + voiceConfig: .prebuiltVoiceConfig(.init(voiceName: voiceName)), + languageCode: languageCode + ) + ) + } +} diff --git a/FirebaseAI/Sources/Types/Public/Part.swift b/FirebaseAI/Sources/Types/Public/Part.swift index e0015901d61..379ba6e6a59 100644 --- a/FirebaseAI/Sources/Types/Public/Part.swift +++ b/FirebaseAI/Sources/Types/Public/Part.swift @@ -147,6 +147,11 @@ public struct FunctionCallPart: Part { public var isThought: Bool { _isThought ?? false } + /// Unique id of the function call. + /// + /// If present, the returned ``FunctionResponsePart`` should have a matching `functionId` field. + public var functionId: String? { functionCall.id } + /// Constructs a new function call part. /// /// > Note: A `FunctionCallPart` is typically received from the model, rather than created @@ -156,10 +161,24 @@ public struct FunctionCallPart: Part { /// - name: The name of the function to call. /// - args: The function parameters and values. public init(name: String, args: JSONObject) { - self.init(FunctionCall(name: name, args: args), isThought: nil, thoughtSignature: nil) + self.init(FunctionCall(name: name, args: args, id: nil), isThought: nil, thoughtSignature: nil) + } + + /// Constructs a new function call part. + /// + /// > Note: A `FunctionCallPart` is typically received from the model, rather than created + /// manually. + /// + /// - Parameters: + /// - name: The name of the function to call. + /// - args: The function parameters and values. + /// - id: Unique id of the function call. If present, the returned ``FunctionResponsePart`` + /// should have a matching ``FunctionResponsePart/functionId`` field. + public init(name: String, args: JSONObject, id: String? = nil) { + self.init(FunctionCall(name: name, args: args, id: id), isThought: nil, thoughtSignature: nil) } - init(_ functionCall: FunctionCall, isThought: Bool?, thoughtSignature: String?) { + init(_ functionCall: FunctionCall, isThought: Bool? = nil, thoughtSignature: String? = nil) { self.functionCall = functionCall _isThought = isThought self.thoughtSignature = thoughtSignature @@ -177,6 +196,9 @@ public struct FunctionResponsePart: Part { let _isThought: Bool? let thoughtSignature: String? + /// Matching ``FunctionCallPart/functionId`` for a ``FunctionCallPart``, if one was provided. + public var functionId: String? { functionResponse.id } + /// The name of the function that was called. public var name: String { functionResponse.name } @@ -196,6 +218,21 @@ public struct FunctionResponsePart: Part { ) } + /// Constructs a new `FunctionResponse`. + /// + /// - Parameters: + /// - name: The name of the function that was called. + /// - response: The function's response. + /// - functionId: Matching ``FunctionCallPart/functionId`` for a ``FunctionCallPart``, if one + /// was provided. + public init(name: String, response: JSONObject, functionId: String? = nil) { + self.init( + FunctionResponse(name: name, response: response, id: functionId), + isThought: nil, + thoughtSignature: nil + ) + } + init(_ functionResponse: FunctionResponse, isThought: Bool?, thoughtSignature: String?) { self.functionResponse = functionResponse _isThought = isThought diff --git a/FirebaseAI/Sources/Types/Public/ResponseModality.swift b/FirebaseAI/Sources/Types/Public/ResponseModality.swift index 442fed5f434..576046aa834 100644 --- a/FirebaseAI/Sources/Types/Public/ResponseModality.swift +++ b/FirebaseAI/Sources/Types/Public/ResponseModality.swift @@ -28,6 +28,7 @@ public struct ResponseModality: EncodableProtoEnum, Sendable { enum Kind: String { case text = "TEXT" case image = "IMAGE" + case audio = "AUDIO" } /// Specifies that the model should generate textual content. @@ -48,5 +49,18 @@ public struct ResponseModality: EncodableProtoEnum, Sendable { /// > backwards-incompatible ways. public static let image = ResponseModality(kind: .image) + /// **Public Preview**: Specifies that the model should generate audio content. + /// + /// Use this modality when you need the model to produce (spoken) audio responses based on the + /// provided input or prompts. + /// + /// > Warning: This is currently **only** supported via the + /// > [live api](https://firebase.google.com/docs/ai-logic/live-api)\. + /// > + /// > Furthermore, using the Firebase AI Logic SDKs with the Gemini Live API is in Public Preview, + /// > which means that the feature is not subject to any SLA or deprecation policy and could + /// > change in backwards-incompatible ways. + public static let audio = ResponseModality(kind: .audio) + let rawValue: String } diff --git a/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift b/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift new file mode 100644 index 00000000000..5689c7610f7 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift @@ -0,0 +1,37 @@ +// 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. + +/// Metadata related to the ``Tool/urlContext()`` tool. +/// +/// > Warning: URL context is a **Public Preview** feature, which means that it is not subject to +/// > any SLA or deprecation policy and could change in backwards-incompatible ways. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public struct URLContextMetadata: Sendable, Hashable { + /// List of URL metadata used to provide context to the Gemini model. + public let urlMetadata: [URLMetadata] +} + +// MARK: - Codable Conformances + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension URLContextMetadata: Decodable { + enum CodingKeys: CodingKey { + case urlMetadata + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + urlMetadata = try container.decodeIfPresent([URLMetadata].self, forKey: .urlMetadata) ?? [] + } +} diff --git a/FirebaseAI/Sources/Types/Public/URLMetadata.swift b/FirebaseAI/Sources/Types/Public/URLMetadata.swift new file mode 100644 index 00000000000..3833d71accf --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/URLMetadata.swift @@ -0,0 +1,88 @@ +// 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 Foundation + +/// Metadata for a single URL retrieved by the ``Tool/urlContext()`` tool. +/// +/// > Warning: URL context is a **Public Preview** feature, which means that it is not subject to +/// > any SLA or deprecation policy and could change in backwards-incompatible ways. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public struct URLMetadata: Sendable, Hashable { + /// Status of the URL retrieval. + public struct URLRetrievalStatus: DecodableProtoEnum, Hashable { + enum Kind: String { + case unspecified = "URL_RETRIEVAL_STATUS_UNSPECIFIED" + case success = "URL_RETRIEVAL_STATUS_SUCCESS" + case error = "URL_RETRIEVAL_STATUS_ERROR" + case paywall = "URL_RETRIEVAL_STATUS_PAYWALL" + case unsafe = "URL_RETRIEVAL_STATUS_UNSAFE" + } + + /// Internal only - Unspecified retrieval status. + static let unspecified = URLRetrievalStatus(kind: .unspecified) + + /// The URL retrieval was successful. + public static let success = URLRetrievalStatus(kind: .success) + + /// The URL retrieval failed. + public static let error = URLRetrievalStatus(kind: .error) + + /// The URL retrieval failed because the content is behind a paywall. + public static let paywall = URLRetrievalStatus(kind: .paywall) + + /// The URL retrieval failed because the content is unsafe. + public static let unsafe = URLRetrievalStatus(kind: .unsafe) + + /// Returns the raw string representation of the `URLRetrievalStatus` value. + public let rawValue: String + + static let unrecognizedValueMessageCode = + AILog.MessageCode.urlMetadataUnrecognizedURLRetrievalStatus + } + + /// The retrieved URL. + public let retrievedURL: URL? + + /// The status of the URL retrieval. + public let retrievalStatus: URLRetrievalStatus +} + +// MARK: - Codable Conformances + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension URLMetadata: Decodable { + enum CodingKeys: String, CodingKey { + case retrievedURL = "retrievedUrl" + case retrievalStatus = "urlRetrievalStatus" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let retrievedURLString = try container.decodeIfPresent(String.self, forKey: .retrievedURL), + let retrievedURL = URL(string: retrievedURLString) { + self.retrievedURL = retrievedURL + } else { + retrievedURL = nil + } + let retrievalStatus = try container.decodeIfPresent( + URLMetadata.URLRetrievalStatus.self, forKey: .retrievalStatus + ) + + self.retrievalStatus = AILog.safeUnwrap( + retrievalStatus, fallback: URLMetadata.URLRetrievalStatus(kind: .unspecified) + ) + } +} diff --git a/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj b/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj index fc62b25f132..c903fa2cee2 100644 --- a/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj +++ b/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 0E0481222EA2E51300A50172 /* DataUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E0481212EA2E51100A50172 /* DataUtils.swift */; }; + 0E460FAB2E9858E4007E26A6 /* LiveSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E460FAA2E9858E4007E26A6 /* LiveSessionTests.swift */; }; + 0EC8BAE22E98784E0075A4E0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 868A7C532CCC26B500E449DD /* Assets.xcassets */; }; 862218812D04E098007ED2D4 /* IntegrationTestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 862218802D04E08D007ED2D4 /* IntegrationTestUtils.swift */; }; 864F8F712D4980DD0002EA7E /* ImagenIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 864F8F702D4980D60002EA7E /* ImagenIntegrationTests.swift */; }; 8661385C2CC943DD00F4B78E /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8661385B2CC943DD00F4B78E /* TestApp.swift */; }; @@ -29,6 +32,7 @@ 86E850612DBAFBC3002E8D94 /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 86E850602DBAFBC3002E8D94 /* FirebaseStorage */; }; DEF0BB4F2DA74F680093E9F4 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF0BB4E2DA74F460093E9F4 /* TestHelpers.swift */; }; DEF0BB512DA9B7450093E9F4 /* SchemaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF0BB502DA9B7400093E9F4 /* SchemaTests.swift */; }; + DEF4634B2EA1AA77004E79B1 /* ServerPromptTemplateIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF4634A2EA1AA77004E79B1 /* ServerPromptTemplateIntegrationTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,6 +46,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0E0481212EA2E51100A50172 /* DataUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataUtils.swift; sourceTree = ""; }; + 0E460FAA2E9858E4007E26A6 /* LiveSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveSessionTests.swift; sourceTree = ""; }; 862218802D04E08D007ED2D4 /* IntegrationTestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTestUtils.swift; sourceTree = ""; }; 864F8F702D4980D60002EA7E /* ImagenIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagenIntegrationTests.swift; sourceTree = ""; }; 866138582CC943DD00F4B78E /* FirebaseAITestApp-SPM.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FirebaseAITestApp-SPM.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -63,6 +69,7 @@ 86D77E032D7B6C95003D155D /* InstanceConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceConfig.swift; sourceTree = ""; }; DEF0BB4E2DA74F460093E9F4 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = ""; }; DEF0BB502DA9B7400093E9F4 /* SchemaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaTests.swift; sourceTree = ""; }; + DEF4634A2EA1AA77004E79B1 /* ServerPromptTemplateIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerPromptTemplateIntegrationTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -141,6 +148,8 @@ 868A7C572CCC27AF00E449DD /* Integration */ = { isa = PBXGroup; children = ( + DEF4634A2EA1AA77004E79B1 /* ServerPromptTemplateIntegrationTests.swift */, + 0E460FAA2E9858E4007E26A6 /* LiveSessionTests.swift */, DEF0BB502DA9B7400093E9F4 /* SchemaTests.swift */, DEF0BB4E2DA74F460093E9F4 /* TestHelpers.swift */, 8689CDCB2D7F8BCF00BF426B /* CountTokensIntegrationTests.swift */, @@ -164,6 +173,7 @@ 8698D7442CD3CEF700ABA833 /* Utilities */ = { isa = PBXGroup; children = ( + 0E0481212EA2E51100A50172 /* DataUtils.swift */, 86D77E032D7B6C95003D155D /* InstanceConfig.swift */, 862218802D04E08D007ED2D4 /* IntegrationTestUtils.swift */, ); @@ -271,6 +281,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0EC8BAE22E98784E0075A4E0 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -295,12 +306,15 @@ files = ( 8689CDCC2D7F8BD700BF426B /* CountTokensIntegrationTests.swift in Sources */, 86D77E042D7B6C9D003D155D /* InstanceConfig.swift in Sources */, + 0E460FAB2E9858E4007E26A6 /* LiveSessionTests.swift in Sources */, DEF0BB512DA9B7450093E9F4 /* SchemaTests.swift in Sources */, DEF0BB4F2DA74F680093E9F4 /* TestHelpers.swift in Sources */, 868A7C4F2CCC229F00E449DD /* Credentials.swift in Sources */, + 0E0481222EA2E51300A50172 /* DataUtils.swift in Sources */, 864F8F712D4980DD0002EA7E /* ImagenIntegrationTests.swift in Sources */, 862218812D04E098007ED2D4 /* IntegrationTestUtils.swift in Sources */, 86D77DFC2D7A5340003D155D /* GenerateContentIntegrationTests.swift in Sources */, + DEF4634B2EA1AA77004E79B1 /* ServerPromptTemplateIntegrationTests.swift in Sources */, 8661386E2CC943DE00F4B78E /* IntegrationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/cat.dataset/Contents.json b/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/cat.dataset/Contents.json new file mode 100644 index 00000000000..e0a74436bb9 --- /dev/null +++ b/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/cat.dataset/Contents.json @@ -0,0 +1,13 @@ +{ + "data" : [ + { + "filename" : "videoplayback.mp4", + "idiom" : "universal", + "universal-type-identifier" : "public.mpeg-4" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/cat.dataset/videoplayback.mp4 b/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/cat.dataset/videoplayback.mp4 new file mode 100644 index 00000000000..fbc8c4feb12 Binary files /dev/null and b/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/cat.dataset/videoplayback.mp4 differ diff --git a/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/hello.dataset/Contents.json b/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/hello.dataset/Contents.json new file mode 100644 index 00000000000..7e31b8c1616 --- /dev/null +++ b/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/hello.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "hello.wav", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/hello.dataset/hello.wav b/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/hello.dataset/hello.wav new file mode 100644 index 00000000000..c065afa21c3 Binary files /dev/null and b/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/hello.dataset/hello.wav differ diff --git a/FirebaseAI/Tests/TestApp/Resources/TestApp.entitlements b/FirebaseAI/Tests/TestApp/Resources/TestApp.entitlements index ee95ab7e582..225aa48bc8c 100644 --- a/FirebaseAI/Tests/TestApp/Resources/TestApp.entitlements +++ b/FirebaseAI/Tests/TestApp/Resources/TestApp.entitlements @@ -6,5 +6,7 @@ com.apple.security.network.client + keychain-access-groups + diff --git a/FirebaseAI/Tests/TestApp/Sources/Constants.swift b/FirebaseAI/Tests/TestApp/Sources/Constants.swift index bedd6a42053..9bfc47a0e5a 100644 --- a/FirebaseAI/Tests/TestApp/Sources/Constants.swift +++ b/FirebaseAI/Tests/TestApp/Sources/Constants.swift @@ -24,9 +24,12 @@ public enum ModelNames { public static let gemini2Flash = "gemini-2.0-flash-001" public static let gemini2FlashLite = "gemini-2.0-flash-lite-001" public static let gemini2FlashPreviewImageGeneration = "gemini-2.0-flash-preview-image-generation" + public static let gemini2FlashLive = "gemini-2.0-flash-live-001" + public static let gemini2FlashLivePreview = "gemini-2.0-flash-live-preview-04-09" public static let gemini2_5_FlashImagePreview = "gemini-2.5-flash-image-preview" public static let gemini2_5_Flash = "gemini-2.5-flash" public static let gemini2_5_FlashLite = "gemini-2.5-flash-lite" + public static let gemini2_5_FlashLivePreview = "gemini-live-2.5-flash-preview" public static let gemini2_5_Pro = "gemini-2.5-pro" public static let gemma3_4B = "gemma-3-4b-it" } diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift index 1e68b640dfb..30e8f897c58 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift @@ -12,14 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseAITestApp import FirebaseAuth import FirebaseCore import FirebaseStorage import Testing -@testable import struct FirebaseAI.APIConfig +@testable import struct FirebaseAILogic.APIConfig @Suite(.serialized) struct CountTokensIntegrationTests { diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index d2fb589a432..d83a6b4a18d 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseAITestApp import FirebaseAuth import FirebaseCore @@ -23,7 +23,7 @@ import Testing import UIKit #endif // canImport(UIKit) -@testable import struct FirebaseAI.BackendError +@testable import struct FirebaseAILogic.BackendError @Suite(.serialized) struct GenerateContentIntegrationTests { @@ -424,6 +424,39 @@ struct GenerateContentIntegrationTests { } } + @Test( + "generateContent with URL Context", + arguments: InstanceConfig.allConfigs + ) + func generateContent_withURLContext_succeeds(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_Flash, + tools: [.urlContext()] + ) + let url = "https://developers.googleblog.com/en/introducing-gemma-3-270m/" + let prompt = "Write a one paragraph summary of this blog post: \(url)" + + // TODO(#15385): Remove `withKnownIssue` when the URL Context tool works consistently using the + // Gemini Developer API. + try await withKnownIssue(isIntermittent: true) { + let response = try await model.generateContent(prompt) + + let candidate = try #require(response.candidates.first) + let urlContextMetadata = try #require(candidate.urlContextMetadata) + #expect(urlContextMetadata.urlMetadata.count == 1) + let urlMetadata = try #require(urlContextMetadata.urlMetadata.first) + let retrievedURL = try #require(urlMetadata.retrievedURL) + #expect(retrievedURL == URL(string: url)) + #expect(urlMetadata.retrievalStatus == .success) + } when: { + // This issue only impacts the Gemini Developer API (Google AI), Vertex AI is unaffected. + if case .googleAI = config.apiConfig.service { + return true + } + return false + } + } + @Test(arguments: InstanceConfig.allConfigs) func generateContent_codeExecution_succeeds(_ config: InstanceConfig) async throws { let model = FirebaseAI.componentInstance(config).generativeModel( diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/ImagenIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/ImagenIntegrationTests.swift index ade781e6176..95a4f04ff2b 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/ImagenIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/ImagenIntegrationTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseAITestApp import FirebaseAuth import FirebaseCore @@ -24,7 +24,7 @@ import Testing #endif // canImport(UIKit) // TODO(#14452): Remove `@testable import` when `generateImages(prompt:gcsURI:)` is public. -@testable import class FirebaseAI.ImagenModel +@testable import class FirebaseAILogic.ImagenModel @Suite( .enabled( diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/IntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/IntegrationTests.swift index c4c49d4b45f..37353eba51a 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/IntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/IntegrationTests.swift @@ -12,14 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseAITestApp import FirebaseAuth import FirebaseCore import FirebaseStorage import XCTest -@testable import struct FirebaseAI.CountTokensRequest +@testable import struct FirebaseAILogic.CountTokensRequest // TODO(#14405): Migrate to Swift Testing and parameterize tests. final class IntegrationTests: XCTestCase { diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift new file mode 100644 index 00000000000..fecf8e80e7b --- /dev/null +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift @@ -0,0 +1,543 @@ +// 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 FirebaseAILogic +import FirebaseAITestApp +import SwiftUI +import Testing + +@testable import struct FirebaseAILogic.APIConfig + +@Suite(.serialized) +struct LiveSessionTests { + private static let arguments = InstanceConfig.liveConfigs.flatMap { config in + switch config.apiConfig.service { + case .vertexAI: + [ + (config, ModelNames.gemini2FlashLivePreview), + ] + case .googleAI: + [ + (config, ModelNames.gemini2FlashLive), + (config, ModelNames.gemini2_5_FlashLivePreview), + ] + } + } + + private let oneSecondInNanoseconds = UInt64(1e+9) + private let tools: [Tool] = [ + .functionDeclarations([ + FunctionDeclaration( + name: "getLastName", + description: "Gets the last name of a person.", + parameters: [ + "firstName": .string( + description: "The first name of the person to lookup." + ), + ] + ), + ]), + ] + private let textConfig = LiveGenerationConfig( + responseModalities: [.text] + ) + private let audioConfig = LiveGenerationConfig( + responseModalities: [.audio], + outputAudioTranscription: AudioTranscriptionConfig() + ) + + private enum SystemInstructions { + static let yesOrNo = ModelContent( + role: "system", + parts: """ + You can only respond with "yes" or "no". + """.trimmingCharacters(in: .whitespacesAndNewlines) + ) + + static let helloGoodbye = ModelContent( + role: "system", + parts: """ + When you hear "Hello" say "Goodbye". If you hear anything else, say "The audio file is \ + broken". + """.trimmingCharacters(in: .whitespacesAndNewlines) + ) + + static let lastNames = ModelContent( + role: "system", + parts: """ + When you receive a message, if the message is a single word, assume it's the first name of a \ + person, and call the getLastName tool to get the last name of said person. Only respond with \ + the last name. + """.trimmingCharacters(in: .whitespacesAndNewlines) + ) + + static let animalInVideo = ModelContent( + role: "system", + parts: """ + Send a one word response of what ANIMAL is in the video. \ + If you don't receive a video, send "Test is broken, I didn't receive a video.". + """.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + @Test(arguments: arguments) + func sendTextRealtime_receiveText(_ config: InstanceConfig, modelName: String) async throws { + let model = FirebaseAI.componentInstance(config).liveModel( + modelName: modelName, + generationConfig: textConfig, + systemInstruction: SystemInstructions.yesOrNo + ) + + let session = try await model.connect() + await session.sendTextRealtime("Does five plus five equal ten?") + + let text = try await session.collectNextTextResponse() + + await session.close() + let modelResponse = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: .punctuationCharacters) + .lowercased() + + #expect(modelResponse == "yes") + } + + @Test(arguments: arguments) + func sendTextRealtime_receiveAudioOutputTranscripts(_ config: InstanceConfig, + modelName: String) async throws { + let model = FirebaseAI.componentInstance(config).liveModel( + modelName: modelName, + generationConfig: audioConfig, + systemInstruction: SystemInstructions.yesOrNo + ) + + let session = try await model.connect() + await session.sendTextRealtime("Does five plus five equal ten?") + + let text = try await session.collectNextAudioOutputTranscript() + + await session.close() + let modelResponse = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: .punctuationCharacters) + .lowercased() + + #expect(modelResponse == "yes") + } + + @Test(arguments: arguments) + func sendAudioRealtime_receiveAudioOutputTranscripts(_ config: InstanceConfig, + modelName: String) async throws { + let model = FirebaseAI.componentInstance(config).liveModel( + modelName: modelName, + generationConfig: audioConfig, + systemInstruction: SystemInstructions.helloGoodbye + ) + + let session = try await model.connect() + + let audioFile = try #require( + NSDataAsset(name: "hello"), "Missing audio file 'hello.wav' in Assets" + ) + await session.sendAudioRealtime(audioFile.data) + // The model can't infer that we're done speaking until we send null bytes + await session.sendAudioRealtime(Data(repeating: 0, count: audioFile.data.count)) + + let text = try await session.collectNextAudioOutputTranscript() + + await session.close() + let modelResponse = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: .punctuationCharacters) + .lowercased() + + #expect(modelResponse == "goodbye") + } + + @Test(arguments: arguments) + func sendAudioRealtime_receiveText(_ config: InstanceConfig, modelName: String) async throws { + let model = FirebaseAI.componentInstance(config).liveModel( + modelName: modelName, + generationConfig: textConfig, + systemInstruction: SystemInstructions.helloGoodbye + ) + + let session = try await model.connect() + + let audioFile = try #require( + NSDataAsset(name: "hello"), "Missing audio file 'hello.wav' in Assets" + ) + await session.sendAudioRealtime(audioFile.data) + await session.sendAudioRealtime(Data(repeating: 0, count: audioFile.data.count)) + + let text = try await session.collectNextTextResponse() + + await session.close() + let modelResponse = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: .punctuationCharacters) + .lowercased() + + #expect(modelResponse == "goodbye") + } + + @Test(arguments: arguments.filter { $0.1 != ModelNames.gemini2FlashLive }) + // gemini-2.0-flash-live-001 is buggy and likes to respond to the audio or system instruction + // (eg; it will say 'okay' or 'hello', instead of following the instructions) + func sendVideoRealtime_receiveText(_ config: InstanceConfig, modelName: String) async throws { + let model = FirebaseAI.componentInstance(config).liveModel( + modelName: modelName, + generationConfig: textConfig, + systemInstruction: SystemInstructions.animalInVideo + ) + + let session = try await model.connect() + guard let videoFile = NSDataAsset(name: "cat") else { + Issue.record("Missing video file 'cat' in Assets") + return + } + + let frames = try await videoFile.videoFrames() + for frame in frames { + await session.sendVideoRealtime(frame, mimeType: "image/png") + } + + // the model doesn't respond unless we send some audio too + // vertex also responds if you send text, but google ai doesn't + // (they both respond with audio though) + guard let audioFile = NSDataAsset(name: "hello") else { + Issue.record("Missing audio file 'hello.wav' in Assets") + return + } + await session.sendAudioRealtime(audioFile.data) + await session.sendAudioRealtime(Data(repeating: 0, count: audioFile.data.count)) + + let text = try await session.collectNextTextResponse() + + await session.close() + let modelResponse = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: .punctuationCharacters) + .lowercased() + + // model response varies + #expect(["kitten", "cat", "kitty"].contains(modelResponse)) + } + + @Test(arguments: arguments) + func realtime_functionCalling(_ config: InstanceConfig, modelName: String) async throws { + let model = FirebaseAI.componentInstance(config).liveModel( + modelName: modelName, + generationConfig: textConfig, + tools: tools, + systemInstruction: SystemInstructions.lastNames + ) + + let session = try await model.connect() + await session.sendTextRealtime("Alex") + + guard let toolCall = try await session.collectNextToolCall() else { + return + } + + let functionCalls = try #require(toolCall.functionCalls) + + #expect(functionCalls.count == 1) + let functionCall = try #require(functionCalls.first) + + #expect(functionCall.name == "getLastName") + guard let response = getLastName(args: functionCall.args) else { + return + } + await session.sendFunctionResponses([ + FunctionResponsePart( + name: functionCall.name, + response: ["lastName": .string(response)], + functionId: functionCall.functionId + ), + ]) + + var text = try await session.collectNextTextResponse() + if text.isEmpty { + // The model sometimes sends an empty text response first + text = try await session.collectNextTextResponse() + } + + await session.close() + let modelResponse = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: .punctuationCharacters) + .lowercased() + + #expect(modelResponse == "smith") + } + + @Test(arguments: arguments.filter { + // TODO: (b/450982184) Remove when Vertex AI adds support for Function IDs and Cancellation + switch $0.0.apiConfig.service { + case .googleAI: + true + case .vertexAI: + false + } + }) + func realtime_functionCalling_cancellation(_ config: InstanceConfig, + modelName: String) async throws { + let model = FirebaseAI.componentInstance(config).liveModel( + modelName: modelName, + generationConfig: textConfig, + tools: tools, + systemInstruction: SystemInstructions.lastNames + ) + + let session = try await model.connect() + await session.sendTextRealtime("Alex") + + guard let toolCall = try await session.collectNextToolCall() else { + return + } + + let functionCalls = try #require(toolCall.functionCalls) + + #expect(functionCalls.count == 1) + let functionCall = try #require(functionCalls.first) + let id = try #require(functionCall.functionId) + + await session.sendTextRealtime("Actually, I don't care about the last name of Alex anymore.") + + for try await cancellation in session.responsesOf(LiveServerToolCallCancellation.self) { + #expect(cancellation.ids == [id]) + break + } + + await session.close() + } + + @Test( + arguments: arguments.filter { !$0.0.useLimitedUseAppCheckTokens } + ) + // Getting a limited use token adds too much of an overhead; we can't interrupt the model in time + func realtime_interruption(_ config: InstanceConfig, modelName: String) async throws { + let model = FirebaseAI.componentInstance(config).liveModel( + modelName: modelName, + generationConfig: audioConfig + ) + + let audioFile = try #require( + NSDataAsset(name: "hello"), "Missing audio file 'hello.wav' in Assets" + ) + + try await retry(times: 3, delayInSeconds: 2.0) { + let session = try await model.connect() + await session.sendAudioRealtime(audioFile.data) + await session.sendAudioRealtime(Data(repeating: 0, count: audioFile.data.count)) + + // Wait a second to allow the model to start generating (and cause a proper interruption) + try await Task.sleep(nanoseconds: oneSecondInNanoseconds) + await session.sendAudioRealtime(audioFile.data) + await session.sendAudioRealtime(Data(repeating: 0, count: audioFile.data.count)) + + for try await content in session.responsesOf(LiveServerContent.self) { + if content.wasInterrupted { + break + } + + if content.isTurnComplete { + throw NoInterruptionError() + } + } + } + } + + @Test(arguments: arguments) + func incremental_works(_ config: InstanceConfig, modelName: String) async throws { + let model = FirebaseAI.componentInstance(config).liveModel( + modelName: modelName, + generationConfig: textConfig, + systemInstruction: SystemInstructions.yesOrNo + ) + + let session = try await model.connect() + await session.sendContent("Does five plus") + await session.sendContent(" five equal ten?", turnComplete: true) + + let text = try await session.collectNextTextResponse() + + await session.close() + let modelResponse = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: .punctuationCharacters) + .lowercased() + + #expect(modelResponse == "yes") + } + + private func getLastName(args: JSONObject) -> String? { + guard case let .string(firstName) = args["firstName"] else { + Issue.record("Missing 'firstName' argument: \(String(describing: args))") + return nil + } + + switch firstName { + case "Alex": return "Smith" + case "Bob": return "Johnson" + default: + Issue.record("Unsupported 'firstName': \(firstName)") + return nil + } + } +} + +private extension LiveSession { + /// Collects the text that the model sends for the next turn. + /// + /// Will listen for `LiveServerContent` messages from the model, + /// incrementally keeping track of any `TextPart`s it sends. Once + /// the model signals that its turn is complete, the function will return + /// a string concatenated of all the `TextPart`s. + func collectNextTextResponse() async throws -> String { + var text = "" + + for try await content in responsesOf(LiveServerContent.self) { + text += content.modelTurn?.allText() ?? "" + + if content.isTurnComplete { + break + } + } + + return text + } + + /// Collects the audio output transcripts that the model sends for the next turn. + /// + /// Will listen for `LiveServerContent` messages from the model, + /// incrementally keeping track of any `LiveAudioTranscription`s it sends. + /// Once the model signals that its turn is complete, the function will return + /// a string concatenated of all the `LiveAudioTranscription`s. + func collectNextAudioOutputTranscript() async throws -> String { + var text = "" + + for try await content in responsesOf(LiveServerContent.self) { + text += content.outputAudioText() + + if content.isTurnComplete { + break + } + } + + return text + } + + /// Waits for the next `LiveServerToolCall` message from the model, and will return it. + /// + /// If the model instead sends `LiveServerContent`, the function will attempt to keep track of + /// any messages it sends (either via `LiveAudioTranscription` or `TextPart`), and will + /// record an issue describing the message. + /// + /// This is useful when testing function calling, as sometimes the model sends an error message, + /// does something unexpected, or will attempt to get clarification. Logging the message (instead + /// of just timing out), allows us to more easily debug such situations. + func collectNextToolCall() async throws -> LiveServerToolCall? { + var error = "" + for try await response in responses { + switch response.payload { + case let .toolCall(toolCall): + return toolCall + case let .content(content): + if let text = content.modelTurn?.allText() { + error += text + } else { + error += content.outputAudioText() + } + + if content.isTurnComplete { + Issue.record("The model didn't send a tool call. Text received: \(error)") + return nil + } + default: + continue + } + } + Issue.record("Failed to receive any responses") + return nil + } + + /// Filters responses from the model to a certain type. + /// + /// Useful when you only expect (or care about) certain types. + /// + /// ```swift + /// for try await content in session.responsesOf(LiveServerContent.self) { + /// // ... + /// } + /// ``` + /// + /// Is the equivalent to manually doing: + /// ```swift + /// for try await response in session.responses { + /// if case let .content(content) = response.payload { + /// // ... + /// } + /// } + /// ``` + func responsesOf(_: T.Type) -> AsyncCompactMapSequence, T> { + responses.compactMap { response in + switch response.payload { + case let .content(content): + if let casted = content as? T { + return casted + } + case let .toolCall(toolCall): + if let casted = toolCall as? T { + return casted + } + case let .toolCallCancellation(cancellation): + if let casted = cancellation as? T { + return casted + } + case let .goingAwayNotice(goingAway): + if let casted = goingAway as? T { + return casted + } + } + return nil + } + } +} + +private struct NoInterruptionError: Error, + CustomStringConvertible { + var description: String { "The model never sent an interrupted message." } +} + +private extension ModelContent { + /// A collection of text from all parts. + /// + /// If this doesn't contain any `TextPart`, then an empty + /// string will be returned instead. + func allText() -> String { + parts.compactMap { ($0 as? TextPart)?.text }.joined() + } +} + +extension LiveServerContent { + /// Text of the output `LiveAudioTranscript`, or an empty string if it's missing. + func outputAudioText() -> String { + outputAudioTranscription?.text ?? "" + } +} diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift index 640b353dc2f..a9e49818364 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseAITestApp import FirebaseAuth import FirebaseCore @@ -23,13 +23,11 @@ import Testing import UIKit #endif // canImport(UIKit) -@testable import struct FirebaseAI.BackendError +@testable import struct FirebaseAILogic.BackendError /// Test the schema fields. @Suite(.serialized) struct SchemaTests { - // Set temperature, topP and topK to lowest allowed values to make responses more deterministic. - let generationConfig = GenerationConfig(temperature: 0.0, topP: 0.0, topK: 1) let safetySettings = [ SafetySetting(harmCategory: .harassment, threshold: .blockLowAndAbove), SafetySetting(harmCategory: .hateSpeech, threshold: .blockLowAndAbove), @@ -37,31 +35,32 @@ struct SchemaTests { SafetySetting(harmCategory: .dangerousContent, threshold: .blockLowAndAbove), SafetySetting(harmCategory: .civicIntegrity, threshold: .blockLowAndAbove), ] - // Candidates and total token counts may differ slightly between runs due to whitespace tokens. - let tokenCountAccuracy = 1 - let storage: Storage - let userID1: String - - init() async throws { - userID1 = try await TestHelpers.getUserID() - storage = Storage.storage() - } - - @Test(arguments: InstanceConfig.allConfigs) - func generateContentSchemaItems(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2FlashLite, - generationConfig: GenerationConfig( - responseMIMEType: "application/json", - responseSchema: - .array( - items: .string(description: "The name of the city"), - description: "A list of city names", - minItems: 3, - maxItems: 5 - ) + @Test( + arguments: testConfigs( + instanceConfigs: InstanceConfig.allConfigs, + openAPISchema: .array( + items: .string(description: "The name of the city"), + description: "A list of city names", + minItems: 3, + maxItems: 5 ), + jsonSchema: [ + "type": .string("array"), + "description": .string("A list of city names"), + "items": .object([ + "type": .string("string"), + "description": .string("The name of the city"), + ]), + "minItems": .number(3), + "maxItems": .number(5), + ] + ) + ) + func generateContentItemsSchema(_ config: InstanceConfig, _ schema: SchemaType) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: SchemaTests.generationConfig(schema: schema), safetySettings: safetySettings ) let prompt = "What are the biggest cities in Canada?" @@ -73,18 +72,25 @@ struct SchemaTests { #expect(decodedJSON.count <= 5, "Expected at most 5 cities, but got \(decodedJSON.count)") } - @Test(arguments: InstanceConfig.allConfigs) - func generateContentSchemaNumberRange(_ config: InstanceConfig) async throws { + @Test(arguments: testConfigs( + instanceConfigs: InstanceConfig.allConfigs, + openAPISchema: .integer( + description: "A number", + minimum: 110, + maximum: 120 + ), + jsonSchema: [ + "type": .string("integer"), + "description": .string("A number"), + "minimum": .number(110), + "maximum": .number(120), + ] + )) + func generateContentSchemaNumberRange(_ config: InstanceConfig, + _ schema: SchemaType) async throws { let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2FlashLite, - generationConfig: GenerationConfig( - responseMIMEType: "application/json", - responseSchema: .integer( - description: "A number", - minimum: 110, - maximum: 120 - ) - ), + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: SchemaTests.generationConfig(schema: schema), safetySettings: safetySettings ) let prompt = "Give me a number" @@ -96,41 +102,83 @@ struct SchemaTests { #expect(decodedNumber <= 120.0, "Expected a number <= 120, but got \(decodedNumber)") } - @Test(arguments: InstanceConfig.allConfigs) - func generateContentSchemaNumberRangeMultiType(_ config: InstanceConfig) async throws { + @Test(arguments: testConfigs( + instanceConfigs: InstanceConfig.allConfigs, + openAPISchema: .object( + properties: [ + "productName": .string(description: "The name of the product"), + "price": .double( + description: "A price", + minimum: 10.00, + maximum: 120.00 + ), + "salePrice": .float( + description: "A sale price", + minimum: 5.00, + maximum: 90.00 + ), + "rating": .integer( + description: "A rating", + minimum: 1, + maximum: 5 + ), + ], + propertyOrdering: ["salePrice", "rating", "price", "productName"], + title: "ProductInfo" + ), + jsonSchema: [ + "type": .string("object"), + "title": .string("ProductInfo"), + "properties": .object([ + "productName": .object([ + "type": .string("string"), + "description": .string("The name of the product"), + ]), + "price": .object([ + "type": .string("number"), + "description": .string("A price"), + "minimum": .number(10.00), + "maximum": .number(120.00), + ]), + "salePrice": .object([ + "type": .string("number"), + "description": .string("A sale price"), + "minimum": .number(5.00), + "maximum": .number(90.00), + ]), + "rating": .object([ + "type": .string("integer"), + "description": .string("A rating"), + "minimum": .number(1), + "maximum": .number(5), + ]), + ]), + "required": .array([ + .string("productName"), + .string("price"), + .string("salePrice"), + .string("rating"), + ]), + "propertyOrdering": .array([ + .string("salePrice"), + .string("rating"), + .string("price"), + .string("productName"), + ]), + ] + )) + func generateContentSchemaNumberRangeMultiType(_ config: InstanceConfig, + _ schema: SchemaType) async throws { struct ProductInfo: Codable { let productName: String - let rating: Int // Will correspond to .integer in schema - let price: Double // Will correspond to .double in schema - let salePrice: Float // Will correspond to .float in schema + let rating: Int + let price: Double + let salePrice: Float } + let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2FlashLite, - generationConfig: GenerationConfig( - responseMIMEType: "application/json", - responseSchema: .object( - properties: [ - "productName": .string(description: "The name of the product"), - "price": .double( - description: "A price", - minimum: 10.00, - maximum: 120.00 - ), - "salePrice": .float( - description: "A sale price", - minimum: 5.00, - maximum: 90.00 - ), - "rating": .integer( - description: "A rating", - minimum: 1, - maximum: 5 - ), - ], - propertyOrdering: ["salePrice", "rating", "price", "productName"], - title: "ProductInfo" - ), - ), + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: SchemaTests.generationConfig(schema: schema), safetySettings: safetySettings ) let prompt = "Describe a premium wireless headphone, including a user rating and price." @@ -149,63 +197,127 @@ struct SchemaTests { #expect(rating <= 5, "Expected a rating <= 5, but got \(rating)") } - @Test(arguments: InstanceConfig.allConfigs) - func generateContentAnyOfSchema(_ config: InstanceConfig) async throws { - struct MailingAddress: Decodable { - let streetAddress: String - let city: String - - // Canadian-specific - let province: String? - let postalCode: String? - - // U.S.-specific - let state: String? - let zipCode: String? - - var isCanadian: Bool { - return province != nil && postalCode != nil && state == nil && zipCode == nil + fileprivate struct MailingAddress { + enum PostalInfo { + struct Canada: Decodable { + let province: String + let postalCode: String } - var isAmerican: Bool { - return province == nil && postalCode == nil && state != nil && zipCode != nil + struct UnitedStates: Decodable { + let state: String + let zipCode: String } + + case canada(province: String, postalCode: String) + case unitedStates(state: String, zipCode: String) } + let streetAddress: String + let city: String + let postalInfo: PostalInfo + } + + private static let generateContentAnyOfOpenAPISchema = { let streetSchema = Schema.string(description: "The civic number and street name, for example, '123 Main Street'.") let citySchema = Schema.string(description: "The name of the city.") - let canadianAddressSchema = Schema.object( + let canadaPostalInfoSchema = Schema.object( properties: [ - "streetAddress": streetSchema, - "city": citySchema, "province": .string(description: "The 2-letter province or territory code, for example, 'ON', 'QC', or 'NU'."), "postalCode": .string(description: "The postal code, for example, 'A1A 1A1'."), - ], - description: "A Canadian mailing address" + ] ) - let americanAddressSchema = Schema.object( + let unitedStatesPostalInfoSchema = Schema.object( properties: [ - "streetAddress": streetSchema, - "city": citySchema, "state": .string(description: "The 2-letter U.S. state or territory code, for example, 'CA', 'NY', or 'TX'."), "zipCode": .string(description: "The 5-digit ZIP code, for example, '12345'."), - ], - description: "A U.S. mailing address" + ] ) + let mailingAddressSchema = Schema.object(properties: [ + "streetAddress": streetSchema, + "city": citySchema, + "postalInfo": .anyOf(schemas: [canadaPostalInfoSchema, unitedStatesPostalInfoSchema]), + ]) + return Schema.array(items: mailingAddressSchema) + }() + + private static let generateContentAnyOfJSONSchema = { + let streetSchema: JSONValue = .object([ + "type": .string("string"), + "description": .string("The civic number and street name, for example, '123 Main Street'."), + ]) + let citySchema: JSONValue = .object([ + "type": .string("string"), + "description": .string("The name of the city."), + ]) + let postalInfoSchema: JSONValue = .object([ + "anyOf": .array([ + .object([ + "type": .string("object"), + "properties": .object([ + "province": .object([ + "type": .string("string"), + "description": .string( + "The 2-letter Canadian province or territory code, for example, 'ON', 'QC', or 'NU'." + ), + ]), + "postalCode": .object([ + "type": .string("string"), + "description": .string("The Canadian postal code, for example, 'A1A 1A1'."), + ]), + ]), + "required": .array([.string("province"), .string("postalCode")]), + ]), + .object([ + "type": .string("object"), + "properties": .object([ + "state": .object([ + "type": .string("string"), + "description": .string( + "The 2-letter U.S. state or territory code, for example, 'CA', 'NY', or 'TX'." + ), + ]), + "zipCode": .object([ + "type": .string("string"), + "description": .string("The 5-digit U.S. ZIP code, for example, '12345'."), + ]), + ]), + "required": .array([.string("state"), .string("zipCode")]), + ]), + ]), + ]) + let mailingAddressSchema: JSONObject = [ + "type": .string("object"), + "description": .string("A mailing address"), + "properties": .object([ + "streetAddress": streetSchema, + "city": citySchema, + "postalInfo": postalInfoSchema, + ]), + "required": .array([ + .string("streetAddress"), + .string("city"), + .string("postalInfo"), + ]), + ] + return [ + "type": .string("array"), + "items": .object(mailingAddressSchema), + ] as JSONObject + }() + + @Test(arguments: testConfigs( + instanceConfigs: InstanceConfig.allConfigs, + openAPISchema: generateContentAnyOfOpenAPISchema, + jsonSchema: generateContentAnyOfJSONSchema + )) + func generateContentAnyOfSchema(_ config: InstanceConfig, _ schema: SchemaType) async throws { let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2Flash, - generationConfig: GenerationConfig( - temperature: 0.0, - topP: 0.0, - topK: 1, - responseMIMEType: "application/json", - responseSchema: .array(items: .anyOf( - schemas: [canadianAddressSchema, americanAddressSchema] - )) - ), + modelName: ModelNames.gemini2_5_Flash, + generationConfig: SchemaTests.generationConfig(schema: schema), safetySettings: safetySettings ) let prompt = """ @@ -217,19 +329,102 @@ struct SchemaTests { let decodedAddresses = try JSONDecoder().decode([MailingAddress].self, from: jsonData) try #require(decodedAddresses.count == 3, "Expected 3 JSON addresses, got \(text).") let waterlooAddress = decodedAddresses[0] - #expect( - waterlooAddress.isCanadian, - "Expected Canadian University of Waterloo address, got \(waterlooAddress)." - ) + #expect(waterlooAddress.city == "Waterloo") + if case let .canada(province, postalCode) = waterlooAddress.postalInfo { + #expect(province == "ON") + #expect(postalCode == "N2L 3G1") + } else { + Issue.record("Expected Canadian University of Waterloo address, got \(waterlooAddress).") + } let berkeleyAddress = decodedAddresses[1] - #expect( - berkeleyAddress.isAmerican, - "Expected American UC Berkeley address, got \(berkeleyAddress)." - ) + #expect(berkeleyAddress.city == "Berkeley") + if case let .unitedStates(state, zipCode) = berkeleyAddress.postalInfo { + #expect(state == "CA") + #expect(zipCode == "94720") + } else { + Issue.record("Expected American UC Berkeley address, got \(berkeleyAddress).") + } let queensAddress = decodedAddresses[2] - #expect( - queensAddress.isCanadian, - "Expected Canadian Queen's University address, got \(queensAddress)." + #expect(queensAddress.city == "Kingston") + if case let .canada(province, postalCode) = queensAddress.postalInfo { + #expect(province == "ON") + #expect(postalCode == "K7L 3N6") + } else { + Issue.record("Expected Canadian Queen's University address, got \(queensAddress).") + } + } + + enum SchemaType: CustomTestStringConvertible { + case openAPI(Schema) + case json(JSONObject) + + var testDescription: String { + switch self { + case .openAPI: + return "OpenAPI Schema" + case .json: + return "JSON Schema" + } + } + } + + private static func generationConfig(schema: SchemaType) -> GenerationConfig { + let mimeType = "application/json" + switch schema { + case let .openAPI(openAPISchema): + return GenerationConfig(temperature: 0.0, topP: 0.0, topK: 1, responseMIMEType: mimeType, + responseSchema: openAPISchema) + case let .json(jsonSchema): + return GenerationConfig(temperature: 0.0, topP: 0.0, topK: 1, responseMIMEType: mimeType, + responseJSONSchema: jsonSchema) + } + } + + private static func testConfigs(instanceConfigs: [InstanceConfig], openAPISchema: Schema, + jsonSchema: JSONObject) -> [(InstanceConfig, SchemaType)] { + return instanceConfigs.flatMap { [($0, .openAPI(openAPISchema)), ($0, .json(jsonSchema))] } + } +} + +extension SchemaTests.MailingAddress: Decodable { + enum CodingKeys: CodingKey { + case streetAddress + case city + case postalInfo + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + streetAddress = try container.decode(String.self, forKey: .streetAddress) + city = try container.decode(String.self, forKey: .city) + let canadaPostalInfo = try? container.decode(PostalInfo.Canada.self, forKey: .postalInfo) + let unitedStatesPostalInfo = try? container.decode( + PostalInfo.UnitedStates.self, forKey: .postalInfo ) + + if canadaPostalInfo != nil, unitedStatesPostalInfo != nil { + throw DecodingError.dataCorruptedError( + forKey: .postalInfo, + in: container, + debugDescription: "Ambiguous postal info: matches both Canadian and U.S. formats." + ) + } + + if let canadaPostalInfo { + postalInfo = .canada( + province: canadaPostalInfo.province, postalCode: canadaPostalInfo.postalCode + ) + } else if let unitedStatesPostalInfo { + postalInfo = .unitedStates( + state: unitedStatesPostalInfo.state, zipCode: unitedStatesPostalInfo.zipCode + ) + } else { + throw DecodingError.typeMismatch( + PostalInfo.self, .init( + codingPath: container.codingPath, + debugDescription: "Expected Canadian or U.S. postal info." + ) + ) + } } } diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/ServerPromptTemplateIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/ServerPromptTemplateIntegrationTests.swift new file mode 100644 index 00000000000..d3b5a8c96e1 --- /dev/null +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/ServerPromptTemplateIntegrationTests.swift @@ -0,0 +1,205 @@ +// 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. + +// TODO: remove @testable when Template Chat is restored to the public API. +@testable import FirebaseAILogic +import Testing +#if canImport(UIKit) + import UIKit +#endif + +struct ServerPromptTemplateIntegrationTests { + private static let testConfigs: [InstanceConfig] = [ + .googleAI_v1beta, + .vertexAI_v1beta, + .vertexAI_v1beta_global, + ] + private static let imageGenerationTestConfigs: [InstanceConfig] = [.vertexAI_v1beta] + + @Test(arguments: testConfigs) + func generateContentWithText(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).templateGenerativeModel() + let userName = "paul" + let response = try await model.generateContent( + templateID: "greeting-5", + inputs: [ + "name": userName, + "language": "Spanish", + ] + ) + let text = try #require(response.text) + #expect(text.contains("Paul")) + } + + @Test(arguments: testConfigs) + func generateContentStream(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).templateGenerativeModel() + let userName = "paul" + let stream = try model.generateContentStream( + templateID: "greeting-5", + inputs: [ + "name": userName, + "language": "English", + ] + ) + var resultText = "" + for try await response in stream { + if let text = response.text { + resultText += text + } + } + #expect(resultText.contains("Paul")) + } + + @Test(arguments: [ + InstanceConfig.googleAI_v1beta, + InstanceConfig.vertexAI_v1beta, + ]) + func generateImages(_ config: InstanceConfig) async throws { + let imagenModel = FirebaseAI.componentInstance(config).templateImagenModel() + let imagenPrompt = "firefly" + let response = try await imagenModel.generateImages( + templateID: "image-generation-basic", + inputs: [ + "prompt": imagenPrompt, + ] + ) + #expect(response.images.count == 4) + } + + @Test(arguments: testConfigs) + func generateContentWithMedia(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).templateGenerativeModel() + #if canImport(UIKit) + let image = UIImage(systemName: "photo")! + #elseif canImport(AppKit) + let image = NSImage(systemSymbolName: "photo", accessibilityDescription: nil)! + #endif + let imageBytes = try #require( + image.jpegData(compressionQuality: 0.8), "Could not get image data." + ) + let base64Image = imageBytes.base64EncodedString() + + let response = try await model.generateContent( + templateID: "media", + inputs: [ + "imageData": [ + "isInline": true, + "mimeType": "image/jpeg", + "contents": base64Image, + ], + ] + ) + let text = try #require(response.text) + #expect(!text.isEmpty) + } + + @Test(arguments: testConfigs) + func generateContentStreamWithMedia(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).templateGenerativeModel() + #if canImport(UIKit) + let image = UIImage(systemName: "photo")! + #elseif canImport(AppKit) + let image = NSImage(systemSymbolName: "photo", accessibilityDescription: nil)! + #endif + let imageBytes = try #require( + image.jpegData(compressionQuality: 0.8), "Could not get image data." + ) + let base64Image = imageBytes.base64EncodedString() + + let stream = try model.generateContentStream( + templateID: "media", + inputs: [ + "imageData": [ + "isInline": true, + "mimeType": "image/jpeg", + "contents": base64Image, + ], + ] + ) + var resultText = "" + for try await response in stream { + if let text = response.text { + resultText += text + } + } + #expect(!resultText.isEmpty) + } + + @Test(arguments: testConfigs) + func chat(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).templateGenerativeModel() + let initialHistory = [ + ModelContent(role: "user", parts: "Hello!"), + ModelContent(role: "model", parts: "Hi there! How can I help?"), + ] + let chatSession = model.startChat(templateID: "chat-history", history: initialHistory) + + let userMessage = "What's the weather like?" + + let response = try await chatSession.sendMessage( + userMessage, + inputs: ["message": userMessage] + ) + let text = try #require(response.text) + #expect(!text.isEmpty) + #expect(chatSession.history.count == 4) + let textPart = try #require(chatSession.history[2].parts.first as? TextPart) + #expect(textPart.text == userMessage) + } + + @Test(arguments: testConfigs) + func chatStream(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).templateGenerativeModel() + let initialHistory = [ + ModelContent(role: "user", parts: "Hello!"), + ModelContent(role: "model", parts: "Hi there! How can I help?"), + ] + let chatSession = model.startChat(templateID: "chat-history", history: initialHistory) + + let userMessage = "What's the weather like?" + + let stream = try chatSession.sendMessageStream( + userMessage, + inputs: ["message": userMessage] + ) + var resultText = "" + for try await response in stream { + if let text = response.text { + resultText += text + } + } + #expect(!resultText.isEmpty) + #expect(chatSession.history.count == 4) + let textPart = try #require(chatSession.history[2].parts.first as? TextPart) + #expect(textPart.text == userMessage) + } +} + +#if canImport(AppKit) + import AppKit + + extension NSImage { + func jpegData(compressionQuality: CGFloat) -> Data? { + guard let tiffRepresentation = tiffRepresentation, + let bitmapImage = NSBitmapImageRep(data: tiffRepresentation) else { + return nil + } + return bitmapImage.representation( + using: .jpeg, + properties: [.compressionFactor: compressionQuality] + ) + } + } +#endif diff --git a/FirebaseAI/Tests/TestApp/Tests/Utilities/DataUtils.swift b/FirebaseAI/Tests/TestApp/Tests/Utilities/DataUtils.swift new file mode 100644 index 00000000000..baaefc47512 --- /dev/null +++ b/FirebaseAI/Tests/TestApp/Tests/Utilities/DataUtils.swift @@ -0,0 +1,64 @@ +// 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 AVFoundation +import SwiftUI + +extension NSDataAsset { + /// The preferred file extension for this asset, if any. + /// + /// This is set in the Asset catalog under the `File Type` field. + var fileExtension: String? { + UTType(typeIdentifier)?.preferredFilenameExtension + } + + /// Extracts `.png` frames from a video at a rate of 1 FPS. + /// + /// - Returns: + /// An array of `Data` corresponding to individual images for each frame. + func videoFrames() async throws -> [Data] { + guard let fileExtension else { + fatalError( + "Failed to find file extension; ensure the \"File Type\" is set in the asset catalog." + ) + } + + // we need a temp file so we can provide a URL to AVURLAsset + let tempFileURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: false) + .appendingPathExtension(fileExtension) + + try data.write(to: tempFileURL) + + defer { + try? FileManager.default.removeItem(at: tempFileURL) + } + + let asset = AVURLAsset(url: tempFileURL) + let generator = AVAssetImageGenerator(asset: asset) + + let duration = try await asset.load(.duration).seconds + return try stride(from: 0, to: duration, by: 1).map { seconds in + let time = CMTime(seconds: seconds, preferredTimescale: 1) + let cg = try generator.copyCGImage(at: time, actualTime: nil) + + let image = UIImage(cgImage: cg) + guard let png = image.pngData() else { + fatalError("Failed to encode image to png") + } + + return png + } + } +} diff --git a/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift b/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift index df06f43c91f..1c515957a36 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift @@ -12,33 +12,51 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseAITestApp import FirebaseCore import Testing -@testable import struct FirebaseAI.APIConfig +@testable import struct FirebaseAILogic.APIConfig struct InstanceConfig: Equatable, Encodable { static let vertexAI_v1beta = InstanceConfig( - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta) + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1"), + version: .v1beta + ) + ) + static let vertexAI_v1beta_appCheckLimitedUse = InstanceConfig( + useLimitedUseAppCheckTokens: true, + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1"), + version: .v1beta + ) ) static let vertexAI_v1beta_global = InstanceConfig( - location: "global", - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta) + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "global"), + version: .v1beta + ) ) static let vertexAI_v1beta_global_appCheckLimitedUse = InstanceConfig( - location: "global", useLimitedUseAppCheckTokens: true, - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta) + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "global"), + version: .v1beta + ) ) static let vertexAI_v1beta_staging = InstanceConfig( - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyStaging), version: .v1beta) + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyStaging, location: "us-central1"), + version: .v1beta + ) ) static let googleAI_v1beta = InstanceConfig( apiConfig: APIConfig(service: .googleAI(endpoint: .firebaseProxyProd), version: .v1beta) ) static let googleAI_v1beta_appCheckLimitedUse = InstanceConfig( + useLimitedUseAppCheckTokens: true, apiConfig: APIConfig(service: .googleAI(endpoint: .firebaseProxyProd), version: .v1beta) ) static let googleAI_v1beta_staging = InstanceConfig( @@ -66,14 +84,28 @@ struct InstanceConfig: Equatable, Encodable { // googleAI_v1beta_freeTier_bypassProxy, ] + static let liveConfigs = [ + vertexAI_v1beta, + vertexAI_v1beta_appCheckLimitedUse, + googleAI_v1beta, + googleAI_v1beta_appCheckLimitedUse, + googleAI_v1beta_freeTier, + ] + static let vertexAI_v1beta_appCheckNotConfigured = InstanceConfig( appName: FirebaseAppNames.appCheckNotConfigured, - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta) + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1"), + version: .v1beta + ) ) static let vertexAI_v1beta_appCheckNotConfigured_limitedUseTokens = InstanceConfig( appName: FirebaseAppNames.appCheckNotConfigured, useLimitedUseAppCheckTokens: true, - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta) + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1"), + version: .v1beta + ) ) static let googleAI_v1beta_appCheckNotConfigured = InstanceConfig( appName: FirebaseAppNames.appCheckNotConfigured, @@ -93,16 +125,11 @@ struct InstanceConfig: Equatable, Encodable { ] let appName: String? - let location: String? let useLimitedUseAppCheckTokens: Bool let apiConfig: APIConfig - init(appName: String? = nil, - location: String? = nil, - useLimitedUseAppCheckTokens: Bool = false, - apiConfig: APIConfig) { + init(appName: String? = nil, useLimitedUseAppCheckTokens: Bool = false, apiConfig: APIConfig) { self.appName = appName - self.location = location self.useLimitedUseAppCheckTokens = useLimitedUseAppCheckTokens self.apiConfig = apiConfig } @@ -136,7 +163,12 @@ extension InstanceConfig: CustomTestStringConvertible { case .googleAIBypassProxy: " - Bypass Proxy" } - let locationSuffix = location.map { " - \($0)" } ?? "" + let locationSuffix: String + if case let .vertexAI(_, location: location) = apiConfig.service { + locationSuffix = " - (\(location))" + } else { + locationSuffix = "" + } let appCheckLimitedUseDesignator = useLimitedUseAppCheckTokens ? " - FAC Limited-Use" : "" return """ @@ -150,21 +182,14 @@ extension FirebaseAI { static func componentInstance(_ instanceConfig: InstanceConfig) -> FirebaseAI { switch instanceConfig.apiConfig.service { case .vertexAI: - let location = instanceConfig.location ?? "us-central1" return FirebaseAI.createInstance( app: instanceConfig.app, - location: location, apiConfig: instanceConfig.apiConfig, useLimitedUseAppCheckTokens: instanceConfig.useLimitedUseAppCheckTokens ) case .googleAI: - assert( - instanceConfig.location == nil, - "The Developer API is global and does not support `location`." - ) return FirebaseAI.createInstance( app: instanceConfig.app, - location: nil, apiConfig: instanceConfig.apiConfig, useLimitedUseAppCheckTokens: instanceConfig.useLimitedUseAppCheckTokens ) diff --git a/FirebaseAI/Tests/TestApp/Tests/Utilities/IntegrationTestUtils.swift b/FirebaseAI/Tests/TestApp/Tests/Utilities/IntegrationTestUtils.swift index f133207540c..af1ef347c63 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Utilities/IntegrationTestUtils.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Utilities/IntegrationTestUtils.swift @@ -13,6 +13,7 @@ // limitations under the License. import Foundation +import Testing import XCTest enum IntegrationTestUtils { @@ -43,3 +44,35 @@ extension Numeric where Self: Strideable, Self.Stride.Magnitude: Comparable { return distance(to: other).magnitude <= accuracy.magnitude } } + +/// Retry a flakey test N times before failing. +/// +/// - Parameters: +/// - times: The amount of attempts to retry before failing. Must be greater than 0. +/// - delayInSeconds: How long to wait before performing the next attempt. +@discardableResult +func retry(times: Int, + delayInSeconds: TimeInterval = 0.1, + _ test: () async throws -> T) async throws -> T { + if times <= 0 { + precondition(times <= 0, "Times must be greater than 0.") + } + let delayNanos = UInt64(delayInSeconds * 1e+9) + var lastError: Error? + for attempt in 1 ... times { + do { return try await test() } + catch { + lastError = error + // only wait if we have more attempts + if attempt < times { + try? await Task.sleep(nanoseconds: delayNanos) + } + } + } + guard let lastError else { + // should not happen unless we change the above code in some way + fatalError("Internal error: retry loop finished without error") + } + Issue.record("Flaky test failed after \(times) attempt(s): \(String(describing: lastError))") + throw lastError +} diff --git a/FirebaseAI/Tests/Unit/APITests.swift b/FirebaseAI/Tests/Unit/APITests.swift index 16c963b1f0c..fbfd647533d 100644 --- a/FirebaseAI/Tests/Unit/APITests.swift +++ b/FirebaseAI/Tests/Unit/APITests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest #if canImport(AppKit) diff --git a/FirebaseAI/Tests/Unit/ChatTests.swift b/FirebaseAI/Tests/Unit/ChatTests.swift index 7ecebf42e28..b2e43ba610c 100644 --- a/FirebaseAI/Tests/Unit/ChatTests.swift +++ b/FirebaseAI/Tests/Unit/ChatTests.swift @@ -16,7 +16,7 @@ import FirebaseCore import Foundation import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ChatTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/GenerationConfigTests.swift b/FirebaseAI/Tests/Unit/GenerationConfigTests.swift index 22bcd70b035..edbde87fc7d 100644 --- a/FirebaseAI/Tests/Unit/GenerationConfigTests.swift +++ b/FirebaseAI/Tests/Unit/GenerationConfigTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +@testable import FirebaseAILogic import Foundation import XCTest @@ -153,4 +153,85 @@ final class GenerationConfigTests: XCTestCase { } """) } + + func testEncodeGenerationConfig_responseJSONSchema() throws { + let mimeType = "application/json" + let responseJSONSchema: JSONObject = [ + "type": .string("object"), + "title": .string("Person"), + "properties": .object([ + "firstName": .object(["type": .string("string")]), + "middleNames": .object([ + "type": .string("array"), + "items": .object(["type": .string("string")]), + "minItems": .number(0), + "maxItems": .number(3), + ]), + "lastName": .object(["type": .string("string")]), + "age": .object(["type": .string("integer")]), + ]), + "required": .array([ + .string("firstName"), + .string("middleNames"), + .string("lastName"), + .string("age"), + ]), + "propertyOrdering": .array([ + .string("firstName"), + .string("middleNames"), + .string("lastName"), + .string("age"), + ]), + "additionalProperties": .bool(false), + ] + let generationConfig = GenerationConfig( + responseMIMEType: mimeType, + responseJSONSchema: responseJSONSchema + ) + + let jsonData = try encoder.encode(generationConfig) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "responseJsonSchema" : { + "additionalProperties" : false, + "properties" : { + "age" : { + "type" : "integer" + }, + "firstName" : { + "type" : "string" + }, + "lastName" : { + "type" : "string" + }, + "middleNames" : { + "items" : { + "type" : "string" + }, + "maxItems" : 3, + "minItems" : 0, + "type" : "array" + } + }, + "propertyOrdering" : [ + "firstName", + "middleNames", + "lastName", + "age" + ], + "required" : [ + "firstName", + "middleNames", + "lastName", + "age" + ], + "title" : "Person", + "type" : "object" + }, + "responseMimeType" : "\(mimeType)" + } + """) + } } diff --git a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift index c6335142959..f04e1d387df 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift @@ -17,7 +17,7 @@ import FirebaseAuthInterop import FirebaseCore import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class GenerativeModelGoogleAITests: XCTestCase { @@ -333,6 +333,55 @@ final class GenerativeModelGoogleAITests: XCTestCase { let textPart = try XCTUnwrap(parts[2] as? TextPart) XCTAssertFalse(textPart.isThought) XCTAssertTrue(textPart.text.hasPrefix("The first 5 prime numbers are 2, 3, 5, 7, and 11.")) + let usageMetadata = try XCTUnwrap(response.usageMetadata) + XCTAssertEqual(usageMetadata.toolUsePromptTokenCount, 160) + } + + func testGenerateContent_success_urlContext() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-url-context", + withExtension: "json", + subdirectory: googleAISubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + XCTAssertEqual(response.candidates.count, 1) + let candidate = try XCTUnwrap(response.candidates.first) + let urlContextMetadata = try XCTUnwrap(candidate.urlContextMetadata) + XCTAssertEqual(urlContextMetadata.urlMetadata.count, 1) + let urlMetadata = try XCTUnwrap(urlContextMetadata.urlMetadata.first) + let retrievedURL = try XCTUnwrap(urlMetadata.retrievedURL) + XCTAssertEqual( + retrievedURL, + URL(string: "https://berkshirehathaway.com") + ) + XCTAssertEqual(urlMetadata.retrievalStatus, .success) + let usageMetadata = try XCTUnwrap(response.usageMetadata) + XCTAssertEqual(usageMetadata.toolUsePromptTokenCount, 424) + } + + func testGenerateContent_success_urlContext_mixedValidity() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-url-context-mixed-validity", + withExtension: "json", + subdirectory: googleAISubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + let candidate = try XCTUnwrap(response.candidates.first) + let urlContextMetadata = try XCTUnwrap(candidate.urlContextMetadata) + XCTAssertEqual(urlContextMetadata.urlMetadata.count, 3) + + let paywallURLMetadata = urlContextMetadata.urlMetadata[0] + XCTAssertEqual(paywallURLMetadata.retrievalStatus, .error) + + let successURLMetadata = urlContextMetadata.urlMetadata[1] + XCTAssertEqual(successURLMetadata.retrievalStatus, .success) + + let errorURLMetadata = urlContextMetadata.urlMetadata[2] + XCTAssertEqual(errorURLMetadata.retrievalStatus, .error) } func testGenerateContent_failure_invalidAPIKey() async throws { @@ -642,4 +691,27 @@ final class GenerativeModelGoogleAITests: XCTestCase { let lastResponse = try XCTUnwrap(responses.last) XCTAssertEqual(lastResponse.text, "text8") } + + func testGenerateContentStream_success_urlContext() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-url-context", + withExtension: "txt", + subdirectory: googleAISubdirectory + ) + + var responses = [GenerateContentResponse]() + let stream = try model.generateContentStream(testPrompt) + for try await response in stream { + responses.append(response) + } + + let firstResponse = try XCTUnwrap(responses.first) + let candidate = try XCTUnwrap(firstResponse.candidates.first) + let urlContextMetadata = try XCTUnwrap(candidate.urlContextMetadata) + XCTAssertEqual(urlContextMetadata.urlMetadata.count, 1) + let urlMetadata = try XCTUnwrap(urlContextMetadata.urlMetadata.first) + let retrievedURL = try XCTUnwrap(urlMetadata.retrievedURL) + XCTAssertEqual(retrievedURL, URL(string: "https://google.com")) + XCTAssertEqual(urlMetadata.retrievalStatus, .success) + } } diff --git a/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift index 847f5a8e643..b302af838c4 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift @@ -17,7 +17,7 @@ import FirebaseAuthInterop import FirebaseCore import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class GenerativeModelVertexAITests: XCTestCase { @@ -487,6 +487,73 @@ final class GenerativeModelVertexAITests: XCTestCase { XCTAssertEqual( textPart2.text, "The sum of the first 5 prime numbers (2, 3, 5, 7, and 11) is 28." ) + let usageMetadata = try XCTUnwrap(response.usageMetadata) + XCTAssertEqual(usageMetadata.toolUsePromptTokenCount, 371) + } + + func testGenerateContent_success_urlContext() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-url-context", + withExtension: "json", + subdirectory: vertexSubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + XCTAssertEqual(response.candidates.count, 1) + let candidate = try XCTUnwrap(response.candidates.first) + let urlContextMetadata = try XCTUnwrap(candidate.urlContextMetadata) + XCTAssertEqual(urlContextMetadata.urlMetadata.count, 1) + let urlMetadata = try XCTUnwrap(urlContextMetadata.urlMetadata.first) + let retrievedURL = try XCTUnwrap(urlMetadata.retrievedURL) + XCTAssertEqual( + retrievedURL, + URL(string: "https://berkshirehathaway.com") + ) + XCTAssertEqual(urlMetadata.retrievalStatus, .success) + let usageMetadata = try XCTUnwrap(response.usageMetadata) + XCTAssertEqual(usageMetadata.toolUsePromptTokenCount, 34) + XCTAssertEqual(usageMetadata.thoughtsTokenCount, 36) + } + + func testGenerateContent_success_urlContext_mixedValidity() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-url-context-mixed-validity", + withExtension: "json", + subdirectory: vertexSubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + let candidate = try XCTUnwrap(response.candidates.first) + let urlContextMetadata = try XCTUnwrap(candidate.urlContextMetadata) + XCTAssertEqual(urlContextMetadata.urlMetadata.count, 3) + + let paywallURLMetadata = urlContextMetadata.urlMetadata[0] + XCTAssertEqual(paywallURLMetadata.retrievalStatus, .error) + + let successURLMetadata = urlContextMetadata.urlMetadata[1] + XCTAssertEqual(successURLMetadata.retrievalStatus, .success) + + let errorURLMetadata = urlContextMetadata.urlMetadata[2] + XCTAssertEqual(errorURLMetadata.retrievalStatus, .error) + } + + func testGenerateContent_success_urlContext_retrievedURLPresentOnErrorStatus() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-url-context-missing-retrievedurl", + withExtension: "json", + subdirectory: vertexSubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + let candidate = try XCTUnwrap(response.candidates.first) + let urlContextMetadata = try XCTUnwrap(candidate.urlContextMetadata) + let urlMetadata = try XCTUnwrap(urlContextMetadata.urlMetadata.first) + let retrievedURL = try XCTUnwrap(urlMetadata.retrievedURL) + XCTAssertEqual(retrievedURL.absoluteString, "https://example.com/8") + XCTAssertEqual(urlMetadata.retrievalStatus, .error) } func testGenerateContent_success_image_invalidSafetyRatingsIgnored() async throws { @@ -1718,6 +1785,29 @@ final class GenerativeModelVertexAITests: XCTestCase { XCTAssertEqual(responses, 1) } + func testGenerateContentStream_success_urlContext() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-url-context", + withExtension: "txt", + subdirectory: vertexSubdirectory + ) + + var responses = [GenerateContentResponse]() + let stream = try model.generateContentStream(testPrompt) + for try await response in stream { + responses.append(response) + } + + let firstResponse = try XCTUnwrap(responses.first) + let candidate = try XCTUnwrap(firstResponse.candidates.first) + let urlContextMetadata = try XCTUnwrap(candidate.urlContextMetadata) + XCTAssertEqual(urlContextMetadata.urlMetadata.count, 1) + let urlMetadata = try XCTUnwrap(urlContextMetadata.urlMetadata.first) + let retrievedURL = try XCTUnwrap(urlMetadata.retrievedURL) + XCTAssertEqual(retrievedURL, URL(string: "https://google.com")) + XCTAssertEqual(urlMetadata.retrievalStatus, .success) + } + // MARK: - Count Tokens func testCountTokens_succeeds() async throws { diff --git a/FirebaseAI/Tests/Unit/JSONValueTests.swift b/FirebaseAI/Tests/Unit/JSONValueTests.swift index 54ac3520e77..1f1beafe922 100644 --- a/FirebaseAI/Tests/Unit/JSONValueTests.swift +++ b/FirebaseAI/Tests/Unit/JSONValueTests.swift @@ -13,7 +13,7 @@ // limitations under the License. import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class JSONValueTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/MockURLProtocol.swift b/FirebaseAI/Tests/Unit/MockURLProtocol.swift index 5385b164015..6db227d5cfb 100644 --- a/FirebaseAI/Tests/Unit/MockURLProtocol.swift +++ b/FirebaseAI/Tests/Unit/MockURLProtocol.swift @@ -21,6 +21,7 @@ class MockURLProtocol: URLProtocol, @unchecked Sendable { URLResponse, AsyncLineSequence? ))? + override class func canInit(with request: URLRequest) -> Bool { #if os(watchOS) print("MockURLProtocol cannot be used on watchOS.") @@ -33,13 +34,14 @@ class MockURLProtocol: URLProtocol, @unchecked Sendable { override class func canonicalRequest(for request: URLRequest) -> URLRequest { return request } override func startLoading() { - guard let requestHandler = MockURLProtocol.requestHandler else { - fatalError("`requestHandler` is nil.") - } guard let client = client else { fatalError("`client` is nil.") } + guard let requestHandler = MockURLProtocol.requestHandler else { + fatalError("No request handler set.") + } + Task { let (response, stream) = try requestHandler(self.request) client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) diff --git a/FirebaseAI/Tests/Unit/PartTests.swift b/FirebaseAI/Tests/Unit/PartTests.swift index f538586d439..6c5429928d0 100644 --- a/FirebaseAI/Tests/Unit/PartTests.swift +++ b/FirebaseAI/Tests/Unit/PartTests.swift @@ -15,7 +15,7 @@ import Foundation import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class PartTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/PartsRepresentableTests.swift b/FirebaseAI/Tests/Unit/PartsRepresentableTests.swift index 658db79a50e..266a64b3429 100644 --- a/FirebaseAI/Tests/Unit/PartsRepresentableTests.swift +++ b/FirebaseAI/Tests/Unit/PartsRepresentableTests.swift @@ -23,7 +23,7 @@ import XCTest import CoreImage #endif // canImport(CoreImage) -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class PartsRepresentableTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/RequestOptionsTest.swift b/FirebaseAI/Tests/Unit/RequestOptionsTest.swift index 5c03a6b63f4..92ab65f1f95 100644 --- a/FirebaseAI/Tests/Unit/RequestOptionsTest.swift +++ b/FirebaseAI/Tests/Unit/RequestOptionsTest.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class RequestOptionsTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/SafetyTests.swift b/FirebaseAI/Tests/Unit/SafetyTests.swift index 4a1e07e04e3..b4422b61fb5 100644 --- a/FirebaseAI/Tests/Unit/SafetyTests.swift +++ b/FirebaseAI/Tests/Unit/SafetyTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class SafetyTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Snippets/ChatSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/ChatSnippets.swift index cfd8089b65b..67bd5cb9a4a 100644 --- a/FirebaseAI/Tests/Unit/Snippets/ChatSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/ChatSnippets.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest diff --git a/FirebaseAI/Tests/Unit/Snippets/CloudStorageSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/CloudStorageSnippets.swift index 63b1be6d069..3d04e156312 100644 --- a/FirebaseAI/Tests/Unit/Snippets/CloudStorageSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/CloudStorageSnippets.swift @@ -14,7 +14,7 @@ #if SWIFT_PACKAGE // The FirebaseStorage dependency has only been added in Package.swift. - import FirebaseAI + import FirebaseAILogic import FirebaseCore import FirebaseStorage diff --git a/FirebaseAI/Tests/Unit/Snippets/CountTokensSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/CountTokensSnippets.swift index 8b8e37368f9..37c4f7e3c04 100644 --- a/FirebaseAI/Tests/Unit/Snippets/CountTokensSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/CountTokensSnippets.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest diff --git a/FirebaseAI/Tests/Unit/Snippets/FunctionCallingSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/FunctionCallingSnippets.swift index e8ef9bf512c..dac1dea76ba 100644 --- a/FirebaseAI/Tests/Unit/Snippets/FunctionCallingSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/FunctionCallingSnippets.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest diff --git a/FirebaseAI/Tests/Unit/Snippets/LiveSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/LiveSnippets.swift new file mode 100644 index 00000000000..6b183d958b4 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Snippets/LiveSnippets.swift @@ -0,0 +1,259 @@ +// 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 FirebaseAILogic +import FirebaseCore +import XCTest + +// These snippet tests are intentionally skipped in CI jobs; see the README file in this directory +// for instructions on running them manually. + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +final class LiveSnippets: XCTestCase { + override func setUpWithError() throws { + try FirebaseApp.configureDefaultAppForSnippets() + } + + override func tearDown() async throws { + await FirebaseApp.deleteDefaultAppForSnippets() + } + + func sendAudioReceiveAudio() async throws { + // Initialize the Vertex AI Gemini API backend service + // Set the location to `us-central1` (the flash-live model is only supported in that location) + // Create a `LiveGenerativeModel` instance with the flash-live model (only model that supports + // the Live API) + let model = FirebaseAI.firebaseAI(backend: .vertexAI(location: "us-central1")).liveModel( + modelName: "gemini-2.0-flash-exp", + // Configure the model to respond with audio + generationConfig: LiveGenerationConfig( + responseModalities: [.audio] + ) + ) + + do { + let session = try await model.connect() + + // Load the audio file, or tap a microphone + guard let audioFile = NSDataAsset(name: "audio.pcm") else { + fatalError("Failed to load audio file") + } + + // Provide the audio data + await session.sendAudioRealtime(audioFile.data) + + for try await message in session.responses { + if case let .content(content) = message.payload { + content.modelTurn?.parts.forEach { part in + if let part = part as? InlineDataPart, part.mimeType.starts(with: "audio/pcm") { + // Handle 16bit pcm audio data at 24khz + playAudio(part.data) + } + } + // Optional: if you don't require to send more requests. + if content.isTurnComplete { + await session.close() + } + } + } + } catch { + fatalError(error.localizedDescription) + } + } + + func sendAudioReceiveText() async throws { + // Initialize the Vertex AI Gemini API backend service + // Set the location to `us-central1` (the flash-live model is only supported in that location) + // Create a `LiveGenerativeModel` instance with the flash-live model (only model that supports + // the Live API) + let model = FirebaseAI.firebaseAI(backend: .googleAI()).liveModel( + modelName: "gemini-live-2.5-flash-preview", + // Configure the model to respond with text + generationConfig: LiveGenerationConfig( + responseModalities: [.text] + ) + ) + + do { + let session = try await model.connect() + + // Load the audio file, or tap a microphone + guard let audioFile = NSDataAsset(name: "audio.pcm") else { + fatalError("Failed to load audio file") + } + + // Provide the audio data + await session.sendAudioRealtime(audioFile.data) + + var outputText = "" + for try await message in session.responses { + if case let .content(content) = message.payload { + content.modelTurn?.parts.forEach { part in + if let part = part as? TextPart { + outputText += part.text + } + } + // Optional: if you don't require to send more requests. + if content.isTurnComplete { + await session.close() + } + } + } + + // Output received from the server. + print(outputText) + } catch { + fatalError(error.localizedDescription) + } + } + + func sendTextReceiveAudio() async throws { + // Initialize the Gemini Developer API backend service + // Create a `LiveModel` instance with the flash-live model (only model that supports the Live + // API) + let model = FirebaseAI.firebaseAI(backend: .googleAI()).liveModel( + modelName: "gemini-live-2.5-flash-preview", + // Configure the model to respond with audio + generationConfig: LiveGenerationConfig( + responseModalities: [.audio] + ) + ) + + do { + let session = try await model.connect() + + // Provide a text prompt + let text = "tell a short story" + + await session.sendTextRealtime(text) + + for try await message in session.responses { + if case let .content(content) = message.payload { + content.modelTurn?.parts.forEach { part in + if let part = part as? InlineDataPart, part.mimeType.starts(with: "audio/pcm") { + // Handle 16bit pcm audio data at 24khz + playAudio(part.data) + } + } + // Optional: if you don't require to send more requests. + if content.isTurnComplete { + await session.close() + } + } + } + } catch { + fatalError(error.localizedDescription) + } + } + + func sendTextReceiveText() async throws { + // Initialize the Gemini Developer API backend service + // Create a `LiveModel` instance with the flash-live model (only model that supports the Live + // API) + let model = FirebaseAI.firebaseAI(backend: .googleAI()).liveModel( + modelName: "gemini-live-2.5-flash-preview", + // Configure the model to respond with audio + generationConfig: LiveGenerationConfig( + responseModalities: [.audio] + ) + ) + + do { + let session = try await model.connect() + + // Provide a text prompt + let text = "tell a short story" + + await session.sendTextRealtime(text) + + for try await message in session.responses { + if case let .content(content) = message.payload { + content.modelTurn?.parts.forEach { part in + if let part = part as? InlineDataPart, part.mimeType.starts(with: "audio/pcm") { + // Handle 16bit pcm audio data at 24khz + playAudio(part.data) + } + } + // Optional: if you don't require to send more requests. + if content.isTurnComplete { + await session.close() + } + } + } + } catch { + fatalError(error.localizedDescription) + } + } + + func changeVoiceAndLanguage() { + let model = FirebaseAI.firebaseAI(backend: .googleAI()).liveModel( + modelName: "gemini-live-2.5-flash-preview", + // Configure the model to use a specific voice for its audio response + generationConfig: LiveGenerationConfig( + responseModalities: [.audio], + speech: SpeechConfig(voiceName: "Fenrir") + ) + ) + + // Not part of snippet + silenceWarning(model) + } + + func modelParameters() { + // ... + + // Set parameter values in a `LiveGenerationConfig` (example values shown here) + let config = LiveGenerationConfig( + temperature: 0.9, + topP: 0.1, + topK: 16, + maxOutputTokens: 200, + responseModalities: [.audio], + speech: SpeechConfig(voiceName: "Fenrir") + ) + + // Initialize the Vertex AI Gemini API backend service + // Specify the config as part of creating the `LiveGenerativeModel` instance + let model = FirebaseAI.firebaseAI(backend: .googleAI()).liveModel( + modelName: "gemini-live-2.5-flash-preview", + generationConfig: config + ) + + // ... + + // Not part of snippet + silenceWarning(model) + } + + func systemInstructions() { + // Specify the system instructions as part of creating the `LiveGenerativeModel` instance + let model = FirebaseAI.firebaseAI(backend: .googleAI()).liveModel( + modelName: "gemini-live-2.5-flash-preview", + systemInstruction: ModelContent(role: "system", parts: "You are a cat. Your name is Neko.") + ) + + // Not part of snippet + silenceWarning(model) + } + + private func playAudio(_ data: Data) { + // Use AVAudioPlayerNode or something akin to play back audio + } + + /// This function only exists to silence the "unused value" warnings. + /// + /// This allows us to ensure the snippets match devsite. + private func silenceWarning(_ model: LiveGenerativeModel) {} +} diff --git a/FirebaseAI/Tests/Unit/Snippets/MultimodalSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/MultimodalSnippets.swift index eeda8052cc6..7e8af1e3882 100644 --- a/FirebaseAI/Tests/Unit/Snippets/MultimodalSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/MultimodalSnippets.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest diff --git a/FirebaseAI/Tests/Unit/Snippets/README.md b/FirebaseAI/Tests/Unit/Snippets/README.md index 6c4313f6d57..77f9aa4ee2e 100644 --- a/FirebaseAI/Tests/Unit/Snippets/README.md +++ b/FirebaseAI/Tests/Unit/Snippets/README.md @@ -5,6 +5,6 @@ documentation continue to compile. They are intentionally skipped in CI but can be manually run to verify expected behavior / outputs. To run the tests, place a valid `GoogleService-Info.plist` file in the -[`FirebaseVertexAI/Tests/Unit/Resources`](https://github.com/firebase/firebase-ios-sdk/tree/main/FirebaseVertexAI/Tests/Unit/Resources) +[`FirebaseAI/Tests/Unit/Resources`](https://github.com/firebase/firebase-ios-sdk/tree/main/FirebaseAI/Tests/Unit/Resources) folder. They may then be invoked individually or alongside the rest of the unit tests in Xcode. diff --git a/FirebaseAI/Tests/Unit/Snippets/StructuredOutputSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/StructuredOutputSnippets.swift index 8db4f803461..17c426c3651 100644 --- a/FirebaseAI/Tests/Unit/Snippets/StructuredOutputSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/StructuredOutputSnippets.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest diff --git a/FirebaseAI/Tests/Unit/Snippets/TextSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/TextSnippets.swift index 31c54648c5a..47ee865585d 100644 --- a/FirebaseAI/Tests/Unit/Snippets/TextSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/TextSnippets.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest diff --git a/FirebaseAI/Tests/Unit/TemplateChatSessionTests.swift b/FirebaseAI/Tests/Unit/TemplateChatSessionTests.swift new file mode 100644 index 00000000000..3ff5ad14ff0 --- /dev/null +++ b/FirebaseAI/Tests/Unit/TemplateChatSessionTests.swift @@ -0,0 +1,121 @@ +// 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. + +@testable import FirebaseAILogic +import FirebaseCore +import XCTest + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class TemplateChatSessionTests: XCTestCase { + var model: TemplateGenerativeModel! + var urlSession: URLSession! + + override func setUp() { + super.setUp() + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + urlSession = URLSession(configuration: configuration) + let firebaseInfo = GenerativeModelTestUtil.testFirebaseInfo() + let generativeAIService = GenerativeAIService( + firebaseInfo: firebaseInfo, + urlSession: urlSession + ) + let apiConfig = APIConfig(service: .googleAI(endpoint: .firebaseProxyProd), version: .v1beta) + model = TemplateGenerativeModel(generativeAIService: generativeAIService, apiConfig: apiConfig) + } + + func testSendMessage() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-basic-reply-short", + withExtension: "json", + subdirectory: "mock-responses/googleai", + isTemplateRequest: true + ) + let chat = model.startChat(templateID: "test-template") + let response = try await chat.sendMessage("Hello", inputs: ["name": "test"]) + XCTAssertEqual(chat.history.count, 2) + XCTAssertEqual(chat.history[0].role, "user") + XCTAssertEqual((chat.history[0].parts.first as? TextPart)?.text, "Hello") + XCTAssertEqual(chat.history[1].role, "model") + XCTAssertEqual( + (chat.history[1].parts.first as? TextPart)?.text, + "Google's headquarters, also known as the Googleplex, is located in **Mountain View, California**.\n" + ) + XCTAssertEqual(response.candidates.count, 1) + } + + func testSendMessageStream() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-basic-reply-short", + withExtension: "txt", + subdirectory: "mock-responses/googleai", + isTemplateRequest: true + ) + let chat = model.startChat(templateID: "test-template") + let stream = try chat.sendMessageStream("Hello", inputs: ["name": "test"]) + + let content = try await GenerativeModelTestUtil.collectTextFromStream(stream) + + XCTAssertEqual(content, "The capital of Wyoming is **Cheyenne**.\n") + XCTAssertEqual(chat.history.count, 2) + XCTAssertEqual(chat.history[0].role, "user") + XCTAssertEqual((chat.history[0].parts.first as? TextPart)?.text, "Hello") + XCTAssertEqual(chat.history[1].role, "model") + } + + func testSendMessageWithModelContent() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-basic-reply-short", + withExtension: "json", + subdirectory: "mock-responses/googleai", + isTemplateRequest: true + ) + let chat = model.startChat(templateID: "test-template") + let response = try await chat.sendMessage( + [ModelContent(parts: [TextPart("Hello")])], + inputs: ["name": "test"] + ) + XCTAssertEqual(chat.history.count, 2) + XCTAssertEqual(chat.history[0].role, "user") + XCTAssertEqual((chat.history[0].parts.first as? TextPart)?.text, "Hello") + XCTAssertEqual(chat.history[1].role, "model") + XCTAssertEqual( + (chat.history[1].parts.first as? TextPart)?.text, + "Google's headquarters, also known as the Googleplex, is located in **Mountain View, California**.\n" + ) + XCTAssertEqual(response.candidates.count, 1) + } + + func testSendMessageStreamWithModelContent() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-basic-reply-short", + withExtension: "txt", + subdirectory: "mock-responses/googleai", + isTemplateRequest: true + ) + let chat = model.startChat(templateID: "test-template") + let stream = try chat.sendMessageStream( + [ModelContent(parts: [TextPart("Hello")])], + inputs: ["name": "test"] + ) + + let content = try await GenerativeModelTestUtil.collectTextFromStream(stream) + + XCTAssertEqual(content, "The capital of Wyoming is **Cheyenne**.\n") + XCTAssertEqual(chat.history.count, 2) + XCTAssertEqual(chat.history[0].role, "user") + XCTAssertEqual((chat.history[0].parts.first as? TextPart)?.text, "Hello") + XCTAssertEqual(chat.history[1].role, "model") + } +} diff --git a/FirebaseAI/Tests/Unit/TemplateGenerativeModelTests.swift b/FirebaseAI/Tests/Unit/TemplateGenerativeModelTests.swift new file mode 100644 index 00000000000..a9994b8cf7a --- /dev/null +++ b/FirebaseAI/Tests/Unit/TemplateGenerativeModelTests.swift @@ -0,0 +1,72 @@ +// 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. + +@testable import FirebaseAILogic +import FirebaseCore +import XCTest + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class TemplateGenerativeModelTests: XCTestCase { + var urlSession: URLSession! + var model: TemplateGenerativeModel! + + override func setUp() { + super.setUp() + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + urlSession = URLSession(configuration: configuration) + let firebaseInfo = GenerativeModelTestUtil.testFirebaseInfo() + let generativeAIService = GenerativeAIService( + firebaseInfo: firebaseInfo, + urlSession: urlSession + ) + let apiConfig = APIConfig(service: .googleAI(endpoint: .firebaseProxyProd), version: .v1beta) + model = TemplateGenerativeModel(generativeAIService: generativeAIService, apiConfig: apiConfig) + } + + func testGenerateContent() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-basic-reply-short", + withExtension: "json", + subdirectory: "mock-responses/googleai", + isTemplateRequest: true + ) + + let response = try await model.generateContent( + templateID: "test-template", + inputs: ["name": "test"] + ) + XCTAssertEqual( + response.text, + "Google's headquarters, also known as the Googleplex, is located in **Mountain View, California**.\n" + ) + } + + func testGenerateContentStream() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-basic-reply-short", + withExtension: "txt", + subdirectory: "mock-responses/googleai", + isTemplateRequest: true + ) + + let stream = try model.generateContentStream( + templateID: "test-template", + inputs: ["name": "test"] + ) + + let content = try await GenerativeModelTestUtil.collectTextFromStream(stream) + XCTAssertEqual(content, "The capital of Wyoming is **Cheyenne**.\n") + } +} diff --git a/FirebaseAI/Tests/Unit/TemplateImagenModelTests.swift b/FirebaseAI/Tests/Unit/TemplateImagenModelTests.swift new file mode 100644 index 00000000000..04712b377b8 --- /dev/null +++ b/FirebaseAI/Tests/Unit/TemplateImagenModelTests.swift @@ -0,0 +1,52 @@ +// 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 of 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. + +@testable import FirebaseAILogic +import XCTest + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class TemplateImagenModelTests: XCTestCase { + var urlSession: URLSession! + var model: TemplateImagenModel! + + override func setUp() { + super.setUp() + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + urlSession = URLSession(configuration: configuration) + let firebaseInfo = GenerativeModelTestUtil.testFirebaseInfo() + let generativeAIService = GenerativeAIService( + firebaseInfo: firebaseInfo, + urlSession: urlSession + ) + let apiConfig = APIConfig(service: .googleAI(endpoint: .firebaseProxyProd), version: .v1beta) + model = TemplateImagenModel(generativeAIService: generativeAIService, apiConfig: apiConfig) + } + + func testGenerateImages() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-generate-images-base64", + withExtension: "json", + subdirectory: "mock-responses/vertexai", + isTemplateRequest: true + ) + + let response = try await model.generateImages( + templateID: "test-template", + inputs: ["prompt": "a cat picture"] + ) + XCTAssertEqual(response.images.count, 4) + XCTAssertNotNil(response.images.first?.data) + } +} diff --git a/FirebaseAI/Tests/Unit/TemplateInputTests.swift b/FirebaseAI/Tests/Unit/TemplateInputTests.swift new file mode 100644 index 00000000000..2ed428be12b --- /dev/null +++ b/FirebaseAI/Tests/Unit/TemplateInputTests.swift @@ -0,0 +1,29 @@ +// 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. + +@testable import FirebaseAILogic +import XCTest + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class TemplateInputTests: XCTestCase { + func testInitWithFloat() throws { + let floatValue: Float = 3.14 + let templateInput = try TemplateInput(value: floatValue) + guard case let .double(doubleValue) = templateInput else { + XCTFail("Expected a .double case, but got \(templateInput)") + return + } + XCTAssertEqual(doubleValue, Double(floatValue), accuracy: 1e-6) + } +} diff --git a/FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift b/FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift new file mode 100644 index 00000000000..3223a2abe2e --- /dev/null +++ b/FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift @@ -0,0 +1,23 @@ +// 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. + +@testable import FirebaseAILogic + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension FirebaseAI { + static let defaultVertexAIAPIConfig = APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1"), + version: .v1beta + ) +} diff --git a/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift b/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift index ee4f47bc5b0..84062c58a2a 100644 --- a/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift +++ b/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift @@ -18,7 +18,7 @@ import FirebaseCore import Foundation import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) enum GenerativeModelTestUtil { @@ -30,10 +30,12 @@ enum GenerativeModelTestUtil { timeout: TimeInterval = RequestOptions().timeout, appCheckToken: String? = nil, authToken: String? = nil, - dataCollection: Bool = true) throws -> ((URLRequest) throws -> ( - URLResponse, - AsyncLineSequence? - )) { + dataCollection: Bool = true, + isTemplateRequest: Bool = false) throws + -> ((URLRequest) throws -> ( + URLResponse, + AsyncLineSequence? + )) { // Skip tests using MockURLProtocol on watchOS; unsupported in watchOS 2 and later, see // https://developer.apple.com/documentation/foundation/urlprotocol for details. #if os(watchOS) @@ -45,7 +47,14 @@ enum GenerativeModelTestUtil { ) return { request in let requestURL = try XCTUnwrap(request.url) - XCTAssertEqual(requestURL.path.occurrenceCount(of: "models/"), 1) + if isTemplateRequest { + XCTAssertEqual( + requestURL.path.occurrenceCount(of: "templates/test-template:template"), + 1 + ) + } else { + XCTAssertEqual(requestURL.path.occurrenceCount(of: "models/"), 1) + } XCTAssertEqual(request.timeoutInterval, timeout) let apiClientTags = try XCTUnwrap(request.value(forHTTPHeaderField: "x-goog-api-client")) .components(separatedBy: " ") @@ -79,6 +88,19 @@ enum GenerativeModelTestUtil { #endif // os(watchOS) } + static func collectTextFromStream(_ stream: AsyncThrowingStream< + GenerateContentResponse, + Error + >) async throws -> String { + var content = "" + for try await response in stream { + if let text = response.text { + content += text + } + } + return content + } + static func nonHTTPRequestHandler() throws -> ((URLRequest) -> ( URLResponse, AsyncLineSequence? 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)\")") + } +} diff --git a/FirebaseAI/Tests/Unit/Types/BackendTests.swift b/FirebaseAI/Tests/Unit/Types/BackendTests.swift index e4e87784e68..5193918849e 100644 --- a/FirebaseAI/Tests/Unit/Types/BackendTests.swift +++ b/FirebaseAI/Tests/Unit/Types/BackendTests.swift @@ -14,32 +14,30 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic final class BackendTests: XCTestCase { func testVertexAI_defaultLocation() { let expectedAPIConfig = APIConfig( - service: .vertexAI(endpoint: .firebaseProxyProd), + service: .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1"), version: .v1beta ) let backend = Backend.vertexAI() XCTAssertEqual(backend.apiConfig, expectedAPIConfig) - XCTAssertEqual(backend.location, "us-central1") } func testVertexAI_customLocation() { + let customLocation = "europe-west1" let expectedAPIConfig = APIConfig( - service: .vertexAI(endpoint: .firebaseProxyProd), + service: .vertexAI(endpoint: .firebaseProxyProd, location: customLocation), version: .v1beta ) - let customLocation = "europe-west1" let backend = Backend.vertexAI(location: customLocation) XCTAssertEqual(backend.apiConfig, expectedAPIConfig) - XCTAssertEqual(backend.location, customLocation) } func testGoogleAI() { @@ -51,6 +49,5 @@ final class BackendTests: XCTestCase { let backend = Backend.googleAI() XCTAssertEqual(backend.apiConfig, expectedAPIConfig) - XCTAssertNil(backend.location) } } diff --git a/FirebaseAI/Tests/Unit/Types/CitationMetadataTests.swift b/FirebaseAI/Tests/Unit/Types/CitationMetadataTests.swift index d75325f1a88..4c908813d68 100644 --- a/FirebaseAI/Tests/Unit/Types/CitationMetadataTests.swift +++ b/FirebaseAI/Tests/Unit/Types/CitationMetadataTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class CitationMetadataTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/CitationTests.swift b/FirebaseAI/Tests/Unit/Types/CitationTests.swift index ced45526721..1a372d8fd47 100644 --- a/FirebaseAI/Tests/Unit/Types/CitationTests.swift +++ b/FirebaseAI/Tests/Unit/Types/CitationTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import XCTest @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) diff --git a/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift b/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift index a53d215359f..1d2a86d4526 100644 --- a/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift +++ b/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift @@ -12,11 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +@testable import FirebaseAILogic import XCTest @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class GenerateContentResponseTests: XCTestCase { + let jsonDecoder = JSONDecoder() + // MARK: - GenerateContentResponse Computed Properties func testGenerateContentResponse_inlineDataParts_success() throws { @@ -106,4 +108,89 @@ final class GenerateContentResponseTests: XCTestCase { "functionCalls should be empty when there are no candidates." ) } + + // MARK: - Decoding Tests + + func testDecodeCandidate_emptyURLMetadata_urlContextMetadataIsNil() throws { + let json = """ + { + "content": { "role": "model", "parts": [ { "text": "Some text." } ] }, + "finishReason": "STOP", + "urlContextMetadata": { "urlMetadata": [] } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let candidate = try jsonDecoder.decode(Candidate.self, from: jsonData) + + XCTAssertNil( + candidate.urlContextMetadata, + "urlContextMetadata should be nil if the `urlMetadata` array is empty in the candidate." + ) + XCTAssertEqual(candidate.content.role, "model") + let part = try XCTUnwrap(candidate.content.parts.first) + let textPart = try XCTUnwrap(part as? TextPart) + XCTAssertEqual(textPart.text, "Some text.") + XCTAssertFalse(textPart.isThought) + XCTAssertEqual(candidate.finishReason, .stop) + } + + func testDecodeCandidate_missingURLMetadata_urlContextMetadataIsNil() throws { + let json = """ + { + "content": { "role": "model", "parts": [ { "text": "Some text." } ] }, + "finishReason": "STOP" + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let candidate = try jsonDecoder.decode(Candidate.self, from: jsonData) + + XCTAssertNil( + candidate.urlContextMetadata, + "urlContextMetadata should be nil if `urlMetadata` is not provided in the candidate." + ) + XCTAssertEqual(candidate.content.role, "model") + let part = try XCTUnwrap(candidate.content.parts.first) + let textPart = try XCTUnwrap(part as? TextPart) + XCTAssertEqual(textPart.text, "Some text.") + XCTAssertFalse(textPart.isThought) + XCTAssertEqual(candidate.finishReason, .stop) + } + + // MARK: - Candidate.isEmpty + + func testCandidateIsEmpty_allEmpty_isTrue() throws { + let candidate = Candidate( + content: ModelContent(parts: []), + safetyRatings: [], + finishReason: nil, + citationMetadata: nil, + groundingMetadata: nil, + urlContextMetadata: nil + ) + + XCTAssertTrue(candidate.isEmpty, "A candidate with no content should be empty.") + } + + func testCandidateIsEmpty_withURLContextMetadata_isFalse() throws { + let urlMetadata = try URLMetadata( + retrievedURL: XCTUnwrap(URL(string: "https://google.com")), + retrievalStatus: .success + ) + let urlContextMetadata = URLContextMetadata(urlMetadata: [urlMetadata]) + let candidate = Candidate( + content: ModelContent(parts: []), + safetyRatings: [], + finishReason: nil, + citationMetadata: nil, + groundingMetadata: nil, + urlContextMetadata: urlContextMetadata + ) + + XCTAssertFalse( + candidate.isEmpty, + "A candidate with only `urlContextMetadata` should not be empty." + ) + } } diff --git a/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift b/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift index 132d47fc589..ca5e8dc3ede 100644 --- a/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift +++ b/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class GroundingMetadataTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationInstanceTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationInstanceTests.swift index ce66fe94cb7..4bebe401e55 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationInstanceTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationInstanceTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ImageGenerationInstanceTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationOutputOptionsTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationOutputOptionsTests.swift index bd5b9f10e44..9e135fa622c 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationOutputOptionsTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationOutputOptionsTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ImageGenerationOutputOptionsTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationParametersTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationParametersTests.swift index a96174f3b7d..0d398738111 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationParametersTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationParametersTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ImageGenerationParametersTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGCSImageTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGCSImageTests.swift index 6bf98306cbf..84da2d5a300 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGCSImageTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGCSImageTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ImagenGCSImageTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationRequestTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationRequestTests.swift index 9a48ed7c8a2..70a98a54321 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationRequestTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationRequestTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ImagenGenerationRequestTests: XCTestCase { @@ -60,7 +60,7 @@ final class ImagenGenerationRequestTests: XCTestCase { XCTAssertEqual(request.instances, [instance]) XCTAssertEqual(request.parameters, parameters) XCTAssertEqual( - request.url, + try request.getURL(), URL(string: "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/\(modelName):predict") ) @@ -80,7 +80,7 @@ final class ImagenGenerationRequestTests: XCTestCase { XCTAssertEqual(request.instances, [instance]) XCTAssertEqual(request.parameters, parameters) XCTAssertEqual( - request.url, + try request.getURL(), URL(string: "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/\(modelName):predict") ) diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationResponseTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationResponseTests.swift index 97122401253..66e6cab4c8c 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationResponseTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationResponseTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ImagenGenerationResponseTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenInlineImageTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenInlineImageTests.swift index 31effc5c0bf..0894b27fb44 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenInlineImageTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenInlineImageTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ImagenInlineImageTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/RAIFilteredReasonTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/RAIFilteredReasonTests.swift index 90ac676f90a..4fdfa4416c7 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/RAIFilteredReasonTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/RAIFilteredReasonTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class RAIFilteredReasonTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Internal/APIConfigTests.swift b/FirebaseAI/Tests/Unit/Types/Internal/APIConfigTests.swift index fe4c290831a..79932f20e1b 100644 --- a/FirebaseAI/Tests/Unit/Types/Internal/APIConfigTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Internal/APIConfigTests.swift @@ -14,42 +14,74 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class APIConfigTests: XCTestCase { + let defaultLocation = "us-central1" + let globalLocation = "global" + func testInitialize_vertexAI_prod_v1() { - let apiConfig = APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1) + let apiConfig = APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: defaultLocation), + version: .v1 + ) - XCTAssertEqual(apiConfig.service.endpoint.rawValue, "https://firebasevertexai.googleapis.com") + switch apiConfig.service { + case let .vertexAI(endpoint: endpoint, location: location): + XCTAssertEqual(endpoint.rawValue, "https://firebasevertexai.googleapis.com") + XCTAssertEqual(location, defaultLocation) + case .googleAI: + XCTFail("Expected .vertexAI, got .googleAI") + } XCTAssertEqual(apiConfig.version.rawValue, "v1") } func testInitialize_vertexAI_prod_v1beta() { - let apiConfig = APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta) + let apiConfig = APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: defaultLocation), + version: .v1beta + ) - XCTAssertEqual(apiConfig.service.endpoint.rawValue, "https://firebasevertexai.googleapis.com") + switch apiConfig.service { + case let .vertexAI(endpoint: endpoint, location: location): + XCTAssertEqual(endpoint.rawValue, "https://firebasevertexai.googleapis.com") + XCTAssertEqual(location, defaultLocation) + case .googleAI: + XCTFail("Expected .vertexAI, got .googleAI") + } XCTAssertEqual(apiConfig.version.rawValue, "v1beta") } func testInitialize_vertexAI_staging_v1() { - let apiConfig = APIConfig(service: .vertexAI(endpoint: .firebaseProxyStaging), version: .v1) - - XCTAssertEqual( - apiConfig.service.endpoint.rawValue, "https://staging-firebasevertexai.sandbox.googleapis.com" + let apiConfig = APIConfig( + service: .vertexAI(endpoint: .firebaseProxyStaging, location: defaultLocation), + version: .v1 ) + + switch apiConfig.service { + case let .vertexAI(endpoint: endpoint, location: location): + XCTAssertEqual(endpoint.rawValue, "https://staging-firebasevertexai.sandbox.googleapis.com") + XCTAssertEqual(location, defaultLocation) + case .googleAI: + XCTFail("Expected .vertexAI, got .googleAI") + } XCTAssertEqual(apiConfig.version.rawValue, "v1") } func testInitialize_vertexAI_staging_v1beta() { let apiConfig = APIConfig( - service: .vertexAI(endpoint: .firebaseProxyStaging), + service: .vertexAI(endpoint: .firebaseProxyStaging, location: defaultLocation), version: .v1beta ) - XCTAssertEqual( - apiConfig.service.endpoint.rawValue, "https://staging-firebasevertexai.sandbox.googleapis.com" - ) + switch apiConfig.service { + case let .vertexAI(endpoint: endpoint, location: location): + XCTAssertEqual(endpoint.rawValue, "https://staging-firebasevertexai.sandbox.googleapis.com") + XCTAssertEqual(location, defaultLocation) + case .googleAI: + XCTFail("Expected .vertexAI, got .googleAI") + } XCTAssertEqual(apiConfig.version.rawValue, "v1beta") } @@ -58,16 +90,24 @@ final class APIConfigTests: XCTestCase { service: .googleAI(endpoint: .firebaseProxyStaging), version: .v1beta ) - XCTAssertEqual( - apiConfig.service.endpoint.rawValue, "https://staging-firebasevertexai.sandbox.googleapis.com" - ) + switch apiConfig.service { + case .vertexAI: + XCTFail("Expected .googleAI, got .vertexAI") + case let .googleAI(endpoint: endpoint): + XCTAssertEqual(endpoint.rawValue, "https://staging-firebasevertexai.sandbox.googleapis.com") + } XCTAssertEqual(apiConfig.version.rawValue, "v1beta") } func testInitialize_developer_generativeLanguage_v1beta() { let apiConfig = APIConfig(service: .googleAI(endpoint: .googleAIBypassProxy), version: .v1beta) - XCTAssertEqual(apiConfig.service.endpoint.rawValue, "https://generativelanguage.googleapis.com") + switch apiConfig.service { + case .vertexAI: + XCTFail("Expected .googleAI, got .vertexAI") + case let .googleAI(endpoint: endpoint): + XCTAssertEqual(endpoint.rawValue, "https://generativelanguage.googleapis.com") + } XCTAssertEqual(apiConfig.version.rawValue, "v1beta") } } diff --git a/FirebaseAI/Tests/Unit/Types/Internal/Requests/CountTokensRequestTests.swift b/FirebaseAI/Tests/Unit/Types/Internal/Requests/CountTokensRequestTests.swift index 6e2f1f790e8..7c43833ed45 100644 --- a/FirebaseAI/Tests/Unit/Types/Internal/Requests/CountTokensRequestTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Internal/Requests/CountTokensRequestTests.swift @@ -15,7 +15,7 @@ import Foundation import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class CountTokensRequestTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift b/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift index 65121e913c2..3094135c5ab 100644 --- a/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift +++ b/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -@testable import FirebaseAI +@testable import FirebaseAILogic import XCTest @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) diff --git a/FirebaseAI/Tests/Unit/Types/Live/BidiGenerateContentServerMessageTests.swift b/FirebaseAI/Tests/Unit/Types/Live/BidiGenerateContentServerMessageTests.swift new file mode 100644 index 00000000000..cf3403b37a1 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Types/Live/BidiGenerateContentServerMessageTests.swift @@ -0,0 +1,191 @@ +// 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 FirebaseAILogic + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +final class BidiGenerateContentServerMessageTests: XCTestCase { + let decoder = JSONDecoder() + + func testDecodeBidiGenerateContentServerMessage_setupComplete() throws { + let json = """ + { + "setupComplete" : {} + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let serverMessage = try decoder.decode(BidiGenerateContentServerMessage.self, from: jsonData) + guard case .setupComplete = serverMessage.messageType else { + XCTFail("Decoded message is not a setupComplete message.") + return + } + } + + func testDecodeBidiGenerateContentServerMessage_serverContent() throws { + let json = """ + { + "serverContent" : { + "modelTurn" : { + "parts" : [ + { + "inlineData" : { + "data" : "BQUFBQU=", + "mimeType" : "audio/pcm" + } + } + ], + "role" : "model" + }, + "turnComplete": true, + "groundingMetadata": { + "webSearchQueries": ["query1", "query2"], + "groundingChunks": [ + { "web": { "uri": "uri1", "title": "title1" } } + ], + "groundingSupports": [ + { "segment": { "endIndex": 10, "text": "text" }, "groundingChunkIndices": [0] } + ], + "searchEntryPoint": { "renderedContent": "html" } + }, + "inputTranscription": { + "text": "What day of the week is it?" + }, + "outputTranscription": { + "text": "Today is friday" + } + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let serverMessage = try decoder.decode(BidiGenerateContentServerMessage.self, from: jsonData) + guard case let .serverContent(serverContent) = serverMessage.messageType else { + XCTFail("Decoded message is not a serverContent message.") + return + } + + XCTAssertEqual(serverContent.turnComplete, true) + XCTAssertNil(serverContent.interrupted) + XCTAssertNil(serverContent.generationComplete) + + let modelTurn = try XCTUnwrap(serverContent.modelTurn) + XCTAssertEqual(modelTurn.role, "model") + XCTAssertEqual(modelTurn.parts.count, 1) + let part = try XCTUnwrap(modelTurn.parts.first as? InlineDataPart) + XCTAssertEqual(part.data, Data(repeating: 5, count: 5)) + XCTAssertEqual(part.mimeType, "audio/pcm") + + let metadata = try XCTUnwrap(serverContent.groundingMetadata) + XCTAssertEqual(metadata.webSearchQueries, ["query1", "query2"]) + XCTAssertEqual(metadata.groundingChunks.count, 1) + let groundingChunk = try XCTUnwrap(metadata.groundingChunks.first) + let webChunk = try XCTUnwrap(groundingChunk.web) + XCTAssertEqual(webChunk.uri, "uri1") + XCTAssertEqual(metadata.groundingSupports.count, 1) + let groundingSupport = try XCTUnwrap(metadata.groundingSupports.first) + XCTAssertEqual(groundingSupport.segment.startIndex, 0) + XCTAssertEqual(groundingSupport.segment.partIndex, 0) + XCTAssertEqual(groundingSupport.segment.endIndex, 10) + XCTAssertEqual(groundingSupport.segment.text, "text") + let searchEntryPoint = try XCTUnwrap(metadata.searchEntryPoint) + XCTAssertEqual(searchEntryPoint.renderedContent, "html") + + let inputTranscription = try XCTUnwrap(serverContent.inputTranscription) + XCTAssertEqual(inputTranscription.text, "What day of the week is it?") + + let outputTranscription = try XCTUnwrap(serverContent.outputTranscription) + XCTAssertEqual(outputTranscription.text, "Today is friday") + } + + func testDecodeBidiGenerateContentServerMessage_toolCall() throws { + let json = """ + { + "toolCall" : { + "functionCalls" : [ + { + "name": "changeBackgroundColor", + "id": "functionCall-12345-67890", + "args" : { + "color": "#F54927" + } + } + ] + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let serverMessage = try decoder.decode(BidiGenerateContentServerMessage.self, from: jsonData) + guard case let .toolCall(toolCall) = serverMessage.messageType else { + XCTFail("Decoded message is not a toolCall message.") + return + } + + let functionCalls = try XCTUnwrap(toolCall.functionCalls) + XCTAssertEqual(functionCalls.count, 1) + let functionCall = try XCTUnwrap(functionCalls.first) + XCTAssertEqual(functionCall.name, "changeBackgroundColor") + XCTAssertEqual(functionCall.id, "functionCall-12345-67890") + let args = try XCTUnwrap(functionCall.args) + guard case let .string(color) = args["color"] else { + XCTFail("Missing color argument") + return + } + XCTAssertEqual(color, "#F54927") + } + + func testDecodeBidiGenerateContentServerMessage_toolCallCancellation() throws { + let json = """ + { + "toolCallCancellation" : { + "ids" : ["functionCall-12345-67890"] + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let serverMessage = try decoder.decode(BidiGenerateContentServerMessage.self, from: jsonData) + guard case let .toolCallCancellation(toolCallCancellation) = serverMessage.messageType else { + XCTFail("Decoded message is not a toolCallCancellation message.") + return + } + + let ids = try XCTUnwrap(toolCallCancellation.ids) + XCTAssertEqual(ids, ["functionCall-12345-67890"]) + } + + func testDecodeBidiGenerateContentServerMessage_goAway() throws { + let json = """ + { + "goAway" : { + "timeLeft": "1.23456789s" + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let serverMessage = try decoder.decode(BidiGenerateContentServerMessage.self, from: jsonData) + guard case let .goAway(goAway) = serverMessage.messageType else { + XCTFail("Decoded message is not a goAway message.") + return + } + + XCTAssertEqual(goAway.timeLeft?.seconds, 1) + XCTAssertEqual(goAway.timeLeft?.nanos, 234_567_890) + } +} diff --git a/FirebaseAI/Tests/Unit/Types/Live/VoiceConfigTests.swift b/FirebaseAI/Tests/Unit/Types/Live/VoiceConfigTests.swift new file mode 100644 index 00000000000..3e57ce2d621 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Types/Live/VoiceConfigTests.swift @@ -0,0 +1,62 @@ +// 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 FirebaseAILogic + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +final class VoiceConfigTests: XCTestCase { + let encoder = JSONEncoder() + + override func setUp() { + super.setUp() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + } + + func testEncodeVoiceConfig_prebuiltVoice() throws { + let voice = VoiceConfig.prebuiltVoiceConfig( + PrebuiltVoiceConfig(voiceName: "Zephyr") + ) + + let jsonData = try encoder.encode(voice) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "prebuiltVoiceConfig" : { + "voiceName" : "Zephyr" + } + } + """) + } + + func testEncodeVoiceConfig_customVoice() throws { + let voice = VoiceConfig.customVoiceConfig( + CustomVoiceConfig(customVoiceSample: Data(repeating: 5, count: 5)) + ) + + let jsonData = try encoder.encode(voice) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "customVoiceConfig" : { + "customVoiceSample" : "BQUFBQU=" + } + } + """) + } +} diff --git a/FirebaseAI/Tests/Unit/Types/ModalityTokenCountTests.swift b/FirebaseAI/Tests/Unit/Types/ModalityTokenCountTests.swift index cd56a0c67d1..12a58e992bb 100644 --- a/FirebaseAI/Tests/Unit/Types/ModalityTokenCountTests.swift +++ b/FirebaseAI/Tests/Unit/Types/ModalityTokenCountTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import XCTest @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) diff --git a/FirebaseAI/Tests/Unit/Types/ProtoDateTests.swift b/FirebaseAI/Tests/Unit/Types/ProtoDateTests.swift index dbe6c2e27ca..7f4315f1012 100644 --- a/FirebaseAI/Tests/Unit/Types/ProtoDateTests.swift +++ b/FirebaseAI/Tests/Unit/Types/ProtoDateTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic final class ProtoDateTests: XCTestCase { let decoder = JSONDecoder() diff --git a/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift b/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift new file mode 100644 index 00000000000..8db761872a5 --- /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 FirebaseAILogic + +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, accuracy: 1e-9) + } + + func testDecodeProtoDuration_withoutNanoseconds() throws { + let duration = try decodeProtoDuration("120s") + XCTAssertEqual(duration.seconds, 120) + XCTAssertEqual(duration.nanos, 0) + + XCTAssertEqual(duration.timeInterval, 120, accuracy: 1e-9) + } + + 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, accuracy: 1e-9) + } + + func testDecodeProtoDuration_withMilliseconds() throws { + let duration = try decodeProtoDuration("15.123s") + XCTAssertEqual(duration.seconds, 15) + XCTAssertEqual(duration.nanos, 123_000_000) + + XCTAssertEqual(duration.timeInterval, 15.123, accuracy: 1e-9) + } + + 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, accuracy: 1e-9) + } +} diff --git a/FirebaseAI/Tests/Unit/Types/SchemaTests.swift b/FirebaseAI/Tests/Unit/Types/SchemaTests.swift index 4f911b31bd7..a24b4048645 100644 --- a/FirebaseAI/Tests/Unit/Types/SchemaTests.swift +++ b/FirebaseAI/Tests/Unit/Types/SchemaTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import Foundation import XCTest diff --git a/FirebaseAI/Tests/Unit/Types/ToolTests.swift b/FirebaseAI/Tests/Unit/Types/ToolTests.swift index 9bfdf2313b7..b429cb5369b 100644 --- a/FirebaseAI/Tests/Unit/Types/ToolTests.swift +++ b/FirebaseAI/Tests/Unit/Types/ToolTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ToolTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/VertexComponentTests.swift b/FirebaseAI/Tests/Unit/VertexComponentTests.swift index 702c6e50871..9d33df1ff50 100644 --- a/FirebaseAI/Tests/Unit/VertexComponentTests.swift +++ b/FirebaseAI/Tests/Unit/VertexComponentTests.swift @@ -17,7 +17,7 @@ internal import FirebaseCoreExtension import Foundation import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) class VertexComponentTests: XCTestCase { @@ -57,8 +57,9 @@ class VertexComponentTests: XCTestCase { XCTAssertNotNil(vertex) XCTAssertEqual(vertex.firebaseInfo.projectID, VertexComponentTests.projectID) XCTAssertEqual(vertex.firebaseInfo.apiKey, VertexComponentTests.apiKey) - XCTAssertEqual(vertex.location, "us-central1") - XCTAssertEqual(vertex.apiConfig.service, .vertexAI(endpoint: .firebaseProxyProd)) + XCTAssertEqual( + vertex.apiConfig.service, .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1") + ) XCTAssertEqual(vertex.apiConfig.service.endpoint, .firebaseProxyProd) XCTAssertEqual(vertex.apiConfig.version, .v1beta) } @@ -71,8 +72,9 @@ class VertexComponentTests: XCTestCase { XCTAssertNotNil(vertex) XCTAssertEqual(vertex.firebaseInfo.projectID, VertexComponentTests.projectID) XCTAssertEqual(vertex.firebaseInfo.apiKey, VertexComponentTests.apiKey) - XCTAssertEqual(vertex.location, location) - XCTAssertEqual(vertex.apiConfig.service, .vertexAI(endpoint: .firebaseProxyProd)) + XCTAssertEqual( + vertex.apiConfig.service, .vertexAI(endpoint: .firebaseProxyProd, location: location) + ) XCTAssertEqual(vertex.apiConfig.service.endpoint, .firebaseProxyProd) XCTAssertEqual(vertex.apiConfig.version, .v1beta) } @@ -87,8 +89,9 @@ class VertexComponentTests: XCTestCase { XCTAssertNotNil(vertex) XCTAssertEqual(vertex.firebaseInfo.projectID, VertexComponentTests.projectID) XCTAssertEqual(vertex.firebaseInfo.apiKey, VertexComponentTests.apiKey) - XCTAssertEqual(vertex.location, location) - XCTAssertEqual(vertex.apiConfig.service, .vertexAI(endpoint: .firebaseProxyProd)) + XCTAssertEqual( + vertex.apiConfig.service, .vertexAI(endpoint: .firebaseProxyProd, location: location) + ) XCTAssertEqual(vertex.apiConfig.service.endpoint, .firebaseProxyProd) XCTAssertEqual(vertex.apiConfig.version, .v1beta) } @@ -154,14 +157,17 @@ class VertexComponentTests: XCTestCase { func testSameAppAndDifferentAPI_newInstanceCreated() throws { let vertex1 = FirebaseAI.createInstance( app: VertexComponentTests.app, - location: location, - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta), + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: location), + version: .v1beta + ), useLimitedUseAppCheckTokens: false ) let vertex2 = FirebaseAI.createInstance( app: VertexComponentTests.app, - location: location, - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1), + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: location), version: .v1 + ), useLimitedUseAppCheckTokens: false ) @@ -182,8 +188,10 @@ class VertexComponentTests: XCTestCase { weakApp = try XCTUnwrap(app1) let vertex = FirebaseAI( app: app1, - location: "transitory location", - apiConfig: FirebaseAI.defaultVertexAIAPIConfig, + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "transitory location"), + version: .v1beta + ), useLimitedUseAppCheckTokens: false ) weakVertex = vertex @@ -195,13 +203,13 @@ class VertexComponentTests: XCTestCase { func testModelResourceName_vertexAI() throws { let app = try XCTUnwrap(VertexComponentTests.app) + let location = "test-location" let vertex = FirebaseAI.firebaseAI(app: app, backend: .vertexAI(location: location)) let model = "test-model-name" let projectID = vertex.firebaseInfo.projectID let modelResourceName = vertex.modelResourceName(modelName: model) - let location = try XCTUnwrap(vertex.location) XCTAssertEqual( modelResourceName, "projects/\(projectID)/locations/\(location)/publishers/google/models/\(model)" @@ -212,10 +220,7 @@ class VertexComponentTests: XCTestCase { let app = try XCTUnwrap(VertexComponentTests.app) let apiConfig = APIConfig(service: .googleAI(endpoint: .googleAIBypassProxy), version: .v1beta) let vertex = FirebaseAI.createInstance( - app: app, - location: nil, - apiConfig: apiConfig, - useLimitedUseAppCheckTokens: false + app: app, apiConfig: apiConfig, useLimitedUseAppCheckTokens: false ) let model = "test-model-name" @@ -231,10 +236,7 @@ class VertexComponentTests: XCTestCase { version: .v1beta ) let vertex = FirebaseAI.createInstance( - app: app, - location: nil, - apiConfig: apiConfig, - useLimitedUseAppCheckTokens: false + app: app, apiConfig: apiConfig, useLimitedUseAppCheckTokens: false ) let model = "test-model-name" let projectID = vertex.firebaseInfo.projectID @@ -244,15 +246,14 @@ class VertexComponentTests: XCTestCase { XCTAssertEqual(modelResourceName, "projects/\(projectID)/models/\(model)") } - func testGenerativeModel_vertexAI() async throws { + func testGenerativeModel_vertexAI_defaultLocation() async throws { let app = try XCTUnwrap(VertexComponentTests.app) - let vertex = FirebaseAI.firebaseAI(app: app, backend: .vertexAI(location: location)) + let vertex = FirebaseAI.firebaseAI(app: app, backend: .vertexAI()) let modelResourceName = vertex.modelResourceName(modelName: modelName) let expectedSystemInstruction = ModelContent(role: nil, parts: systemInstruction.parts) let generativeModel = vertex.generativeModel( - modelName: modelName, - systemInstruction: systemInstruction + modelName: modelName, systemInstruction: systemInstruction ) XCTAssertEqual(generativeModel.modelResourceName, modelResourceName) @@ -260,6 +261,24 @@ class VertexComponentTests: XCTestCase { XCTAssertEqual(generativeModel.apiConfig, FirebaseAI.defaultVertexAIAPIConfig) } + func testGenerativeModel_vertexAI_customLocation() async throws { + let app = try XCTUnwrap(VertexComponentTests.app) + let vertex = FirebaseAI.firebaseAI(app: app, backend: .vertexAI(location: location)) + let modelResourceName = vertex.modelResourceName(modelName: modelName) + let expectedAPIConfig = APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: location), version: .v1beta + ) + let expectedSystemInstruction = ModelContent(role: nil, parts: systemInstruction.parts) + + let generativeModel = vertex.generativeModel( + modelName: modelName, systemInstruction: systemInstruction + ) + + XCTAssertEqual(generativeModel.modelResourceName, modelResourceName) + XCTAssertEqual(generativeModel.systemInstruction, expectedSystemInstruction) + XCTAssertEqual(generativeModel.apiConfig, expectedAPIConfig) + } + func testGenerativeModel_developerAPI() async throws { let app = try XCTUnwrap(VertexComponentTests.app) let apiConfig = APIConfig( @@ -267,10 +286,7 @@ class VertexComponentTests: XCTestCase { version: .v1beta ) let vertex = FirebaseAI.createInstance( - app: app, - location: nil, - apiConfig: apiConfig, - useLimitedUseAppCheckTokens: false + app: app, apiConfig: apiConfig, useLimitedUseAppCheckTokens: false ) let modelResourceName = vertex.modelResourceName(modelName: modelName) let expectedSystemInstruction = ModelContent(role: nil, parts: systemInstruction.parts) diff --git a/FirebaseAI/Wrapper/Sources/FirebaseAIWrapper.swift b/FirebaseAI/Wrapper/Sources/FirebaseAIWrapper.swift new file mode 100644 index 00000000000..a4ff8613b44 --- /dev/null +++ b/FirebaseAI/Wrapper/Sources/FirebaseAIWrapper.swift @@ -0,0 +1,15 @@ +// 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. + +@_exported import FirebaseAILogic diff --git a/FirebaseAI/Wrapper/Tests/APITests.swift b/FirebaseAI/Wrapper/Tests/APITests.swift new file mode 100644 index 00000000000..16c963b1f0c --- /dev/null +++ b/FirebaseAI/Wrapper/Tests/APITests.swift @@ -0,0 +1,186 @@ +// Copyright 2023 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 FirebaseAI +import FirebaseCore +import XCTest +#if canImport(AppKit) + import AppKit // For NSImage extensions. +#elseif canImport(UIKit) + import UIKit // For UIImage extensions. +#endif + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class APITests: XCTestCase { + func codeSamples() async throws { + let app = FirebaseApp.app() + let config = GenerationConfig(temperature: 0.2, + topP: 0.1, + topK: 16, + candidateCount: 4, + maxOutputTokens: 256, + stopSequences: ["..."], + responseMIMEType: "text/plain") + let filters = [SafetySetting(harmCategory: .dangerousContent, threshold: .blockOnlyHigh)] + let systemInstruction = ModelContent( + role: "system", + parts: TextPart("Talk like a pirate.") + ) + + let requestOptions = RequestOptions() + let _ = RequestOptions(timeout: 30.0) + + // Instantiate Firebase AI SDK - Default App + let firebaseAI = FirebaseAI.firebaseAI() + let _ = FirebaseAI.firebaseAI(backend: .googleAI()) + let _ = FirebaseAI.firebaseAI(backend: .vertexAI()) + let _ = FirebaseAI.firebaseAI(backend: .vertexAI(location: "my-location")) + + // Instantiate Firebase AI SDK - Custom App + let _ = FirebaseAI.firebaseAI(app: app!) + let _ = FirebaseAI.firebaseAI(app: app!, backend: .googleAI()) + let _ = FirebaseAI.firebaseAI(app: app!, backend: .vertexAI()) + let _ = FirebaseAI.firebaseAI(app: app!, backend: .vertexAI(location: "my-location")) + + // Permutations without optional arguments. + + let _ = firebaseAI.generativeModel(modelName: "gemini-2.0-flash") + + let _ = firebaseAI.generativeModel( + modelName: "gemini-2.0-flash", + safetySettings: filters + ) + + let _ = firebaseAI.generativeModel( + modelName: "gemini-2.0-flash", + generationConfig: config + ) + + let _ = firebaseAI.generativeModel( + modelName: "gemini-2.0-flash", + systemInstruction: systemInstruction + ) + + // All arguments passed. + let model = firebaseAI.generativeModel( + modelName: "gemini-2.0-flash", + generationConfig: config, // Optional + safetySettings: filters, // Optional + systemInstruction: systemInstruction, // Optional + requestOptions: requestOptions // Optional + ) + + // Full Typed Usage + let pngData = Data() // .... + let contents = [ModelContent( + role: "user", + parts: [ + TextPart("Is it a cat?"), + InlineDataPart(data: pngData, mimeType: "image/png"), + ] + )] + + do { + let response = try await model.generateContent(contents) + print(response.text ?? "Couldn't get text... check status") + } catch { + print("Error generating content: \(error)") + } + + // Content input combinations. + let _ = try await model.generateContent("Constant String") + let str = "String Variable" + let _ = try await model.generateContent(str) + let _ = try await model.generateContent([str]) + let _ = try await model.generateContent(str, "abc", "def") + let _ = try await model.generateContent( + str, + FileDataPart(uri: "gs://test-bucket/image.jpg", mimeType: "image/jpeg") + ) + #if canImport(UIKit) + _ = try await model.generateContent(UIImage()) + _ = try await model.generateContent([UIImage()]) + _ = try await model.generateContent([str, UIImage(), TextPart(str)]) + _ = try await model.generateContent(str, UIImage(), "def", UIImage()) + _ = try await model.generateContent([str, UIImage(), "def", UIImage()]) + _ = try await model.generateContent([ModelContent(parts: "def", UIImage()), + ModelContent(parts: "def", UIImage())]) + #elseif canImport(AppKit) + _ = try await model.generateContent(NSImage()) + _ = try await model.generateContent([NSImage()]) + _ = try await model.generateContent(str, NSImage(), "def", NSImage()) + _ = try await model.generateContent([str, NSImage(), "def", NSImage()]) + #endif + + // PartsRepresentable combinations. + let _ = ModelContent(parts: [TextPart(str)]) + let _ = ModelContent(role: "model", parts: [TextPart(str)]) + let _ = ModelContent(parts: "Constant String") + let _ = ModelContent(parts: str) + let _ = ModelContent(parts: [str]) + let _ = ModelContent(parts: [str, InlineDataPart(data: Data(), mimeType: "foo")]) + #if canImport(UIKit) + _ = ModelContent(role: "user", parts: UIImage()) + _ = ModelContent(role: "user", parts: [UIImage()]) + _ = ModelContent(parts: [str, UIImage()]) + // Note: without explicitly specifying`: [any PartsRepresentable]` this will fail to compile + // below with "Cannot convert value of type `[Any]` to expected type `[any Part]`. + let representable2: [any PartsRepresentable] = [str, UIImage()] + _ = ModelContent(parts: representable2) + _ = ModelContent(parts: [str, UIImage(), TextPart(str)]) + #elseif canImport(AppKit) + _ = ModelContent(role: "user", parts: NSImage()) + _ = ModelContent(role: "user", parts: [NSImage()]) + _ = ModelContent(parts: [str, NSImage()]) + // Note: without explicitly specifying`: [any PartsRepresentable]` this will fail to compile + // below with "Cannot convert value of type `[Any]` to expected type `[any Part]`. + let representable2: [any PartsRepresentable] = [str, NSImage()] + _ = ModelContent(parts: representable2) + _ = ModelContent(parts: [str, NSImage(), TextPart(str)]) + #endif + + // countTokens API + let _: CountTokensResponse = try await model.countTokens("What color is the Sky?") + #if canImport(UIKit) + let _: CountTokensResponse = try await model.countTokens("What color is the Sky?", + UIImage()) + let _: CountTokensResponse = try await model.countTokens([ + ModelContent(parts: "What color is the Sky?", UIImage()), + ModelContent(parts: UIImage(), "What color is the Sky?", UIImage()), + ]) + #endif + + // Chat + _ = model.startChat() + _ = model.startChat(history: [ModelContent(parts: "abc")]) + } + + // Public API tests for GenerateContentResponse. + func generateContentResponseAPI() { + let response = GenerateContentResponse(candidates: []) + + let _: [Candidate] = response.candidates + let _: PromptFeedback? = response.promptFeedback + + // Usage Metadata + guard let usageMetadata = response.usageMetadata else { fatalError() } + let _: Int = usageMetadata.promptTokenCount + let _: Int = usageMetadata.candidatesTokenCount + let _: Int = usageMetadata.totalTokenCount + + // Computed Properties + let _: String? = response.text + let _: [FunctionCallPart] = response.functionCalls + } +} diff --git a/FirebaseAILogic.podspec b/FirebaseAILogic.podspec new file mode 100644 index 00000000000..b940e4c2ad3 --- /dev/null +++ b/FirebaseAILogic.podspec @@ -0,0 +1,70 @@ +Pod::Spec.new do |s| + s.name = 'FirebaseAILogic' + s.version = '12.6.0' + s.summary = 'Firebase AI Logic SDK' + + s.description = <<-DESC +Build AI-powered apps and features with the Gemini API using the Firebase AI Logic SDK. + DESC + + s.homepage = 'https://firebase.google.com' + s.license = { :type => 'Apache-2.0', :file => 'LICENSE' } + s.authors = 'Google, Inc.' + + s.source = { + :git => 'https://github.com/firebase/firebase-ios-sdk.git', + :tag => 'CocoaPods-' + s.version.to_s + } + + s.social_media_url = 'https://twitter.com/Firebase' + + ios_deployment_target = '15.0' + osx_deployment_target = '12.0' + tvos_deployment_target = '15.0' + watchos_deployment_target = '8.0' + + s.ios.deployment_target = ios_deployment_target + s.osx.deployment_target = osx_deployment_target + s.tvos.deployment_target = tvos_deployment_target + s.watchos.deployment_target = watchos_deployment_target + + s.cocoapods_version = '>= 1.12.0' + s.prefix_header_file = false + + s.source_files = [ + 'FirebaseAI/Sources/**/*.swift', + ] + + s.swift_version = '5.9' + + s.framework = 'Foundation' + s.ios.framework = 'UIKit' + s.osx.framework = 'AppKit' + s.tvos.framework = 'UIKit' + s.watchos.framework = 'WatchKit' + + s.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' + s.dependency 'FirebaseAuthInterop', '~> 12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseCoreExtension', '~> 12.6.0' + + s.test_spec 'unit' do |unit_tests| + unit_tests_dir = 'FirebaseAI/Tests/Unit/' + unit_tests.scheme = { :code_coverage => true } + unit_tests.platforms = { + :ios => ios_deployment_target, + :osx => osx_deployment_target, + :tvos => tvos_deployment_target + } + unit_tests.source_files = [ + unit_tests_dir + '**/*.swift', + ] + unit_tests.exclude_files = [ + unit_tests_dir + 'Snippets/**/*.swift', + ] + unit_tests.resources = [ + unit_tests_dir + 'vertexai-sdk-test-data/mock-responses', + unit_tests_dir + 'Resources/**/*', + ] + end +end diff --git a/FirebaseAnalytics.podspec b/FirebaseAnalytics.podspec index ddfd6bfefcf..d646e498bbe 100644 --- a/FirebaseAnalytics.podspec +++ b/FirebaseAnalytics.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAnalytics' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Firebase Analytics for iOS' s.description = <<-DESC @@ -13,7 +13,7 @@ Pod::Spec.new do |s| s.authors = 'Google, Inc.' s.source = { - :http => 'https://dl.google.com/firebase/ios/analytics/7f774173bfc50ea8/FirebaseAnalytics-12.3.0.tar.gz' + :http => 'https://dl.google.com/firebase/ios/analytics/1d0a9f91196548b3/FirebaseAnalytics-12.5.0.tar.gz' } s.cocoapods_version = '>= 1.12.0' @@ -26,8 +26,8 @@ Pod::Spec.new do |s| s.libraries = 'c++', 'sqlite3', 'z' s.frameworks = 'StoreKit' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseInstallations', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/MethodSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/NSData+zlib', '~> 8.1' @@ -37,17 +37,17 @@ Pod::Spec.new do |s| s.default_subspecs = 'Default' s.subspec 'Default' do |ss| - ss.dependency 'GoogleAppMeasurement/Default', '12.4.0' + ss.dependency 'GoogleAppMeasurement/Default', '12.6.0' ss.vendored_frameworks = 'Frameworks/FirebaseAnalytics.xcframework' end s.subspec 'Core' do |ss| - ss.dependency 'GoogleAppMeasurement/Core', '12.4.0' + ss.dependency 'GoogleAppMeasurement/Core', '12.6.0' ss.vendored_frameworks = 'Frameworks/FirebaseAnalytics.xcframework' end s.subspec 'IdentitySupport' do |ss| - ss.dependency 'GoogleAppMeasurement/IdentitySupport', '12.4.0' + ss.dependency 'GoogleAppMeasurement/IdentitySupport', '12.6.0' ss.vendored_frameworks = 'Frameworks/FirebaseAnalytics.xcframework' end diff --git a/FirebaseAppCheck.podspec b/FirebaseAppCheck.podspec index 41996f1d2e7..50bcbb75b2b 100644 --- a/FirebaseAppCheck.podspec +++ b/FirebaseAppCheck.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAppCheck' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Firebase App Check SDK.' s.description = <<-DESC @@ -45,8 +45,8 @@ Pod::Spec.new do |s| s.tvos.weak_framework = 'DeviceCheck' s.dependency 'AppCheckCore', '~> 11.0' - s.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' - s.dependency 'FirebaseCore', '~> 12.4.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' diff --git a/FirebaseAppCheckInterop.podspec b/FirebaseAppCheckInterop.podspec index 86601279a4d..c36bc7bc447 100644 --- a/FirebaseAppCheckInterop.podspec +++ b/FirebaseAppCheckInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAppCheckInterop' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Interfaces that allow other Firebase SDKs to use AppCheck functionality.' s.description = <<-DESC diff --git a/FirebaseAppDistribution.podspec b/FirebaseAppDistribution.podspec index 0c94ff6b98c..fb2ae4ab9f6 100644 --- a/FirebaseAppDistribution.podspec +++ b/FirebaseAppDistribution.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAppDistribution' - s.version = '12.4.0-beta' + s.version = '12.6.0-beta' s.summary = 'App Distribution for Firebase iOS SDK.' s.description = <<-DESC @@ -30,10 +30,10 @@ iOS SDK for App Distribution for Firebase. ] s.public_header_files = base_dir + 'Public/FirebaseAppDistribution/*.h' - s.dependency 'FirebaseCore', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.6.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' - s.dependency 'FirebaseInstallations', '~> 12.4.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' diff --git a/FirebaseAuth.podspec b/FirebaseAuth.podspec index 4b2a81768ea..2de9ddbb308 100644 --- a/FirebaseAuth.podspec +++ b/FirebaseAuth.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAuth' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Apple platform client for Firebase Authentication' s.description = <<-DESC @@ -55,10 +55,10 @@ supports email and password accounts, as well as several 3rd party authenticatio } s.framework = 'Security' s.ios.framework = 'SafariServices' - s.dependency 'FirebaseAuthInterop', '~> 12.4.0' - s.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseCoreExtension', '~> 12.4.0' + s.dependency 'FirebaseAuthInterop', '~> 12.6.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseCoreExtension', '~> 12.6.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GTMSessionFetcher/Core', '>= 3.4', '< 6.0' diff --git a/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift b/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift index 223f06e8014..2d4dcee59fc 100644 --- a/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift +++ b/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift @@ -38,7 +38,7 @@ actor SecureTokenServiceInternal { /// Makes a request to STS for an access token. /// - /// This handles both the case that the token has not been granted yet and that it just needs + /// This handles both the case that the token has not been granted yet and that it just /// needs to be refreshed. /// /// - Returns: Token and Bool indicating if update occurred. diff --git a/FirebaseAuthInterop.podspec b/FirebaseAuthInterop.podspec index e1525e77ccc..775b8a750de 100644 --- a/FirebaseAuthInterop.podspec +++ b/FirebaseAuthInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAuthInterop' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Interfaces that allow other Firebase SDKs to use Auth functionality.' s.description = <<-DESC diff --git a/FirebaseCombineSwift.podspec b/FirebaseCombineSwift.podspec index 6c46db9ae8e..b790fc8abde 100644 --- a/FirebaseCombineSwift.podspec +++ b/FirebaseCombineSwift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCombineSwift' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Swift extensions with Combine support for Firebase' s.description = <<-DESC @@ -51,11 +51,11 @@ for internal testing only. It should not be published. s.osx.framework = 'AppKit' s.tvos.framework = 'UIKit' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseAuth', '~> 12.4.0' - s.dependency 'FirebaseFunctions', '~> 12.4.0' - s.dependency 'FirebaseFirestore', '~> 12.4.0' - s.dependency 'FirebaseStorage', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseAuth', '~> 12.6.0' + s.dependency 'FirebaseFunctions', '~> 12.6.0' + s.dependency 'FirebaseFirestore', '~> 12.6.0' + s.dependency 'FirebaseStorage', '~> 12.6.0' s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"', @@ -104,6 +104,6 @@ for internal testing only. It should not be published. int_tests.resources = 'FirebaseStorage/Tests/Integration/Resources/1mb.dat', 'FirebaseStorage/Tests/Integration/Resources/GoogleService-Info.plist', 'FirebaseStorage/Tests/Integration/Resources/HomeImprovement.numbers' - int_tests.dependency 'FirebaseAuth', '~> 12.4.0' + int_tests.dependency 'FirebaseAuth', '~> 12.6.0' end end diff --git a/FirebaseCore.podspec b/FirebaseCore.podspec index fb5e5594da4..ad0a18e4574 100644 --- a/FirebaseCore.podspec +++ b/FirebaseCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCore' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Firebase Core' s.description = <<-DESC @@ -53,7 +53,7 @@ Firebase Core includes FIRApp and FIROptions which provide central configuration # Remember to also update version in `cmake/external/GoogleUtilities.cmake` s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/Logger', '~> 8.1' - s.dependency 'FirebaseCoreInternal', '~> 12.4.0' + s.dependency 'FirebaseCoreInternal', '~> 12.6.0' s.pod_target_xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' => 'Firebase_VERSION=' + s.version.to_s, diff --git a/FirebaseCoreExtension.podspec b/FirebaseCoreExtension.podspec index 582d7c03633..574c7257989 100644 --- a/FirebaseCoreExtension.podspec +++ b/FirebaseCoreExtension.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCoreExtension' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Extended FirebaseCore APIs for Firebase product SDKs' s.description = <<-DESC @@ -34,5 +34,5 @@ Pod::Spec.new do |s| "#{s.module_name}_Privacy" => 'FirebaseCore/Extension/Resources/PrivacyInfo.xcprivacy' } - s.dependency 'FirebaseCore', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.6.0' end diff --git a/FirebaseCoreInternal.podspec b/FirebaseCoreInternal.podspec index 11d45777ec2..f055c2c7a12 100644 --- a/FirebaseCoreInternal.podspec +++ b/FirebaseCoreInternal.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCoreInternal' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'APIs for internal FirebaseCore usage.' s.description = <<-DESC diff --git a/FirebaseCrashlytics.podspec b/FirebaseCrashlytics.podspec index 0b041473102..a8ace6fd337 100644 --- a/FirebaseCrashlytics.podspec +++ b/FirebaseCrashlytics.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCrashlytics' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Best and lightest-weight crash reporting for mobile, desktop and tvOS.' s.description = 'Firebase Crashlytics helps you track, prioritize, and fix stability issues that erode app quality.' s.homepage = 'https://firebase.google.com/' @@ -59,10 +59,10 @@ Pod::Spec.new do |s| cp -f ./Crashlytics/CrashlyticsInputFiles.xcfilelist ./CrashlyticsInputFiles.xcfilelist PREPARE_COMMAND_END - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseInstallations', '~> 12.4.0' - s.dependency 'FirebaseSessions', '~> 12.4.0' - s.dependency 'FirebaseRemoteConfigInterop', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' + s.dependency 'FirebaseSessions', '~> 12.6.0' + s.dependency 'FirebaseRemoteConfigInterop', '~> 12.6.0' s.dependency 'PromisesObjC', '~> 2.4' s.dependency 'GoogleDataTransport', '~> 10.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' diff --git a/FirebaseDatabase.podspec b/FirebaseDatabase.podspec index 0380ba60221..f4cf9e40198 100644 --- a/FirebaseDatabase.podspec +++ b/FirebaseDatabase.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseDatabase' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Firebase Realtime Database' s.description = <<-DESC @@ -48,9 +48,9 @@ Simplify your iOS development, grow your user base, and monetize more effectivel s.macos.frameworks = 'CFNetwork', 'Security', 'SystemConfiguration' s.watchos.frameworks = 'CFNetwork', 'Security', 'WatchKit' s.dependency 'leveldb-library', '~> 1.22' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' - s.dependency 'FirebaseSharedSwift', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' + s.dependency 'FirebaseSharedSwift', '~> 12.6.0' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' @@ -72,7 +72,7 @@ Simplify your iOS development, grow your user base, and monetize more effectivel 'SharedTestUtilities/FIRComponentTestUtilities.[mh]', 'SharedTestUtilities/FIROptionsMock.[mh]', ] - unit_tests.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' + unit_tests.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' unit_tests.dependency 'OCMock' unit_tests.resources = 'FirebaseDatabase/Tests/Resources/syncPointSpec.json', 'FirebaseDatabase/Tests/Resources/GoogleService-Info.plist' diff --git a/FirebaseFirestore.podspec b/FirebaseFirestore.podspec index 0b4b0380a11..c9f3402d0a8 100644 --- a/FirebaseFirestore.podspec +++ b/FirebaseFirestore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseFirestore' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Google Cloud Firestore' s.description = <<-DESC Google Cloud Firestore is a NoSQL document database built for automatic scaling, high performance, and ease of application development. @@ -35,9 +35,9 @@ Google Cloud Firestore is a NoSQL document database built for automatic scaling, "#{s.module_name}_Privacy" => 'Firestore/Swift/Source/Resources/PrivacyInfo.xcprivacy' } - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseCoreExtension', '~> 12.4.0' - s.dependency 'FirebaseFirestoreInternal', '~> 12.4.0' - s.dependency 'FirebaseSharedSwift', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseCoreExtension', '~> 12.6.0' + s.dependency 'FirebaseFirestoreInternal', '~> 12.6.0' + s.dependency 'FirebaseSharedSwift', '~> 12.6.0' end diff --git a/FirebaseFirestoreInternal.podspec b/FirebaseFirestoreInternal.podspec index a324ed9a54f..c40b791e8ef 100644 --- a/FirebaseFirestoreInternal.podspec +++ b/FirebaseFirestoreInternal.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseFirestoreInternal' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Google Cloud Firestore' s.description = <<-DESC @@ -91,8 +91,8 @@ Google Cloud Firestore is a NoSQL document database built for automatic scaling, "#{s.module_name}_Privacy" => 'Firestore/Source/Resources/PrivacyInfo.xcprivacy' } - s.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' - s.dependency 'FirebaseCore', '~> 12.4.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' abseil_version = '~> 1.20240722.0' s.dependency 'abseil/algorithm', abseil_version diff --git a/FirebaseFunctions.podspec b/FirebaseFunctions.podspec index e3276fc464d..a6506a644ba 100644 --- a/FirebaseFunctions.podspec +++ b/FirebaseFunctions.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseFunctions' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Cloud Functions for Firebase' s.description = <<-DESC @@ -35,12 +35,12 @@ Cloud Functions for Firebase. 'FirebaseFunctions/Sources/**/*.swift', ] - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseCoreExtension', '~> 12.4.0' - s.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' - s.dependency 'FirebaseAuthInterop', '~> 12.4.0' - s.dependency 'FirebaseMessagingInterop', '~> 12.4.0' - s.dependency 'FirebaseSharedSwift', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseCoreExtension', '~> 12.6.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' + s.dependency 'FirebaseAuthInterop', '~> 12.6.0' + s.dependency 'FirebaseMessagingInterop', '~> 12.6.0' + s.dependency 'FirebaseSharedSwift', '~> 12.6.0' s.dependency 'GTMSessionFetcher/Core', '>= 3.4', '< 6.0' s.test_spec 'objc' do |objc_tests| diff --git a/FirebaseInAppMessaging.podspec b/FirebaseInAppMessaging.podspec index f122ac31e15..c67d833fcd1 100644 --- a/FirebaseInAppMessaging.podspec +++ b/FirebaseInAppMessaging.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseInAppMessaging' - s.version = '12.4.0-beta' + s.version = '12.6.0-beta' s.summary = 'Firebase In-App Messaging for iOS' s.description = <<-DESC @@ -80,9 +80,9 @@ See more product details at https://firebase.google.com/products/in-app-messagin s.framework = 'UIKit' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseInstallations', '~> 12.4.0' - s.dependency 'FirebaseABTesting', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' + s.dependency 'FirebaseABTesting', '~> 12.6.0' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' s.dependency 'nanopb', '~> 3.30910.0' diff --git a/FirebaseInstallations.podspec b/FirebaseInstallations.podspec index 69fa914c438..c7fa4a5f625 100644 --- a/FirebaseInstallations.podspec +++ b/FirebaseInstallations.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseInstallations' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Firebase Installations' s.description = <<-DESC @@ -45,7 +45,7 @@ Pod::Spec.new do |s| } s.framework = 'Security' - s.dependency 'FirebaseCore', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.6.0' s.dependency 'PromisesObjC', '~> 2.4' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' diff --git a/FirebaseMLModelDownloader.podspec b/FirebaseMLModelDownloader.podspec index 66e6010c3fb..272d4a9ce60 100644 --- a/FirebaseMLModelDownloader.podspec +++ b/FirebaseMLModelDownloader.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseMLModelDownloader' - s.version = '12.4.0-beta' + s.version = '12.6.0-beta' s.summary = 'Firebase ML Model Downloader' s.description = <<-DESC @@ -36,9 +36,9 @@ Pod::Spec.new do |s| ] s.framework = 'Foundation' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseCoreExtension', '~> 12.4.0' - s.dependency 'FirebaseInstallations', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseCoreExtension', '~> 12.6.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' s.dependency 'GoogleDataTransport', '~> 10.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' s.dependency 'SwiftProtobuf', '~> 1.19' diff --git a/FirebaseMessaging.podspec b/FirebaseMessaging.podspec index 7815af1b184..7afc529c58c 100644 --- a/FirebaseMessaging.podspec +++ b/FirebaseMessaging.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseMessaging' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Firebase Messaging' s.description = <<-DESC @@ -60,8 +60,8 @@ device, and it is completely free. s.tvos.framework = 'SystemConfiguration' s.osx.framework = 'SystemConfiguration' s.weak_framework = 'UserNotifications' - s.dependency 'FirebaseInstallations', '~> 12.4.0' - s.dependency 'FirebaseCore', '~> 12.4.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/Reachability', '~> 8.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' diff --git a/FirebaseMessagingInterop.podspec b/FirebaseMessagingInterop.podspec index fa29a1849d9..d2d1062a555 100644 --- a/FirebaseMessagingInterop.podspec +++ b/FirebaseMessagingInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseMessagingInterop' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Interfaces that allow other Firebase SDKs to use Messaging functionality.' s.description = <<-DESC diff --git a/FirebasePerformance.podspec b/FirebasePerformance.podspec index b571987c056..6d9c9316394 100644 --- a/FirebasePerformance.podspec +++ b/FirebasePerformance.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebasePerformance' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Firebase Performance' s.description = <<-DESC @@ -58,10 +58,10 @@ Firebase Performance library to measure performance of Mobile and Web Apps. s.ios.framework = 'CoreTelephony' s.framework = 'QuartzCore' s.framework = 'SystemConfiguration' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseInstallations', '~> 12.4.0' - s.dependency 'FirebaseRemoteConfig', '~> 12.4.0' - s.dependency 'FirebaseSessions', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' + s.dependency 'FirebaseRemoteConfig', '~> 12.6.0' + s.dependency 'FirebaseSessions', '~> 12.6.0' s.dependency 'GoogleDataTransport', '~> 10.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/MethodSwizzler', '~> 8.1' diff --git a/FirebasePerformance/CHANGELOG.md b/FirebasePerformance/CHANGELOG.md index 3e301bf9443..01f671e5db2 100644 --- a/FirebasePerformance/CHANGELOG.md +++ b/FirebasePerformance/CHANGELOG.md @@ -1,3 +1,7 @@ +# 12.5.0 +- [fixed] Prevent race condition crash in FPRTraceBackgroundActivityTracker. (#14273) +- [fixed] Fix app start trace outliers from network delays. (#10733) + # 12.3.0 - [fixed] Add missing nanopb dependency to fix SwiftPM builds when building dynamically linked libraries. (#15276) diff --git a/FirebasePerformance/Sources/AppActivity/FPRAppActivityTracker.m b/FirebasePerformance/Sources/AppActivity/FPRAppActivityTracker.m index f6efa0972f9..fd38dd125fb 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRAppActivityTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRAppActivityTracker.m @@ -19,6 +19,7 @@ #import #import "FirebasePerformance/Sources/AppActivity/FPRSessionManager.h" +#import "FirebasePerformance/Sources/Common/FPRDiagnostics.h" #import "FirebasePerformance/Sources/Configurations/FPRConfigurations.h" #import "FirebasePerformance/Sources/Gauges/CPU/FPRCPUGaugeCollector+Private.h" #import "FirebasePerformance/Sources/Gauges/FPRGaugeManager.h" @@ -71,6 +72,9 @@ @interface FPRAppActivityTracker () /** Tracks if the gauge metrics are dispatched. */ @property(nonatomic) BOOL appStartGaugeMetricDispatched; +/** Tracks if app start trace completion logic has been executed. */ +@property(nonatomic) BOOL appStartTraceCompleted; + /** Firebase Performance Configuration object */ @property(nonatomic) FPRConfigurations *configurations; @@ -113,6 +117,18 @@ + (void)windowDidBecomeVisible:(NSNotification *)notification { + (void)applicationDidFinishLaunching:(NSNotification *)notification { applicationDidFinishLaunchTime = [NSDate date]; + + // Detect a background launch and invalidate app start time + // this prevents we measure duration from background launch + UIApplicationState state = [UIApplication sharedApplication].applicationState; + if (state == UIApplicationStateBackground) { + // App launched in background so we invalidate the captured app start time + // to prevent incorrect measurement when user later opens the app + appStartTime = nil; + FPRLogDebug(kFPRTraceNotCreated, + @"Background launch detected. App start measurement will be skipped."); + } + [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidFinishLaunchingNotification object:nil]; @@ -135,6 +151,7 @@ - (instancetype)initAppActivityTracker { if (self != nil) { _applicationState = FPRApplicationStateUnknown; _appStartGaugeMetricDispatched = NO; + _appStartTraceCompleted = NO; _configurations = [FPRConfigurations sharedInstance]; [self startTrackingNetwork]; } @@ -242,6 +259,15 @@ - (void)appDidBecomeActiveNotification:(NSNotification *)notification { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ + // Early bailout if background launch was detected, appStartTime will be nil if the app was + // launched in background + if (appStartTime == nil) { + FPRLogDebug(kFPRTraceNotCreated, + @"App start trace skipped due to background launch. " + @"This prevents reporting incorrect multi-minute/hour durations."); + return; + } + self.appStartTrace = [[FIRTrace alloc] initInternalTraceWithName:kFPRAppStartTraceName]; [self.appStartTrace startWithStartTime:appStartTime]; [self.appStartTrace startStageNamed:kFPRAppStartStageNameTimeToUI startTime:appStartTime]; @@ -250,9 +276,13 @@ - (void)appDidBecomeActiveNotification:(NSNotification *)notification { [self.appStartTrace startStageNamed:kFPRAppStartStageNameTimeToFirstDraw]; }); - // If ever the app start trace had it life in background stage, do not send the trace. - if (self.appStartTrace.backgroundTraceState != FPRTraceStateForegroundOnly) { + // If ever the app start trace had its life in background stage, do not send the trace. + if (self.appStartTrace && + self.appStartTrace.backgroundTraceState != FPRTraceStateForegroundOnly) { + [self.appStartTrace cancel]; self.appStartTrace = nil; + FPRLogDebug(kFPRTraceNotCreated, + @"App start trace cancelled due to background state contamination."); } // Stop the active background session trace. @@ -266,28 +296,44 @@ - (void)appDidBecomeActiveNotification:(NSNotification *)notification { self.foregroundSessionTrace = appTrace; // Start measuring time to make the app interactive on the App start trace. - static BOOL TTIStageStarted = NO; - if (!TTIStageStarted) { + if (!self.appStartTraceCompleted && self.appStartTrace) { [self.appStartTrace startStageNamed:kFPRAppStartStageNameTimeToUserInteraction]; - TTIStageStarted = YES; + self.appStartTraceCompleted = YES; // Assumption here is that - the app becomes interactive in the next runloop cycle. // It is possible that the app does more things later, but for now we are not measuring that. + __weak typeof(self) weakSelf = self; dispatch_async(dispatch_get_main_queue(), ^{ - NSTimeInterval startTimeSinceEpoch = [self.appStartTrace startTimeSinceEpoch]; + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf || !strongSelf.appStartTrace) { + return; + } + + NSTimeInterval startTimeSinceEpoch = [strongSelf.appStartTrace startTimeSinceEpoch]; NSTimeInterval currentTimeSinceEpoch = [[NSDate date] timeIntervalSince1970]; + NSTimeInterval measuredAppStartTime = currentTimeSinceEpoch - startTimeSinceEpoch; - // The below check is to account for 2 scenarios. - // 1. The app gets started in the background and might come to foreground a lot later. - // 2. The app is launched, but immediately backgrounded for some reason and the actual launch + // The below check accounts for multiple scenarios: + // 1. App started in background and comes to foreground later + // 2. App launched but immediately backgroundedfor some reason and the actual launch // happens a lot later. - // Dropping the app start trace in such situations where the launch time is taking more than - // 60 minutes. This is an approximation, but a more agreeable timelimit for app start. - if ((currentTimeSinceEpoch - startTimeSinceEpoch < gAppStartMaxValidDuration) && - [self isAppStartEnabled] && ![self isApplicationPreWarmed]) { - [self.appStartTrace stop]; + // 3. Network delays during startup inflating metrics + // 4. iOS prewarm scenarios + // 5. Dropping the app start trace in such situations where the launch time is taking more + // than 60 minutes. This is an approximation, but a more agreeable timelimit for app start. + BOOL shouldDispatchAppStartTrace = (measuredAppStartTime < gAppStartMaxValidDuration) && + [strongSelf isAppStartEnabled] && + ![strongSelf isApplicationPreWarmed]; + + if (shouldDispatchAppStartTrace) { + [strongSelf.appStartTrace stop]; } else { - [self.appStartTrace cancel]; + [strongSelf.appStartTrace cancel]; + if (measuredAppStartTime >= gAppStartMaxValidDuration) { + FPRLogDebug(kFPRTraceInvalidName, + @"App start trace cancelled due to excessive duration: %.2fs", + measuredAppStartTime); + } } }); } diff --git a/FirebasePerformance/Sources/AppActivity/FPRTraceBackgroundActivityTracker.m b/FirebasePerformance/Sources/AppActivity/FPRTraceBackgroundActivityTracker.m index 5c7db89c9fe..74636f198b0 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRTraceBackgroundActivityTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRTraceBackgroundActivityTracker.m @@ -23,6 +23,8 @@ @interface FPRTraceBackgroundActivityTracker () @property(nonatomic, readwrite) FPRTraceState traceBackgroundState; +- (void)registerNotificationObservers; + @end @implementation FPRTraceBackgroundActivityTracker @@ -35,21 +37,29 @@ - (instancetype)init { } else { _traceBackgroundState = FPRTraceStateForegroundOnly; } + __weak typeof(self) weakSelf = self; dispatch_async(dispatch_get_main_queue(), ^{ - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidBecomeActive:) - name:UIApplicationDidBecomeActiveNotification - object:[UIApplication sharedApplication]]; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidEnterBackground:) - name:UIApplicationDidEnterBackgroundNotification - object:[UIApplication sharedApplication]]; + __strong typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf) { + [strongSelf registerNotificationObservers]; + } }); } return self; } +- (void)registerNotificationObservers { + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(applicationDidBecomeActive:) + name:UIApplicationDidBecomeActiveNotification + object:[UIApplication sharedApplication]]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(applicationDidEnterBackground:) + name:UIApplicationDidEnterBackgroundNotification + object:[UIApplication sharedApplication]]; +} + - (void)dealloc { // Remove all the notification observers registered. [[NSNotificationCenter defaultCenter] removeObserver:self]; diff --git a/FirebasePerformance/Tests/Unit/FIRPerformanceTest.m b/FirebasePerformance/Tests/Unit/FIRPerformanceTest.m index 947311e4544..3dedf81bfc9 100644 --- a/FirebasePerformance/Tests/Unit/FIRPerformanceTest.m +++ b/FirebasePerformance/Tests/Unit/FIRPerformanceTest.m @@ -118,7 +118,7 @@ - (void)testReadingAttributesFromProperty { } /** Validates if attributes property is immutable. */ -- (void)testImmutablityOfAttributesProperty { +- (void)testImmutabilityOfAttributesProperty { [self.performance setValue:@"bar" forAttribute:@"foo"]; NSMutableDictionary *attributes = (NSMutableDictionary *)self.performance.attributes; diff --git a/FirebasePerformance/Tests/Unit/FPRTraceBackgroundActivityTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRTraceBackgroundActivityTrackerTest.m index 272a7c418fa..334027fa3aa 100644 --- a/FirebasePerformance/Tests/Unit/FPRTraceBackgroundActivityTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRTraceBackgroundActivityTrackerTest.m @@ -61,4 +61,69 @@ - (void)testBackgroundTracking { }]; } +/** Tests that synchronous observer registration works correctly and observers are immediately + * available. */ +- (void)testObservers_synchronousRegistrationAddsObserver { + NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter]; + FPRTraceBackgroundActivityTracker *tracker = [[FPRTraceBackgroundActivityTracker alloc] init]; + XCTAssertNotNil(tracker); + + [notificationCenter postNotificationName:UIApplicationDidBecomeActiveNotification + object:[UIApplication sharedApplication]]; + XCTAssertEqual(tracker.traceBackgroundState, FPRTraceStateForegroundOnly); + + tracker = nil; + XCTAssertNil(tracker); + XCTAssertNoThrow([notificationCenter postNotificationName:UIApplicationDidBecomeActiveNotification + object:[UIApplication sharedApplication]]); + XCTAssertNoThrow([notificationCenter + postNotificationName:UIApplicationDidEnterBackgroundNotification + object:[UIApplication sharedApplication]]); +} + +/** Tests rapid creation and deallocation to verify race condition. */ +- (void)testRapidCreationAndDeallocation_noRaceCondition { + for (int i = 0; i < 100; i++) { + @autoreleasepool { + FPRTraceBackgroundActivityTracker *tracker = [[FPRTraceBackgroundActivityTracker alloc] init]; + XCTAssertNotNil(tracker); + + [[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidBecomeActiveNotification + object:[UIApplication sharedApplication]]; + } + } + + XCTAssertNoThrow([[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidBecomeActiveNotification + object:[UIApplication sharedApplication]]); + XCTAssertNoThrow([[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidEnterBackgroundNotification + object:[UIApplication sharedApplication]]); +} + +/** Tests observer registration when created from background thread. */ +- (void)testObservers_registrationFromBackgroundThread { + XCTestExpectation *expectation = [self expectationWithDescription:@"Background thread creation"]; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + FPRTraceBackgroundActivityTracker *tracker = [[FPRTraceBackgroundActivityTracker alloc] init]; + XCTAssertNotNil(tracker); + + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidBecomeActiveNotification + object:[UIApplication sharedApplication]]; + + XCTAssertEqual(tracker.traceBackgroundState, FPRTraceStateForegroundOnly); + [expectation fulfill]; + }); + }); + + [self waitForExpectationsWithTimeout:5.0 + handler:^(NSError *error) { + XCTAssertNil(error, @"Test timed out"); + }]; +} + @end diff --git a/FirebasePerformance/Tests/Unit/Instruments/FIRHTTPMetricTests.m b/FirebasePerformance/Tests/Unit/Instruments/FIRHTTPMetricTests.m index c837c477a44..e59e7c0e02a 100644 --- a/FirebasePerformance/Tests/Unit/Instruments/FIRHTTPMetricTests.m +++ b/FirebasePerformance/Tests/Unit/Instruments/FIRHTTPMetricTests.m @@ -283,7 +283,7 @@ - (void)testReadingAttributesFromProperty { } /** Validates if attributes property is immutable. */ -- (void)testImmutablityOfAttributesProperty { +- (void)testImmutabilityOfAttributesProperty { FIRHTTPMetric *metric = [[FIRHTTPMetric alloc] initWithURL:self.sampleURL HTTPMethod:FIRHTTPMethodGET]; [metric setValue:@"bar" forAttribute:@"foo"]; diff --git a/FirebasePerformance/Tests/Unit/Timer/FIRTraceTest.m b/FirebasePerformance/Tests/Unit/Timer/FIRTraceTest.m index 39a389fdb3b..f2c2608d9dc 100644 --- a/FirebasePerformance/Tests/Unit/Timer/FIRTraceTest.m +++ b/FirebasePerformance/Tests/Unit/Timer/FIRTraceTest.m @@ -612,7 +612,7 @@ - (void)testReadingAttributesFromProperty { } /** Validates if attributes property is immutable. */ -- (void)testImmutablityOfAttributesProperty { +- (void)testImmutabilityOfAttributesProperty { FIRTrace *trace = [[FIRTrace alloc] initWithName:@"Random"]; [trace setValue:@"bar" forAttribute:@"foo"]; NSMutableDictionary *attributes = diff --git a/FirebaseRemoteConfig.podspec b/FirebaseRemoteConfig.podspec index 8247f4cccf3..81417a7a5e5 100644 --- a/FirebaseRemoteConfig.podspec +++ b/FirebaseRemoteConfig.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseRemoteConfig' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Firebase Remote Config' s.description = <<-DESC @@ -49,13 +49,13 @@ app update. s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' } - s.dependency 'FirebaseABTesting', '~> 12.4.0' - s.dependency 'FirebaseSharedSwift', '~> 12.4.0' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseInstallations', '~> 12.4.0' + s.dependency 'FirebaseABTesting', '~> 12.6.0' + s.dependency 'FirebaseSharedSwift', '~> 12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/NSData+zlib', '~> 8.1' - s.dependency 'FirebaseRemoteConfigInterop', '~> 12.4.0' + s.dependency 'FirebaseRemoteConfigInterop', '~> 12.6.0' s.test_spec 'unit' do |unit_tests| unit_tests.scheme = { :code_coverage => true } diff --git a/FirebaseRemoteConfig/CHANGELOG.md b/FirebaseRemoteConfig/CHANGELOG.md index 91ba0e80f24..4d4beeb2c21 100644 --- a/FirebaseRemoteConfig/CHANGELOG.md +++ b/FirebaseRemoteConfig/CHANGELOG.md @@ -1,3 +1,9 @@ +# 12.6.0 +- [fixed] Fixed a bug where Remote Config does not work after a restore + of a previous backup of the device. (#14459) +- [fixed] Fixed a data race condition on the global database status flag + by synchronizing all read and write operations. (#14715) + # 12.3.0 - [fixed] Add missing GoogleUtilities dependency to fix SwiftPM builds when building dynamically linked libraries. (#15276) diff --git a/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m b/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m index 258eb362fe7..bfc55cdf877 100644 --- a/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m +++ b/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m @@ -160,6 +160,10 @@ - (instancetype)initWithAppName:(NSString *)appName // Initialize RCConfigContent if not already. _configContent = configContent; + + // We must ensure the DBManager's asynchronous setup (which sets gIsNewDatabase) + // completes before RCNConfigSettings tries to read that state for the resetUserDefaults logic. + [_DBManager waitForDatabaseOperationQueue]; _settings = [[RCNConfigSettings alloc] initWithDatabaseManager:_DBManager namespace:_FIRNamespace firebaseAppName:appName diff --git a/FirebaseRemoteConfig/Sources/RCNConfigDBManager.h b/FirebaseRemoteConfig/Sources/RCNConfigDBManager.h index e22b40d3779..d881381186b 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigDBManager.h +++ b/FirebaseRemoteConfig/Sources/RCNConfigDBManager.h @@ -130,4 +130,8 @@ typedef void (^RCNDBLoadCompletion)(BOOL success, /// Returns true if this a new install of the Config database. - (BOOL)isNewDatabase; + +/// Blocks the calling thread until all pending database operations on the internal serial queue are +/// completed. Used to enforce initialization order. +- (void)waitForDatabaseOperationQueue; @end diff --git a/FirebaseRemoteConfig/Sources/RCNConfigDBManager.m b/FirebaseRemoteConfig/Sources/RCNConfigDBManager.m index 161f678b8d6..c1fd403a246 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigDBManager.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigDBManager.m @@ -38,6 +38,9 @@ /// The storage sub-directory that the Remote Config database resides in. static NSString *const RCNRemoteConfigStorageSubDirectory = @"Google/RemoteConfig"; +/// Introduce a dedicated serial queue for gIsNewDatabase access. +static dispatch_queue_t gIsNewDatabaseQueue; + /// Remote Config database path for deprecated V0 version. static NSString *RemoteConfigPathForOldDatabaseV0(void) { NSArray *dirPaths = @@ -82,7 +85,9 @@ static BOOL RemoteConfigCreateFilePathIfNotExist(NSString *filePath) { } NSFileManager *fileManager = [NSFileManager defaultManager]; if (![fileManager fileExistsAtPath:filePath]) { - gIsNewDatabase = YES; + dispatch_sync(gIsNewDatabaseQueue, ^{ + gIsNewDatabase = YES; + }); NSError *error; [fileManager createDirectoryAtPath:[filePath stringByDeletingLastPathComponent] withIntermediateDirectories:YES @@ -119,6 +124,8 @@ + (instancetype)sharedInstance { static dispatch_once_t onceToken; static RCNConfigDBManager *sharedInstance; dispatch_once(&onceToken, ^{ + gIsNewDatabaseQueue = dispatch_queue_create("com.google.FirebaseRemoteConfig.gIsNewDatabase", + DISPATCH_QUEUE_SERIAL); sharedInstance = [[RCNConfigDBManager alloc] init]; }); return sharedInstance; @@ -1219,7 +1226,19 @@ - (BOOL)logErrorWithSQL:(const char *)SQL } - (BOOL)isNewDatabase { - return gIsNewDatabase; + __block BOOL isNew; + dispatch_sync(gIsNewDatabaseQueue, ^{ + isNew = gIsNewDatabase; + }); + return isNew; +} + +- (void)waitForDatabaseOperationQueue { + // This dispatch_sync call ensures that all blocks queued before it on _databaseOperationQueue + // (including the createOrOpenDatabase setup block) execute and complete before this method + // returns. + dispatch_sync(_databaseOperationQueue, ^{ + }); } @end diff --git a/FirebaseRemoteConfigInterop.podspec b/FirebaseRemoteConfigInterop.podspec index 6530c98dccc..49f8c976264 100644 --- a/FirebaseRemoteConfigInterop.podspec +++ b/FirebaseRemoteConfigInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseRemoteConfigInterop' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Interfaces that allow other Firebase SDKs to use Remote Config functionality.' s.description = <<-DESC diff --git a/FirebaseSessions.podspec b/FirebaseSessions.podspec index 56c7d3f6a13..0ad66ff18c7 100644 --- a/FirebaseSessions.podspec +++ b/FirebaseSessions.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseSessions' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Firebase Sessions' s.description = <<-DESC @@ -39,9 +39,9 @@ Pod::Spec.new do |s| base_dir + 'SourcesObjC/**/*.{c,h,m,mm}', ] - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseCoreExtension', '~> 12.4.0' - s.dependency 'FirebaseInstallations', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseCoreExtension', '~> 12.6.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' s.dependency 'GoogleDataTransport', '~> 10.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' diff --git a/FirebaseSharedSwift.podspec b/FirebaseSharedSwift.podspec index e5be20b55a1..feb413fd285 100644 --- a/FirebaseSharedSwift.podspec +++ b/FirebaseSharedSwift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseSharedSwift' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Shared Swift Extensions for Firebase' s.description = <<-DESC diff --git a/FirebaseStorage.podspec b/FirebaseStorage.podspec index c8270ad7da5..3aff87e24b3 100644 --- a/FirebaseStorage.podspec +++ b/FirebaseStorage.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseStorage' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Firebase Storage' s.description = <<-DESC @@ -37,10 +37,10 @@ Firebase Storage provides robust, secure file uploads and downloads from Firebas 'FirebaseStorage/Typedefs/*.h', ] - s.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' - s.dependency 'FirebaseAuthInterop', '~> 12.4.0' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseCoreExtension', '~> 12.4.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' + s.dependency 'FirebaseAuthInterop', '~> 12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseCoreExtension', '~> 12.6.0' s.dependency 'GTMSessionFetcher/Core', '>= 3.4', '< 6.0' s.dependency 'GoogleUtilities/Environment', '~> 8.1' @@ -57,7 +57,7 @@ Firebase Storage provides robust, secure file uploads and downloads from Firebas objc_tests.requires_app_host = true objc_tests.resources = 'FirebaseStorage/Tests/Integration/Resources/1mb.dat', 'FirebaseStorage/Tests/Integration/Resources/GoogleService-Info.plist' - objc_tests.dependency 'FirebaseAuth', '~> 12.4.0' + objc_tests.dependency 'FirebaseAuth', '~> 12.6.0' objc_tests.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' } @@ -86,6 +86,6 @@ Firebase Storage provides robust, secure file uploads and downloads from Firebas int_tests.resources = 'FirebaseStorage/Tests/Integration/Resources/1mb.dat', 'FirebaseStorage/Tests/Integration/Resources/GoogleService-Info.plist', 'FirebaseStorage/Tests/Integration/Resources/HomeImprovement.numbers' - int_tests.dependency 'FirebaseAuth', '~> 12.4.0' + int_tests.dependency 'FirebaseAuth', '~> 12.6.0' end end diff --git a/Firestore/CHANGELOG.md b/Firestore/CHANGELOG.md index 628e3afd826..573f1822684 100644 --- a/Firestore/CHANGELOG.md +++ b/Firestore/CHANGELOG.md @@ -1,3 +1,7 @@ +# 12.4.0 +- [fixed] Implemented an internal workaround to fix + [CVE-2025-0838](https://nvd.nist.gov/vuln/detail/CVE-2025-0838). (#15300) + # 12.1.0 - [fixed] Fixed accidental removal of `pod "Firebase/Firestore"` for tvOS in 12.0.0. diff --git a/Firestore/README.md b/Firestore/README.md index 9c7d01a3531..13a79d68606 100644 --- a/Firestore/README.md +++ b/Firestore/README.md @@ -48,6 +48,9 @@ scripts/build.sh Firestore iOS spm This is rarely necessary for primary development and is done automatically by CI. +For a detailed explanation of the Firestore target hierarchy in the +`Package.swift` manifest, see [FirestoreSPM.md](../docs/FirestoreSPM.md). + ### Improving the debugger experience You can install a set of type formatters to improve the presentation of diff --git a/Firestore/core/CMakeLists.txt b/Firestore/core/CMakeLists.txt index cb405074816..90cd4af476c 100644 --- a/Firestore/core/CMakeLists.txt +++ b/Firestore/core/CMakeLists.txt @@ -245,6 +245,15 @@ target_include_directories( ${PROJECT_SOURCE_DIR}/Firestore/core/include ) +# Add the gRPC include directories as SYSTEM directories to silence warnings +target_include_directories( + firestore_core + SYSTEM # The SYSTEM keyword applies to all directories in this block + PUBLIC + # This generator expression automatically gets the correct include path(s) from the grpc++ target + $ +) + target_link_libraries( firestore_core PUBLIC LevelDB::LevelDB diff --git a/Gemfile.lock b/Gemfile.lock index aa840195966..c4e166dd671 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -150,7 +150,7 @@ GEM open4 (1.3.4) public_suffix (4.0.7) rchardet (1.8.0) - rexml (3.3.9) + rexml (3.4.2) ruby-macho (2.5.1) ruby2_keywords (0.0.5) sawyer (0.9.2) diff --git a/GoogleAppMeasurement.podspec b/GoogleAppMeasurement.podspec index c1ee6cfb4e3..b354f4e8f7c 100644 --- a/GoogleAppMeasurement.podspec +++ b/GoogleAppMeasurement.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'GoogleAppMeasurement' - s.version = '12.4.0' + s.version = '12.6.0' s.summary = 'Shared measurement methods for Google libraries. Not intended for direct use.' s.description = <<-DESC @@ -16,7 +16,7 @@ Pod::Spec.new do |s| s.authors = 'Google, Inc.' s.source = { - :http => 'https://dl.google.com/firebase/ios/analytics/1c0181b69fa16f29/GoogleAppMeasurement-12.3.0.tar.gz' + :http => 'https://dl.google.com/firebase/ios/analytics/4a8fa8d922b0b454/GoogleAppMeasurement-12.5.0.tar.gz' } s.cocoapods_version = '>= 1.12.0' @@ -37,9 +37,9 @@ Pod::Spec.new do |s| s.default_subspecs = 'Default' s.subspec 'Default' do |ss| - ss.dependency 'GoogleAppMeasurement/Core', '12.4.0' - ss.dependency 'GoogleAppMeasurement/IdentitySupport', '12.4.0' - ss.ios.dependency 'GoogleAdsOnDeviceConversion', '~> 3.0.0' + ss.dependency 'GoogleAppMeasurement/Core', '12.6.0' + ss.dependency 'GoogleAppMeasurement/IdentitySupport', '12.6.0' + ss.ios.dependency 'GoogleAdsOnDeviceConversion', '~> 3.2.0' end s.subspec 'Core' do |ss| @@ -47,7 +47,7 @@ Pod::Spec.new do |s| end s.subspec 'IdentitySupport' do |ss| - ss.dependency 'GoogleAppMeasurement/Core', '12.4.0' + ss.dependency 'GoogleAppMeasurement/Core', '12.6.0' ss.vendored_frameworks = 'Frameworks/GoogleAppMeasurementIdentitySupport.xcframework' end end diff --git a/Package.swift b/Package.swift index 3bcbca83686..a776a6b1d7a 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,7 @@ import PackageDescription -let firebaseVersion = "12.4.0" +let firebaseVersion = "12.6.0" let package = Package( name: "Firebase", @@ -26,7 +26,16 @@ let package = Package( products: [ .library( name: "FirebaseAI", - targets: ["FirebaseAI"] + targets: [ + "FirebaseAI", + "FirebaseAILogic", + ] + ), + .library( + name: "FirebaseAILogic", + targets: [ + "FirebaseAILogic", + ] ), .library( name: "FirebaseAnalytics", @@ -178,7 +187,7 @@ let package = Package( // MARK: - Firebase AI .target( - name: "FirebaseAI", + name: "FirebaseAILogic", dependencies: [ "FirebaseAppCheckInterop", "FirebaseAuthInterop", @@ -188,9 +197,9 @@ let package = Package( path: "FirebaseAI/Sources" ), .testTarget( - name: "FirebaseAIUnit", + name: "FirebaseAILogicUnit", dependencies: [ - "FirebaseAI", + "FirebaseAILogic", "FirebaseStorage", ], path: "FirebaseAI/Tests/Unit", @@ -202,6 +211,16 @@ let package = Package( .headerSearchPath("../../../"), ] ), + .target( + name: "FirebaseAI", + dependencies: ["FirebaseAILogic"], + path: "FirebaseAI/Wrapper/Sources" + ), + .testTarget( + name: "FirebaseAIUnit", + dependencies: ["FirebaseAI"], + path: "FirebaseAI/Wrapper/Tests" + ), // MARK: - Firebase Core @@ -329,8 +348,8 @@ let package = Package( ), .binaryTarget( name: "FirebaseAnalytics", - url: "https://dl.google.com/firebase/ios/swiftpm/12.3.0/FirebaseAnalytics.zip", - checksum: "a7fcb34227d6cc0b2db9b1d3f9dd844801e5a28217f20f1daae6c3d2b7d1e8e1" + url: "https://dl.google.com/firebase/ios/swiftpm/12.5.0/FirebaseAnalytics.zip", + checksum: "7ff922682f5d47e6add687979b3126f391c7d2e8f367599d4ec8d2a58dce8cc9" ), .testTarget( name: "AnalyticsSwiftUnit", @@ -1392,7 +1411,7 @@ func googleAppMeasurementDependency() -> Package.Dependency { return .package(url: appMeasurementURL, branch: "main") } - return .package(url: appMeasurementURL, exact: "12.3.0") + return .package(url: appMeasurementURL, exact: "12.5.0") } func abseilDependency() -> Package.Dependency { @@ -1566,8 +1585,8 @@ func firestoreTargets() -> [Target] { } else { return .binaryTarget( name: "FirebaseFirestoreInternal", - url: "https://dl.google.com/firebase/ios/bin/firestore/12.0.0/rc0/FirebaseFirestoreInternal.zip", - checksum: "e7add08e9044ef45f7923d0b9ea5518ddc66b090d3f7e9455382f769e74c48c4" + url: "https://dl.google.com/firebase/ios/bin/firestore/12.4.0/rc0/FirebaseFirestoreInternal.zip", + checksum: "58b916624c01a56c5de694cfc9c5cc7aabcafb13b54e7bde8c83bacc51a3460d" ) } }() diff --git a/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json b/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json index a03e94e57a6..7e2b17a6dd3 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseABTesting-bb0e44f97fd81c31.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseABTesting-328b9123860fa215.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseABTesting-240f73a221798c3b.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseABTesting-453e3715e84856d3.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseABTesting-53d336f7762176f9.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/ABTesting-d0fdf10c43e985b1.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/ABTesting-d0fdf10c43e985b1.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/ABTesting-a71d17cadc209af9.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseAIBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAIBinary.json index e381207e117..69dc27a5d40 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAIBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAIBinary.json @@ -5,5 +5,6 @@ "12.0.0": "https://dl.google.com/dl/firebase/ios/carthage/12.0.0/FirebaseAI-05a4568076093001.zip", "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseAI-1fa7d016c66b2331.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseAI-8fac222fb35cd84e.zip", - "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAI-0b9e8cce3bf315f0.zip" + "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAI-0b9e8cce3bf315f0.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseAI-e465e675fb9cbb7c.zip" } diff --git a/ReleaseTooling/CarthageJSON/FirebaseAILogicBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAILogicBinary.json new file mode 100644 index 00000000000..ca0fbd563b2 --- /dev/null +++ b/ReleaseTooling/CarthageJSON/FirebaseAILogicBinary.json @@ -0,0 +1,3 @@ +{ + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseAILogic-543b2fedd88a76fd.zip" +} diff --git a/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json index e83b6fa2484..c7878fc25bb 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseAnalytics-88dad74aa8ab040a.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseAnalytics-b37787f72cdbb950.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAnalytics-866ebeb7925d0267.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseAnalytics-61b0d6c9596bf37a.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseAnalytics-d3705dd81b8b5477.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Analytics-2468c231ebeb7922.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Analytics-bc8101d420b896c5.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Analytics-d2b6a6b0242db786.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json index 730f4448260..f11b39dda48 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseAppCheck-072a1be1f8eb1177.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseAppCheck-dac2380c7e1b9898.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAppCheck-f0fb8c2a38b272c7.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseAppCheck-f45ebf0c8d5ae0aa.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseAppCheck-15439cab8a605863.zip", "8.0.0": "https://dl.google.com/dl/firebase/ios/carthage/8.0.0/FirebaseAppCheck-9ef1d217cf057203.zip", "8.1.0": "https://dl.google.com/dl/firebase/ios/carthage/8.1.0/FirebaseAppCheck-fc03215d9fe45d3a.zip", "8.10.0": "https://dl.google.com/dl/firebase/ios/carthage/8.10.0/FirebaseAppCheck-6ebe9e9539f06003.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json index 5bb403553da..fe5ad8cc63f 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseAppDistribution-370884f5f825f098.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseAppDistribution-042b04483c9241b6.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAppDistribution-8498aaebd9f9e633.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseAppDistribution-aa1bb9ef501d82e7.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseAppDistribution-935f33cbb50ac9c0.zip", "6.31.0": "https://dl.google.com/dl/firebase/ios/carthage/6.31.0/FirebaseAppDistribution-07f6a2cf7f576a8a.zip", "6.32.0": "https://dl.google.com/dl/firebase/ios/carthage/6.32.0/FirebaseAppDistribution-a9c4f5db794508ca.zip", "6.33.0": "https://dl.google.com/dl/firebase/ios/carthage/6.33.0/FirebaseAppDistribution-448a96d2ade54581.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json index a0a4b7a240c..3fad38b709c 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseAuth-2c17100b302eb080.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseAuth-9f0a14da6c12ea6d.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAuth-e4ba94c15c57a75f.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseAuth-9d35d5c62a3e1a75.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseAuth-873cd7b4bce5c5a6.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Auth-0fa76ba0f7956220.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Auth-5ddd2b4351012c7a.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Auth-5e248984d78d7284.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json b/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json index 8b7ee0355b6..e918fc87460 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseCrashlytics-fbf241b0c59f3821.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseCrashlytics-623ce628d0404f39.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseCrashlytics-6054b7e88b91a91d.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseCrashlytics-49a8a1b1f30115df.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseCrashlytics-0d4111d63d6eefd0.zip", "6.15.0": "https://dl.google.com/dl/firebase/ios/carthage/6.15.0/FirebaseCrashlytics-1c6d22d5b73c84fd.zip", "6.16.0": "https://dl.google.com/dl/firebase/ios/carthage/6.16.0/FirebaseCrashlytics-938e5fd0e2eab3b3.zip", "6.17.0": "https://dl.google.com/dl/firebase/ios/carthage/6.17.0/FirebaseCrashlytics-fa09f0c8f31ed5d9.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json b/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json index 9f585bed58a..cf1d7d7ce4d 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseDatabase-d6c24e13e4b05437.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseDatabase-a87ae96a7eeb2535.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseDatabase-2b6c597465ec9d34.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseDatabase-34750cd661cbd49b.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseDatabase-12c5aa3455796be4.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Database-1f7a820452722c7d.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Database-1f7a820452722c7d.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Database-59a12d87456b3e1c.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json b/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json index 58e18b4caaa..7ed793df826 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseFirestore-6098779ef7b7b151.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseFirestore-8d65b82dc9d53ddf.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseFirestore-e8ec00ce472204d2.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseFirestore-acab074433fa0c6f.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseFirestore-ff38f0cab6f32140.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Firestore-68fc02c229d0cc69.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Firestore-87a804ab561d91db.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Firestore-ecb3eea7bde7e8e8.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json b/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json index 7ffdb345717..35c9605edd4 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseFunctions-f4a1c660d9a2ea75.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseFunctions-f3aa95160827b0af.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseFunctions-6d891e5b755e773c.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseFunctions-8589fb2f6bff1e38.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseFunctions-9a267b1256451803.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Functions-f4c426016dd41e38.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Functions-c6c44427c3034736.zip", "5.0.0": "https://dl.google.com/dl/firebase/ios/carthage/5.0.0/Functions-146f34c401bd459b.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json b/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json index 368c8b27372..3b1a835b62d 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/GoogleSignIn-01f98c11db934294.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/GoogleSignIn-31b2e32d1dadbaa8.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/GoogleSignIn-0a9fd70d77dbb99e.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/GoogleSignIn-bcaadfe04c892ecb.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/GoogleSignIn-ad70a9d759c22eb6.zip", "6.0.0": "https://dl.google.com/dl/firebase/ios/carthage/6.0.0/GoogleSignIn-de9c5d5e8eb6d6ea.zip", "6.1.0": "https://dl.google.com/dl/firebase/ios/carthage/6.1.0/GoogleSignIn-8c82f2870573a793.zip", "6.10.0": "https://dl.google.com/dl/firebase/ios/carthage/6.10.0/GoogleSignIn-ff3aef61c4a55b05.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json b/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json index e7ed4682208..a0f968ba039 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseInAppMessaging-78a0d591fb574512.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseInAppMessaging-0ec7907b67ce2888.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseInAppMessaging-349edad4650cdc0e.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseInAppMessaging-9447455910a3800d.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseInAppMessaging-464b7174be10e192.zip", "5.10.0": "https://dl.google.com/dl/firebase/ios/carthage/5.10.0/InAppMessaging-a7a3f933362f6e95.zip", "5.11.0": "https://dl.google.com/dl/firebase/ios/carthage/5.11.0/InAppMessaging-fa28ce1b88fbca93.zip", "5.12.0": "https://dl.google.com/dl/firebase/ios/carthage/5.12.0/InAppMessaging-fa28ce1b88fbca93.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json b/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json index 0da5e938b8d..ca652f3f343 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseMLModelDownloader-3864d35f4429bc08.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseMLModelDownloader-6bfb3459ae557ef3.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseMLModelDownloader-1d7e6bff24c9b2ec.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseMLModelDownloader-2ce9f1e78f15027f.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseMLModelDownloader-99a4ce736c201f72.zip", "8.0.0": "https://dl.google.com/dl/firebase/ios/carthage/8.0.0/FirebaseMLModelDownloader-8f972757fb181320.zip", "8.1.0": "https://dl.google.com/dl/firebase/ios/carthage/8.1.0/FirebaseMLModelDownloader-058ad59fa6dc0111.zip", "8.10.0": "https://dl.google.com/dl/firebase/ios/carthage/8.10.0/FirebaseMLModelDownloader-286479a966d2fb37.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json b/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json index 32a1c19a979..d6cf70340c1 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseMessaging-252cac88c87e9c55.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseMessaging-d1ab6eaf596d9b7d.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseMessaging-2a16804f5c5602a0.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseMessaging-9175a3fe41e7c83c.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseMessaging-2ffe78328b8babb2.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Messaging-a22ef2b5f2f30f82.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Messaging-94fa4e090c7e9185.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Messaging-2a00a1c64a19d176.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json b/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json index a6a72e117e7..a38ea1ecd9e 100644 --- a/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebasePerformance-dec4dc5c3edadd9a.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebasePerformance-1913383f1952dce6.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebasePerformance-5e59e383ee5e57f7.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebasePerformance-79cbc1d26656ac6d.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebasePerformance-31908337721c4819.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Performance-d8693eb892bfa05b.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Performance-0a400f9460f7a71d.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Performance-f5b4002ab96523e4.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json b/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json index 80a8e71d6d0..d0278766f6d 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseRemoteConfig-bb5ba29a5f73cd24.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseRemoteConfig-3e803b148769baed.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseRemoteConfig-41aaab0dc398a6fc.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseRemoteConfig-5d12611a14be55c9.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseRemoteConfig-925f509a1b213a01.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/RemoteConfig-7e9635365ccd4a17.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/RemoteConfig-e7928fcb6311c439.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/RemoteConfig-9ab1ca5f360a1780.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json b/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json index 127ee0cf03e..b75c403583e 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json @@ -48,6 +48,8 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseStorage-faeffdccd0d44a7c.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseStorage-20489713b94790a0.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseStorage-318fa79cc514a2be.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseStorage-e758b10b671ddad7.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseStorage-c060333135f118a7.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Storage-6b3e77e1a7fdbc61.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Storage-4721c35d2b90a569.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Storage-821299369b9d0fb2.zip", diff --git a/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift b/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift index 046c6f88e39..3b6453ee75c 100755 --- a/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift +++ b/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift @@ -21,7 +21,7 @@ import Foundation /// The version and releasing fields of the non-Firebase pods should be reviewed every release. /// The array should be ordered so that any pod's dependencies precede it in the list. public let shared = Manifest( - version: "12.4.0", + version: "12.6.0", pods: [ Pod("FirebaseSharedSwift"), Pod("FirebaseCoreInternal"), @@ -38,7 +38,8 @@ public let shared = Manifest( Pod("FirebaseABTesting", zip: true), Pod("FirebaseAppCheck", zip: true), Pod("FirebaseRemoteConfig", zip: true), - Pod("FirebaseAI", zip: true), + Pod("FirebaseAILogic", zip: true), + Pod("FirebaseAI", zip: false), Pod("FirebaseAppDistribution", isBeta: true, platforms: ["ios"], zip: true), Pod("FirebaseAuth", zip: true), Pod("FirebaseCrashlytics", zip: true), diff --git a/cmake/external/grpc.cmake b/cmake/external/grpc.cmake index 21d970a8d15..cc7a038a51a 100644 --- a/cmake/external/grpc.cmake +++ b/cmake/external/grpc.cmake @@ -18,7 +18,7 @@ if(TARGET grpc) return() endif() -set(version 1.62.0) +set(version 1.69.0) ExternalProject_Add( grpc @@ -26,7 +26,7 @@ ExternalProject_Add( DOWNLOAD_DIR ${FIREBASE_DOWNLOAD_DIR} DOWNLOAD_NAME grpc-${version}.tar.gz URL https://github.com/grpc/grpc/archive/v${version}.tar.gz - URL_HASH SHA256=f40bde4ce2f31760f65dc49a2f50876f59077026494e67dccf23992548b1b04f + URL_HASH SHA256=cd256d91781911d46a57506978b3979bfee45d5086a1b6668a3ae19c5e77f8dc PREFIX ${PROJECT_BINARY_DIR} SOURCE_DIR ${PROJECT_BINARY_DIR}/src/grpc diff --git a/docs/AsyncStreams/swift-async-sequence-api-design.md b/docs/AsyncStreams/swift-async-sequence-api-design.md new file mode 100644 index 00000000000..07645f30d3e --- /dev/null +++ b/docs/AsyncStreams/swift-async-sequence-api-design.md @@ -0,0 +1,301 @@ +# API Design for Firebase `AsyncSequence` Event Streams + +* **Authors** + * Peter Friese +* **Contributors** + * Nick Cooke + * Paul Beusterien +* **Status**: `In Review` +* **Last Updated**: 2025-09-25 + +## 1. Abstract + +This proposal outlines the integration of Swift's `AsyncStream` and `AsyncSequence` APIs into the Firebase Apple SDK. The goal is to provide a modern, developer-friendly way to consume real-time data streams from Firebase APIs, aligning the SDK with Swift's structured concurrency model and improving the overall developer experience. + +## 2. Background + +Many Firebase APIs produce a sequence of asynchronous events, such as authentication state changes, document and collection updates, and remote configuration updates. Currently, the SDK exposes these through completion-handler-based APIs (listeners). + +```swift +// Current listener-based approach +db.collection("cities").document("SF") + .addSnapshotListener { documentSnapshot, error in + guard let document = documentSnapshot else { /* ... */ } + guard let data = document.data() else { /* ... */ } + print("Current data: \(data)") + } +``` + +This approach breaks the otherwise linear control flow, requires manual management of listener lifecycles, and complicates error handling. Swift's `AsyncSequence` provides a modern, type-safe alternative that integrates seamlessly with structured concurrency, offering automatic resource management, simplified error handling, and a more intuitive, linear control flow. + +## 3. Motivation + +Adopting `AsyncSequence` will: + +* **Modernize the SDK:** Align with Swift's modern concurrency approach, making Firebase feel more native to Swift developers. +* **Simplify Development:** Eliminate the need for manual listener management and reduce boilerplate code, especially when integrating with SwiftUI. +* **Improve Code Quality:** Provide official, high-quality implementations for streaming APIs, reducing ecosystem fragmentation caused by unofficial solutions. +* **Enhance Readability:** Leverage structured error handling (`throws`) and a linear `for try await` syntax to make asynchronous code easier to read and maintain. +* **Enable Composition:** Allow developers to use a rich set of sequence operators (like `map`, `filter`, `prefix`) to transform and combine streams declaratively. + +## 4. Goals + +* To design and implement an idiomatic, `AsyncSequence`-based API surface for all relevant event-streaming Firebase APIs. +* To provide a clear and consistent naming convention that aligns with Apple's own Swift APIs. +* To ensure the new APIs automatically manage the lifecycle of underlying listeners, removing this burden from the developer. +* To improve the testability of asynchronous Firebase interactions. + +## 5. Non-Goals + +* To deprecate or remove the existing listener-based APIs in the immediate future. The new APIs will be additive. +* To introduce `AsyncSequence` wrappers for one-shot asynchronous calls (which are better served by `async/await` functions). This proposal is focused exclusively on event streams. +* To provide a custom `AsyncSequence` implementation. We will use Swift's standard `Async(Throwing)Stream` types. + +## 6. API Naming Convention + +The guiding principle is to establish a clear, concise, and idiomatic naming convention that aligns with modern Swift practices and mirrors Apple's own frameworks. + +### Recommended Approach: Name the sequence based on its conceptual model. + +1. **For sequences of discrete items, use a plural noun.** + * This applies when the stream represents a series of distinct objects, like data snapshots. + * **Guidance:** Use a computed property for parameter-less access and a method for cases that require parameters. + * **Examples:** `url.lines`, `db.collection("users").snapshots`. + +2. **For sequences observing a single entity, describe the event with a suffix.** + * This applies when the stream represents the changing value of a single property or entity over time. + * **Guidance:** Use the entity's name combined with a suffix like `Changes`, `Updates`, or `Events`. + * **Example:** `auth.authStateChanges`. + +This approach was chosen over verb-based (`.streamSnapshots()`) or suffix-based (`.snapshotStream`) alternatives because it aligns most closely with Apple's API design guidelines, leading to a more idiomatic and less verbose call site. + +## 7. Proposed API Design + +### 7.1. Cloud Firestore + +Provides an async alternative to `addSnapshotListener`. + +#### API Design + +```swift +// Collection snapshots +extension CollectionReference { + var snapshots: AsyncThrowingStream { get } + func snapshots(includeMetadataChanges: Bool = false) -> AsyncThrowingStream +} + +// Query snapshots +extension Query { + var snapshots: AsyncThrowingStream { get } + func snapshots(includeMetadataChanges: Bool = false) -> AsyncThrowingStream +} + +// Document snapshots +extension DocumentReference { + var snapshots: AsyncThrowingStream { get } + func snapshots(includeMetadataChanges: Bool = false) -> AsyncThrowingStream +} +``` + +#### Usage + +```swift +// Streaming updates on a collection +func observeUsers() async throws { + for try await snapshot in db.collection("users").snapshots { + // ... + } +} +``` + +### 7.2. Realtime Database + +Provides an async alternative to the `observe(_:with:)` method. + +#### API Design + +```swift +/// An enumeration of granular child-level events. +public enum DatabaseEvent { + case childAdded(DataSnapshot, previousSiblingKey: String?) + case childChanged(DataSnapshot, previousSiblingKey: String?) + case childRemoved(DataSnapshot) + case childMoved(DataSnapshot, previousSiblingKey: String?) +} + +extension DatabaseQuery { + /// An asynchronous stream of the entire contents at a location. + /// This stream emits a new `DataSnapshot` every time the data changes. + var value: AsyncThrowingStream { get } + + /// An asynchronous stream of child-level events at a location. + func events() -> AsyncThrowingStream +} +``` + +#### Usage + +```swift +// Streaming a single value +let scoreRef = Database.database().reference(withPath: "game/score") +for try await snapshot in scoreRef.value { + // ... +} + +// Streaming child events +let messagesRef = Database.database().reference(withPath: "chats/123/messages") +for try await event in messagesRef.events() { + switch event { + case .childAdded(let snapshot, _): + // ... + // ... + } +} +``` + +### 7.3. Authentication + +Provides an async alternative to `addStateDidChangeListener`. + +#### API Design + +```swift +extension Auth { + /// An asynchronous stream of authentication state changes. + var authStateChanges: AsyncStream { get } +} +``` + +#### Usage + +```swift +// Monitoring authentication state +for await user in Auth.auth().authStateChanges { + if let user = user { + // User is signed in + } else { + // User is signed out + } +} +``` + +### 7.4. Cloud Storage + +Provides an async alternative to `observe(.progress, ...)`. + +#### API Design + +```swift +extension StorageTask { + /// An asynchronous stream of progress updates for an ongoing task. + var progressUpdates: AsyncThrowingStream { get } +} +``` + +#### Usage + +```swift +// Monitoring an upload task +let uploadTask = ref.putData(data, metadata: nil) +do { + for try await progress in uploadTask.progress { + // Update progress bar + } + print("Upload complete") +} catch { + // Handle error +} +``` + +### 7.5. Remote Config + +Provides an async alternative to `addOnConfigUpdateListener`. + +#### API Design + +```swift +extension RemoteConfig { + /// An asynchronous stream of configuration updates. + var updates: AsyncThrowingStream { get } +} +``` + +#### Usage + +```swift +// Listening for real-time config updates +for try await update in RemoteConfig.remoteConfig().updates { + // Activate new config +} +``` + +### 7.6. Cloud Messaging (FCM) + +Provides an async alternative to the delegate-based approach for token updates and foreground messages. + +#### API Design + +```swift +extension Messaging { + /// An asynchronous stream of FCM registration token updates. + var tokenUpdates: AsyncStream { get } + + /// An asynchronous stream of remote messages received while the app is in the foreground. + var foregroundMessages: AsyncStream { get } +} +``` + +#### Usage + +```swift +// Monitoring FCM token updates +for await token in Messaging.messaging().tokenUpdates { + // Send token to server +} +``` + +## 8. Testing Plan + +The quality and reliability of this new API surface will be ensured through a multi-layered testing strategy, covering unit, integration, and cancellation scenarios. + +### 8.1. Unit Tests + +The primary goal of unit tests is to verify the correctness of the `AsyncStream` wrapping logic in isolation from the network and backend services. + +* **Mocking:** Each product's stream implementation will be tested against a mocked version of its underlying service (e.g., a mock `Firestore` client). +* **Behavior Verification:** + * Tests will confirm that initiating a stream correctly registers a listener with the underlying service. + * We will use the mock listeners to simulate events (e.g., new snapshots, auth state changes) and assert that the `AsyncStream` yields the corresponding values correctly. + * Error conditions will be simulated to ensure that the stream correctly throws errors. +* **Teardown Logic:** We will verify that the underlying listener is removed when the stream is either cancelled or finishes naturally. + +### 8.2. Integration Tests + +Integration tests will validate the end-to-end functionality of the async sequences against a live backend environment using the **Firebase Emulator Suite**. + +* **Environment:** A new integration test suite will be created that configures the Firebase SDK to connect to the local emulators (Firestore, Database, Auth, etc.). +* **Validation:** These tests will perform real operations (e.g., writing a document and then listening to its `snapshots` stream) to verify that real-time updates are correctly received and propagated through the `AsyncSequence` API. +* **Cross-Product Scenarios:** We will test scenarios that involve multiple Firebase products where applicable. + +### 8.3. Cancellation Behavior Tests + +A specific set of tests will be dedicated to ensuring that resource cleanup (i.e., listener removal) happens correctly and promptly when the consuming task is cancelled. + +* **Test Scenario:** + 1. A stream will be consumed within a Swift `Task`. + 2. The `Task` will be cancelled immediately after the stream is initiated. + 3. Using a mock or a spy object, we will assert that the `remove()` method on the underlying listener registration is called. +* **Importance:** This is critical for preventing resource leaks and ensuring the new API behaves predictably within the Swift structured concurrency model, especially in SwiftUI contexts where tasks are automatically managed. + +## 9. Implementation Plan + +The implementation will be phased, with each product's API being added in a separate Pull Request to facilitate focused reviews. + +* **Firestore:** [PR #14924: Support AsyncStream in realtime query](https://github.com/firebase/firebase-ios-sdk/pull/14924) +* **Authentication:** [Link to PR when available] +* **Realtime Database:** [Link to PR when available] +* ...and so on. + +## 10. Open Questions & Future Work + +* Should we provide convenience wrappers for common `AsyncSequence` operators? (e.g., a method to directly stream decoded objects instead of snapshots). For now, this is considered a **Non-Goal** but could be revisited. diff --git a/docs/FirestoreSPM.md b/docs/FirestoreSPM.md new file mode 100644 index 00000000000..e473a986983 --- /dev/null +++ b/docs/FirestoreSPM.md @@ -0,0 +1,196 @@ +# Firestore Swift Package Manager Target Hierarchy + +This document outlines the hierarchy of the Firestore-related targets in the +`Package.swift` manifest. The setup is designed to support three different +build options for Firestore: from a pre-compiled binary (the default), from +source (via the `FIREBASE_SOURCE_FIRESTORE` environment variable), or from a +local binary for CI purposes (via the `FIREBASECI_USE_LOCAL_FIRESTORE_ZIP` +environment variable). + +--- + +## 1. Binary-based build (Default) + +When the `FIREBASE_SOURCE_FIRESTORE` environment variable is **not** set, SPM +will use pre-compiled binaries for Firestore and its heavy dependencies. This +is the default and recommended approach for most users. + +### Dependency hierarchy + +The dependency tree for a binary-based build is as follows: + +``` +FirebaseFirestore (Library Product) +└── FirebaseFirestoreTarget (Wrapper Target) + └── FirebaseFirestore (Swift Target) + ├── FirebaseAppCheckInterop + ├── FirebaseCore + ├── FirebaseCoreExtension + ├── FirebaseSharedSwift + ├── leveldb + ├── nanopb + ├── abseil (binary) (from https://github.com/google/abseil-cpp-binary.git) + ├── gRPC-C++ (binary) (from https://github.com/google/grpc-binary.git, contains BoringSSL-GRPC target) + └── FirebaseFirestoreInternalWrapper (Wrapper Target) + └── FirebaseFirestoreInternal (Binary Target) +``` + +### Target breakdown + +* **`FirebaseFirestore`**: The Swift target containing the public API. In this + configuration, it depends on the binary versions of abseil and gRPC, as + well as the `FirebaseFirestoreInternalWrapper`. +* **`FirebaseFirestoreInternalWrapper`**: A thin wrapper target that exists to + expose the headers from the underlying binary target. +* **`FirebaseFirestoreInternal`**: This is a `binaryTarget` that downloads and + links the pre-compiled `FirebaseFirestoreInternal.xcframework`. This + framework contains the compiled C++ core of Firestore. + +--- + +## 2. Source-based build + +When the `FIREBASE_SOURCE_FIRESTORE` environment variable is set, Firestore and +its dependencies (like abseil and gRPC) are compiled from source. + +### How to build Firestore from source + +To build Firestore from source, set the `FIREBASE_SOURCE_FIRESTORE` environment +variable before building the project. + +#### Building with Xcode + +A direct method for building within Xcode is to pass the environment variable +when opening it from the command line. This approach scopes the variable to the +Xcode instance. To enable an env var within Xcode, first quit any running Xcode +instance, and then open the project from the command line: + +```console +open --env FIREBASE_SOURCE_FIRESTORE Package.swift +``` + +To unset the env var, quit the running Xcode instance. If you need to pass +multiple variables, repeat the `--env` argument for each: +```console +open --env FIREBASECI_USE_LATEST_GOOGLEAPPMEASUREMENT \ +--env FIREBASE_SOURCE_FIRESTORE Package.swift +``` + +#### Command-line builds + +For command-line builds using `xcodebuild` or `swift build`, the recommended +approach is to prefix the build command with the environment variable. This sets +the variable only for that specific command, avoiding unintended side effects. + +```bash +FIREBASE_SOURCE_FIRESTORE=1 xcodebuild -scheme FirebaseFirestore \ +-destination 'generic/platform=iOS' +``` + +Alternatively, if you plan to run multiple commands that require the variable +to be set, you can `export` it. This will apply the variable to all subsequent +commands in that terminal session. + +```bash +export FIREBASE_SOURCE_FIRESTORE=1 +xcodebuild -scheme FirebaseFirestore -destination 'generic/platform=iOS' +# Any other commands here will also have the variable set +``` + +Once the project is built with the variable set, SPM will clone and build +Firestore and its C++ dependencies (like abseil and gRPC) from source. + +### Dependency hierarchy + +The dependency tree for a source-based build looks like this: + +``` +FirebaseFirestore (Library Product) +└── FirebaseFirestoreTarget (Wrapper Target) + └── FirebaseFirestore (Swift Target) + ├── FirebaseCore + ├── FirebaseCoreExtension + ├── FirebaseSharedSwift + └── FirebaseFirestoreInternalWrapper (C++ Target) + ├── FirebaseAppCheckInterop + ├── FirebaseCore + ├── leveldb + ├── nanopb + ├── abseil (source) (from https://github.com/firebase/abseil-cpp-SwiftPM.git) + └── gRPC-cpp (source) (from https://github.com/grpc/grpc-ios.git) + └── BoringSSL (source) (from https://github.com/firebase/boringSSL-SwiftPM.git) +``` + +### Target breakdown + +* **`FirebaseFirestore`**: The main Swift target containing the public Swift + API for Firestore. It acts as a bridge to the underlying C++ + implementation. +* **`FirebaseFirestoreInternalWrapper`**: This target compiles the core C++ + source code of Firestore. It depends on other low-level libraries and C++ + dependencies, which are also built from source. + +--- + +## 3. Local binary build (CI only) + +A third, less common build option is available for CI environments. When the +`FIREBASECI_USE_LOCAL_FIRESTORE_ZIP` environment variable is set, the build +system will use a local `FirebaseFirestoreInternal.xcframework` instead of +downloading the pre-compiled binary. This option assumes the xcframework is +located at the root of the repository. + +This option is primarily used by internal scripts, such as +`scripts/check_firestore_symbols.sh`, to perform validation against a locally +built version of the Firestore binary. It is not intended for general consumer +use. + +--- + +## Core target explanations + +### `FirebaseFirestore` (Library product) + +The main entry point for integrating Firestore via SPM is the +`FirebaseFirestore` library product. + +```swift +.library( + name: "FirebaseFirestore", + targets: ["FirebaseFirestoreTarget"]) +``` + +This product points to a wrapper target, `FirebaseFirestoreTarget`, which then +depends on the appropriate Firestore targets based on the chosen build option. + +### `FirebaseFirestoreTarget` (Wrapper target) + +The `FirebaseFirestoreTarget` is a thin wrapper that exists to work around a +limitation in SPM where a single target cannot conditionally depend on +different sets of targets (source vs. binary). + +By having clients depend on the wrapper, the `Package.swift` can internally +manage the complexity of switching between source and binary builds. This +provides a stable entry point for all clients and avoids pushing conditional +logic into their own package manifests. + +--- + +## Test targets + +The testing infrastructure for Firestore in SPM is designed to be independent +of the build choice (source vs. binary). + +* **`FirebaseFirestoreTestingSupport`**: This is a library target, not a test + target. It provides public testing utilities that consumers can use to + write unit tests for their Firestore-dependent code. It has a dependency on + `FirebaseFirestoreTarget`, which means it will link against whichever + version of Firestore (source or binary) is being used in the build. + +* **`FirestoreTestingSupportTests`**: This is a test target that contains the + unit tests for the `FirebaseFirestoreTestingSupport` library itself. Its + purpose is to validate the testing utilities. + +Because both of these targets depend on the `FirebaseFirestoreTarget` wrapper, +they seamlessly adapt to either the source-based or binary-based build path +without any conditional logic. diff --git a/scripts/gha-encrypted/VertexAI/TestApp-Credentials.swift.gpg b/scripts/gha-encrypted/FirebaseAI/TestApp-Credentials.swift.gpg similarity index 100% rename from scripts/gha-encrypted/VertexAI/TestApp-Credentials.swift.gpg rename to scripts/gha-encrypted/FirebaseAI/TestApp-Credentials.swift.gpg diff --git a/scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg b/scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg new file mode 100644 index 00000000000..28619cee88a --- /dev/null +++ b/scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg @@ -0,0 +1,2 @@ +  V_;2l,n%7A{Ȯ?S(3nPy.Q&CјOqUkHһ~-9\]ru'Ddmڽm'=Au?skڿ4UC[X=>ɉ+ GHwuw`mI,i#~(Y]xG.3!G#9u.%t]3h}v\t{[8~YwY@+])c\ RɓcΕw$PNAfm8hqsXhzsE6k> a],,E9Ŀp _ۥ~ۀlv=S6)PQ`ƗJ#-!Z9m l.@K݉yCxʈXB4c 54@N[(:~/ ֋S6EHjTD쮎HnW҄oKs ;'?K F_L): +|QM0R@T \ No newline at end of file diff --git a/scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg b/scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg new file mode 100644 index 00000000000..02695881bb3 Binary files /dev/null and b/scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg differ diff --git a/scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info-Spark.plist.gpg b/scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info-Spark.plist.gpg deleted file mode 100644 index 71463a8043d..00000000000 Binary files a/scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info-Spark.plist.gpg and /dev/null differ diff --git a/scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info.plist.gpg b/scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info.plist.gpg deleted file mode 100644 index 47f836feff3..00000000000 Binary files a/scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info.plist.gpg and /dev/null differ diff --git a/scripts/repo.sh b/scripts/repo.sh new file mode 100755 index 00000000000..607ad987a1a --- /dev/null +++ b/scripts/repo.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# 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. + +# USAGE: ./repo.sh [args...] +# +# EXAMPLE: ./repo.sh tests decrypt --json ./scripts/secrets/AI.json +# +# Wraps around the local "repo" swift package, and facilitates calls to it. +# The main purpose of this is to make calling "repo" easier, as you typically +# need to call "swift run" with the package path. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ $# -eq 0 ]]; then + cat 1>&2 < [args...] +EOF + exit 1 +fi + +swift run --package-path "${ROOT}/repo" "$@" diff --git a/scripts/repo/Package.swift b/scripts/repo/Package.swift new file mode 100755 index 00000000000..1edeeb69aad --- /dev/null +++ b/scripts/repo/Package.swift @@ -0,0 +1,50 @@ +// swift-tools-version:6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +/* + * 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 PackageDescription + +/// Package containing CLI executables for our larger scripts that are a bit harder to follow in +/// bash form, or that need more advanced flag/optional requirements. +let package = Package( + name: "RepoScripts", + platforms: [.macOS(.v15)], + products: [ + .executable(name: "tests", targets: ["Tests"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", exact: "1.6.2"), + .package(url: "https://github.com/apple/swift-log", exact: "1.6.2"), + ], + targets: [ + .executableTarget( + name: "Tests", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .byName(name: "Util"), + ] + ), + .target( + name: "Util", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + ] + ), + ] +) diff --git a/scripts/repo/README.md b/scripts/repo/README.md new file mode 100644 index 00000000000..318c16c547d --- /dev/null +++ b/scripts/repo/README.md @@ -0,0 +1,13 @@ +# Firebase Apple repo commands + +This project includes commands that are too long and complicated to properly +maintain in a bash script, or that have unique option/flag constraints that +are better represented in a swift project. + +## Tests + +Commands for interacting with integration tests in the repo. + +```sh +./scripts/repo.sh tests --help +``` diff --git a/scripts/repo/Sources/Tests/Decrypt.swift b/scripts/repo/Sources/Tests/Decrypt.swift new file mode 100755 index 00000000000..b1836787be9 --- /dev/null +++ b/scripts/repo/Sources/Tests/Decrypt.swift @@ -0,0 +1,183 @@ +/* + * 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 ArgumentParser +import Foundation +import Logging +import Util + +extension Tests { + /// Command for decrypting the secret files needed for a test run. + struct Decrypt: ParsableCommand { + nonisolated(unsafe) static var configuration = CommandConfiguration( + abstract: "Decrypt the secret files for a test run.", + usage: """ + tests decrypt [--json] [--overwrite] [] + tests decrypt [--password ] [--overwrite] [ ...] + + tests decrypt --json secret_files.json + tests decrypt --json --overwrite secret_files.json + tests decrypt --password "super_secret" \\ + scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg:FirebaseAI/Tests/TestApp/Resources/GoogleService-Info.plist \\ + scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg:FirebaseAI/Tests/TestApp/Resources/GoogleService-Info-Spark.plist + """, + discussion: """ + The happy path usage is saving the secret passphrase in the environment variable \ + 'secrets_passphrase', and passing a json file to the command. Although, you can also \ + pass everything inline via options. + + When using a json file, it's expected that the json file is an array of json elements \ + in the format of: + { encrypted: , destination: } + """, + ) + + @Argument( + help: """ + An array of secret files to decrypt. \ + The files should be in the format "encrypted:destination", where "encrypted" is a path to \ + the encrypted file and "destination" is a path to where the decrypted file should be saved. + """ + ) + var secretFiles: [String] = [] + + @Option( + help: """ + The secret to use when decrypting the files. \ + Defaults to the environment variable 'secrets_passphrase'. + """ + ) + var password: String = "" + + @Flag(help: "Overwrite existing decrypted secret files.") + var overwrite: Bool = false + + @Flag( + help: """ + Use a json file of secret file mappings instead. \ + When this flag is enabled, should be a single json file. + """ + ) + var json: Bool = false + + /// The parsed version of ``secretFiles``. + /// + /// Only populated after `validate()` runs. + var files: [SecretFile] = [] + + static let log = Logger(label: "Tests::Decrypt") + private var log: Logger { Decrypt.log } + + mutating func validate() throws { + try validatePassword() + + if json { + try validateJSON() + } else { + try validateFileString() + } + + if !overwrite { + log.info("Overwrite is disabled, so we're skipping generation for existing files.") + files = files.filter { file in + let keep = !FileManager.default.fileExists(atPath: file.destination) + if !keep { + log.debug( + "Skipping generation for existing file", + metadata: ["destination": "\(file.destination)"] + ) + } + return keep + } + } + + for file in files { + guard FileManager.default.fileExists(atPath: file.encrypted) else { + throw ValidationError("Encrypted secret file does not exist: \(file.encrypted)") + } + } + } + + private mutating func validatePassword() throws { + if password.isEmpty { + // when a password isn't provided, try to load one from the environment variable + guard + let secrets_passphrase = ProcessInfo.processInfo.environment["secrets_passphrase"] + else { + throw ValidationError( + "Either provide a passphrase via the password option or set the environvment variable 'secrets_passphrase' to the passphrase." + ) + } + password = secrets_passphrase + } + } + + private mutating func validateJSON() throws { + guard let jsonPath = secretFiles.first else { + throw ValidationError("Missing path to json file for secret files") + } + + let fileURL = URL( + filePath: jsonPath, directoryHint: .notDirectory, + relativeTo: URL.currentDirectory() + ) + + files = try SecretFile.parseArrayFrom(file: fileURL) + guard !files.isEmpty else { + throw ValidationError("Missing secret files in json file: \(jsonPath)") + } + } + + private mutating func validateFileString() throws { + guard !secretFiles.isEmpty else { + throw ValidationError("Missing paths to secret files") + } + for string in secretFiles { + try files.append(SecretFile(string: string)) + } + } + + mutating func run() throws { + log.info("Decrypting files...") + + for file in files { + let gpg = Process("gpg", inheritEnvironment: true) + let result = try gpg.runWithSignals([ + "--quiet", + "--batch", + "--yes", + "--decrypt", + "--passphrase=\(password)", + "--output", + file.destination, + file.encrypted, + ]) + + guard result == 0 else { + log.error("Failed to decrypt file", metadata: ["file": "\(file.encrypted)"]) + throw ExitCode(result) + } + + log.debug( + "File encrypted", + metadata: ["file": "\(file.encrypted)", "destination": "\(file.destination)"] + ) + } + + log.info("Files decrypted") + } + } +} diff --git a/scripts/repo/Sources/Tests/SecretFile.swift b/scripts/repo/Sources/Tests/SecretFile.swift new file mode 100755 index 00000000000..67d5e953981 --- /dev/null +++ b/scripts/repo/Sources/Tests/SecretFile.swift @@ -0,0 +1,73 @@ +/* + * 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 ArgumentParser +import Foundation + +/// A representation of a secret file, which should be decrypted for an integration test. +struct SecretFile: Codable { + /// A relative path to the encrypted file. + let encrypted: String + + /// A relative path to where the decrypted file should be output to. + let destination: String +} + +extension SecretFile { + /// Parses a `SecretFile` from a string. + /// + /// The string should be in the format of "encrypted:destination". + /// If it's not, then a `ValidationError`will be thrown. + /// + /// - Parameters: + /// - string: A string in the format of "encrypted:destination". + init(string: String) throws { + let splits = string.split(separator: ":") + guard splits.count == 2 else { + throw ValidationError( + "Invalid secret file format. Format should be \"encrypted:destination\". Cause: \(string)" + ) + } + encrypted = String(splits[0]) + destination = String(splits[1]) + } + + /// Parses an array of `SecretFile` from a JSON file. + /// + /// It's expected that the secrets are encoded in the JSON file in the format of: + /// ```json + /// [ + /// { + /// "encrypted": "path-to-encrypted-file", + /// "destination": "where-to-output-decrypted-file" + /// } + /// ] + /// ``` + /// + /// - Parameters: + /// - file: The URL of a JSON file which contains an array of `SecretFile`, + /// encoded as JSON. + static func parseArrayFrom(file: URL) throws -> [SecretFile] { + do { + let data = try Data(contentsOf: file) + return try JSONDecoder().decode([SecretFile].self, from: data) + } catch { + throw ValidationError( + "Failed to load secret files from json file. Cause: \(error.localizedDescription)" + ) + } + } +} diff --git a/scripts/repo/Sources/Tests/main.swift b/scripts/repo/Sources/Tests/main.swift new file mode 100755 index 00000000000..0abb218e46a --- /dev/null +++ b/scripts/repo/Sources/Tests/main.swift @@ -0,0 +1,54 @@ +/* + * 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 ArgumentParser +import Foundation +import Logging + +struct Tests: ParsableCommand { + nonisolated(unsafe) static var configuration = CommandConfiguration( + abstract: "Commands for running and interacting with integration tests.", + discussion: """ + A note on logging: by default, only log levels "info" and above are logged. For further \ + debugging, you can set the "LOG_LEVEL" environment variable to a different minimum level \ + (eg; "debug"). + """, + subcommands: [Decrypt.self] + // defaultSubcommand: Run.self + ) +} + +LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + if let level = ProcessInfo.processInfo.environment["LOG_LEVEL"] { + if let parsedLevel = Logger.Level(rawValue: String(level)) { + handler.logLevel = parsedLevel + return handler + } else { + print( + """ + [WARNING]: Unrecognized log level "\(level)"; defaulting to "info". + Valid values: \(Logger.Level.allCases.map(\.rawValue)) + """ + ) + } + } + + handler.logLevel = .info + return handler +} + +Tests.main() diff --git a/scripts/repo/Sources/Util/Process.swift b/scripts/repo/Sources/Util/Process.swift new file mode 100755 index 00000000000..0b5ca5bec9d --- /dev/null +++ b/scripts/repo/Sources/Util/Process.swift @@ -0,0 +1,120 @@ +/* + * 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 Dispatch +import Foundation + +public extension Process { + /// Creates a new `Process` instance without running it. + /// + /// - Parameters: + /// - exe: The executable to run. + /// - args: An array of arguments to pass to the executable. + /// - env: A map of environment variables to set for the process. + /// - inheritEnvironment: When enabled, the parent process' environvment will also be applied + /// to this process. Effectively, this means that any environvment variables declared within the + /// parent process will propagate down to this new process. + convenience init(_ exe: String, + _ args: [String] = [], + env: [String: String] = [:], + inheritEnvironment: Bool = false) { + self.init() + executableURL = URL(filePath: "/usr/bin/env") + arguments = [exe] + args + environment = env + if inheritEnvironment { + mergeEnvironment(ProcessInfo.processInfo.environment) + } + } + + /// Merges the provided environment variables with this process' existing environment variables. + /// + /// If an environment variable is already set, then it will **NOT** be overwritten. Only + /// environment variables not currently set on the process will be applied. + /// + /// - Parameters: + /// - env: The environment variables to merge with this process. + func mergeEnvironment(_ env: [String: String]) { + guard environment != nil else { + // if this process doesn't have an environment, we can just set it instead of merging + environment = env + return + } + + environment = environment?.merging(env) { current, _ in current } + } + + /// Run the process with signals from the parent process. + /// + /// The signals `SIGINT` and `SIGTERM` will both be propagated + /// down to the process from the parent process. + /// + /// This function will not return until the process is done running. + /// + /// - Parameters: + /// - args: Optionally provide an array of arguments to run the process with. + /// + /// - Returns: The exit code that the process completed with. + @discardableResult + func runWithSignals(_ args: [String]? = nil) throws -> Int32 { + if let args { + arguments = (arguments ?? []) + args + } + + let sigint = bindSignal(signal: SIGINT) { + if self.isRunning { + self.interrupt() + } + } + + let sigterm = bindSignal(signal: SIGTERM) { + if self.isRunning { + self.terminate() + } + } + + sigint.resume() + sigterm.resume() + + try run() + waitUntilExit() + + return terminationStatus + } +} + +/// Binds a callback to a signal from the parent process. +/// +/// ```swift +/// bindSignal(SIGINT) { +/// print("SIGINT was triggered") +/// } +/// ``` +/// +/// - Parameters: +/// - signal: The signal to listen for. +/// - callback: The function to invoke when the signal is received. +func bindSignal(signal value: Int32, + callback: @escaping DispatchSourceProtocol + .DispatchSourceHandler) -> any DispatchSourceSignal { + // allow the process to survive long enough to trigger the callback + signal(value, SIG_IGN) + + let dispatch = DispatchSource.makeSignalSource(signal: value, queue: .main) + dispatch.setEventHandler(handler: callback) + + return dispatch +} diff --git a/scripts/secrets/AI.json b/scripts/secrets/AI.json new file mode 100755 index 00000000000..1f675c8fc3e --- /dev/null +++ b/scripts/secrets/AI.json @@ -0,0 +1,14 @@ +[ + { + "encrypted": "scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg", + "destination": "FirebaseAI/Tests/TestApp/Resources/GoogleService-Info.plist" + }, + { + "encrypted": "scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg", + "destination": "FirebaseAI/Tests/TestApp/Resources/GoogleService-Info-Spark.plist" + }, + { + "encrypted": "scripts/gha-encrypted/FirebaseAI/TestApp-Credentials.swift.gpg", + "destination": "FirebaseAI/Tests/TestApp/Tests/Integration/Credentials.swift" + } +] diff --git a/scripts/spm_test_schemes/FirebaseAILogicUnit.xcscheme b/scripts/spm_test_schemes/FirebaseAILogicUnit.xcscheme new file mode 100644 index 00000000000..22c999d990a --- /dev/null +++ b/scripts/spm_test_schemes/FirebaseAILogicUnit.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/zip_quickstart_test.sh b/scripts/zip_quickstart_test.sh index 1476b2b1985..0a3cc30ec9a 100755 --- a/scripts/zip_quickstart_test.sh +++ b/scripts/zip_quickstart_test.sh @@ -59,7 +59,7 @@ fi xcodebuild \ -project ${PROJECT_NAME} \ -scheme ${SCHEME_NAME} \ --destination "platform=iOS Simulator,name=$device_name" "SWIFT_VERSION=5.3" "OTHER_LDFLAGS=\$(OTHER_LDFLAGS) -ObjC" "FRAMEWORK_SEARCH_PATHS= \$(PROJECT_DIR)/Firebase/" HEADER_SEARCH_PATHS='$(PROJECT_DIR)/Firebase' \ +-destination "platform=iOS Simulator,name=$device_name" "SWIFT_VERSION=5.3" "OTHER_LDFLAGS=\$(OTHER_LDFLAGS) -ObjC" "FRAMEWORK_SEARCH_PATHS= \$(PROJECT_DIR)/Firebase/" HEADER_SEARCH_PATHS='$(inherited) $(PROJECT_DIR)/Firebase' \ build \ ) || EXIT_STATUS=$?