diff --git a/.gemini/config.yaml b/.gemini/config.yaml new file mode 100644 index 00000000000..cf6e5d0be1f --- /dev/null +++ b/.gemini/config.yaml @@ -0,0 +1,9 @@ +have_fun: false +code_review: + disable: false + comment_severity_threshold: LOW + max_review_comments: -1 + pull_request_opened: + help: true + summary: false + code_review: true diff --git a/.github/actions/notices_generation/Gemfile b/.github/actions/notices_generation/Gemfile index 24197d9a369..cbaf67c9bc1 100644 --- a/.github/actions/notices_generation/Gemfile +++ b/.github/actions/notices_generation/Gemfile @@ -1,9 +1,6 @@ source "https://rubygems.org" # cocoapods isn't needed for generating the Gemfile.lock, but is needed for the CI job -gem "cocoapods" +gem "cocoapods", ">= 1.16.2" gem "octokit", "~> 4.19" -gem "xcodeproj", "~> 1.21" gem "plist" -# activesupport is locked because of https://github.com/CocoaPods/CocoaPods/issues/12081 -gem 'activesupport', '7.0.8' diff --git a/.github/actions/notices_generation/Gemfile.lock b/.github/actions/notices_generation/Gemfile.lock index 8ea9bd7b820..78d9a193cfb 100644 --- a/.github/actions/notices_generation/Gemfile.lock +++ b/.github/actions/notices_generation/Gemfile.lock @@ -1,12 +1,22 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.5) + CFPropertyList (3.0.7) + base64 + nkf rexml - activesupport (7.0.8) + activesupport (7.1.5.1) + base64 + benchmark (>= 0.3) + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) + mutex_m + securerandom (>= 0.3) tzinfo (~> 2.0) addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) @@ -14,13 +24,16 @@ GEM httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) atomos (0.1.3) + base64 (0.2.0) + benchmark (0.4.0) + bigdecimal (3.1.9) claide (1.1.0) - cocoapods (1.12.1) + cocoapods (1.16.2) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.12.1) + cocoapods-core (= 1.16.2) cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 1.6.0, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) cocoapods-search (>= 1.0.0, < 2.0) cocoapods-trunk (>= 1.6.0, < 2.0) @@ -32,8 +45,8 @@ GEM molinillo (~> 0.8.0) nap (~> 1.0) ruby-macho (>= 2.3.0, < 3.0) - xcodeproj (>= 1.21.0, < 2.0) - cocoapods-core (1.12.1) + xcodeproj (>= 1.27.0, < 2.0) + cocoapods-core (1.16.2) activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) @@ -44,7 +57,7 @@ GEM public_suffix (~> 4.0) typhoeus (~> 1.0) cocoapods-deintegrate (1.0.5) - cocoapods-downloader (1.6.3) + cocoapods-downloader (2.1) cocoapods-plugins (1.0.0) nap cocoapods-search (1.0.1) @@ -53,7 +66,9 @@ GEM netrc (~> 0.11) cocoapods-try (1.2.0) colored2 (3.1.2) - concurrent-ruby (1.2.2) + concurrent-ruby (1.3.5) + connection_pool (2.5.0) + drb (2.2.1) escape (0.0.4) ethon (0.16.0) ffi (>= 1.15.0) @@ -64,48 +79,52 @@ GEM fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - httpclient (2.8.3) - i18n (1.14.1) + httpclient (2.9.0) + mutex_m + i18n (1.14.7) concurrent-ruby (~> 1.0) - json (2.7.2) - minitest (5.20.0) + json (2.10.2) + logger (1.6.6) + minitest (5.25.4) molinillo (0.8.0) multipart-post (2.1.1) - nanaimo (0.3.0) + mutex_m (0.3.0) + nanaimo (0.4.0) nap (1.1.0) netrc (0.11.0) + nkf (0.2.0) octokit (4.19.0) faraday (>= 0.9) sawyer (~> 0.8.0, >= 0.5.3) plist (3.6.0) public_suffix (4.0.6) - rexml (3.3.9) + rexml (3.4.1) ruby-macho (2.5.1) ruby2_keywords (0.0.2) sawyer (0.8.2) addressable (>= 2.3.5) faraday (> 0.8, < 2.0) + securerandom (0.3.2) typhoeus (1.4.1) ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - xcodeproj (1.25.0) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (>= 3.3.2, < 4.0) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) PLATFORMS + ruby x86_64-linux DEPENDENCIES - activesupport (= 7.0.8) - cocoapods + cocoapods (>= 1.16.2) octokit (~> 4.19) plist - xcodeproj (~> 1.21) BUNDLED WITH 2.3.11 diff --git a/.github/actions/testing_report_generation/app.rb b/.github/actions/testing_report_generation/app.rb index 8919fbe95c7..3cdd180c104 100644 --- a/.github/actions/testing_report_generation/app.rb +++ b/.github/actions/testing_report_generation/app.rb @@ -107,7 +107,10 @@ def get_workflows(client, repo_name) end workflow_text = "[%s](%s)" % [wf.name, wf.html_url] - runs = client.workflow_runs(REPO_NAME_WITH_OWNER, File.basename(wf.path), :event => "schedule").workflow_runs + # If the below request 404s, it may be because the repo doesn't own that workflow (e.g. + # `dependabot-updates`). In these cases, add the workflow to the `EXCLUDED_WORKFLOWS` env + # var that's set in `.github/workflows/generate_issues.yml`. + runs = client.workflow_runs(REPO_NAME_WITH_OWNER, workflow_file, :event => "schedule").workflow_runs runs = runs.sort_by { |run| -run.created_at.to_i } latest_run = runs[0] if latest_run.nil? diff --git a/.github/workflows/abtesting.yml b/.github/workflows/abtesting.yml index e2276e99178..ca4c532ca88 100644 --- a/.github/workflows/abtesting.yml +++ b/.github/workflows/abtesting.yml @@ -120,6 +120,9 @@ jobs: key: ${{needs.spm-package-resolved.outputs.cache_key}} - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer + - name: Install visionOS, if needed. + if: matrix.target == 'visionOS' + run: xcodebuild -downloadPlatform visionOS - name: Initialize xcodebuild run: scripts/setup_spm_tests.sh - uses: nick-fields/retry@v3 diff --git a/.github/workflows/auth.yml b/.github/workflows/auth.yml index 2d1e83f3288..260a35be252 100644 --- a/.github/workflows/auth.yml +++ b/.github/workflows/auth.yml @@ -141,6 +141,9 @@ jobs: key: ${{needs.spm-package-resolved.outputs.cache_key}} - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer + - name: Install visionOS, if needed. + if: matrix.target == 'visionOS spm' + run: xcodebuild -downloadPlatform visionOS - name: Initialize xcodebuild run: scripts/setup_spm_tests.sh - uses: nick-fields/retry@v3 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b2a6a56a984..6ddea6fef00 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -15,7 +15,7 @@ jobs: check: # Don't run on private repo. if: github.repository == 'Firebase/firebase-ios-sdk' - runs-on: macos-14 + runs-on: ubuntu-latest env: MINT_PATH: ${{ github.workspace }}/mint steps: @@ -33,7 +33,13 @@ jobs: restore-keys: ${{ runner.os }}-mint- - name: Setup check - run: scripts/setup_check.sh + run: | + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" + scripts/setup_check.sh - name: Check - run: scripts/check.sh --test-only + run: | + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" + # Add Homebrew clang-format to first in PATH + export PATH="/home/linuxbrew/.linuxbrew/opt/clang-format/bin:$PATH" + scripts/check.sh --test-only diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 72206f4ff65..3a345918410 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -101,6 +101,9 @@ jobs: key: ${{needs.spm-package-resolved.outputs.cache_key}} - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer + - name: Install visionOS, if needed. + if: matrix.target == 'visionOS' + run: xcodebuild -downloadPlatform visionOS - name: Initialize xcodebuild run: scripts/setup_spm_tests.sh - name: Unit Tests diff --git a/.github/workflows/core_internal.yml b/.github/workflows/core_internal.yml index 37b62b0437e..4201e8633b5 100644 --- a/.github/workflows/core_internal.yml +++ b/.github/workflows/core_internal.yml @@ -21,8 +21,13 @@ jobs: build-env: - os: macos-14 xcode: Xcode_15.2 + swift_version: 5.9 - os: macos-15 xcode: Xcode_16.2 + swift_version: 5.9 + - os: macos-15 + xcode: Xcode_16.2 + swift_version: 6.0 runs-on: ${{ matrix.build-env.os }} steps: - uses: actions/checkout@v4 @@ -31,6 +36,8 @@ jobs: run: scripts/setup_bundler.sh - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.build-env.xcode }}.app/Contents/Developer + - name: Set Swift swift_version + run: sed -i "" "s/s.swift_version[[:space:]]*=[[:space:]]*'5.9'/s.swift_version = '${{ matrix.build-env.swift_version }}'/" FirebaseCoreInternal.podspec - name: Build and test run: scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseCoreInternal.podspec --platforms=${{ matrix.target }} @@ -99,6 +106,9 @@ jobs: run: scripts/setup_spm_tests.sh - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer + - name: Install visionOS, if needed. + if: matrix.target == 'visionOS' + run: xcodebuild -downloadPlatform visionOS - name: FirebaseCoreInternalTests run: scripts/third_party/travis/retry.sh ./scripts/build.sh FirebaseCoreInternalTests ${{ matrix.target }} spm diff --git a/.github/workflows/crashlytics.yml b/.github/workflows/crashlytics.yml index 47867fb5fd8..fe5561bf8dc 100644 --- a/.github/workflows/crashlytics.yml +++ b/.github/workflows/crashlytics.yml @@ -116,6 +116,9 @@ jobs: key: ${{needs.spm-package-resolved.outputs.cache_key}} - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer + - name: Install visionOS, if needed. + if: matrix.target == 'visionOS' + run: xcodebuild -downloadPlatform visionOS - name: Initialize xcodebuild run: scripts/setup_spm_tests.sh - uses: nick-fields/retry@v3 diff --git a/.github/workflows/firebase_app_check.yml b/.github/workflows/firebase_app_check.yml index 964c6229796..304a7dd358f 100644 --- a/.github/workflows/firebase_app_check.yml +++ b/.github/workflows/firebase_app_check.yml @@ -157,6 +157,9 @@ jobs: key: ${{needs.spm-package-resolved.outputs.cache_key}} - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer + - name: Install visionOS, if needed. + if: matrix.target == 'visionOS spm' + run: xcodebuild -downloadPlatform visionOS - name: Initialize xcodebuild run: scripts/setup_spm_tests.sh - name: Unit Tests diff --git a/.github/workflows/functions.yml b/.github/workflows/functions.yml index e89bccc8ff5..4da67e2c200 100644 --- a/.github/workflows/functions.yml +++ b/.github/workflows/functions.yml @@ -31,8 +31,6 @@ jobs: matrix: target: [ios, tvos, macos, watchos] build-env: - - os: macos-14 - xcode: Xcode_15.2 - os: macos-15 xcode: Xcode_16.2 runs-on: ${{ matrix.build-env.os }} @@ -43,14 +41,12 @@ jobs: run: sudo xcode-select -s /Applications/${{ matrix.build-env.xcode }}.app/Contents/Developer - name: Setup Bundler run: scripts/setup_bundler.sh - # The integration tests are flaky on Xcode 15 so only run the unit tests. The integration tests still run with SPM. - # - name: Integration Test Server - # run: FirebaseFunctions/Backend/start.sh synchronous + - name: Integration Test Server + run: FirebaseFunctions/Backend/start.sh synchronous - name: Build and test run: | scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseFunctions.podspec \ - --test-specs=unit --platforms=${{ matrix.target }} - + --platforms=${{ matrix.target }} spm-package-resolved: runs-on: macos-14 @@ -145,6 +141,9 @@ jobs: key: ${{needs.spm-package-resolved.outputs.cache_key}} - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer + - name: Install visionOS, if needed. + if: matrix.target == 'visionOS' + run: xcodebuild -downloadPlatform visionOS - name: Initialize xcodebuild run: scripts/setup_spm_tests.sh - name: Unit Tests diff --git a/.github/workflows/generate_issues.yml b/.github/workflows/generate_issues.yml index f28a423b1db..9acf59f1caf 100644 --- a/.github/workflows/generate_issues.yml +++ b/.github/workflows/generate_issues.yml @@ -8,6 +8,10 @@ on: schedule: # Run every day at 4am (PST) - cron uses UTC times - cron: '0 12 * * *' + +permissions: + issues: write + jobs: generate_an_issue: # Don't run on private repo. @@ -21,7 +25,7 @@ jobs: with: access-token: '${{ secrets.GITHUB_TOKEN }}' # This is to exclude workflows that will be searched in the nightly report. - exclude-workflow-files: 'performance-integration-tests.yml, sessions-integration-tests.yml, codeql' + exclude-workflow-files: 'performance-integration-tests.yml, sessions-integration-tests.yml, codeql, dependabot-updates' test_generate_an_issue: # Don't run on private repo. @@ -35,6 +39,6 @@ jobs: with: access-token: '${{ secrets.GITHUB_TOKEN }}' # This is to exclude workflows that will be searched in the nightly report. - exclude-workflow-files: 'performance-integration-tests.yml, sessions-integration-tests.yml, codeql' + exclude-workflow-files: 'performance-integration-tests.yml, sessions-integration-tests.yml, codeql, dependabot-updates' issue-labels: 'nightly-testing-report-generation-test' issue-title: 'Nightly Testing Report For Presubmit Testing' diff --git a/.github/workflows/mlmodeldownloader.yml b/.github/workflows/mlmodeldownloader.yml index f36056c50b8..f163a3bf62d 100644 --- a/.github/workflows/mlmodeldownloader.yml +++ b/.github/workflows/mlmodeldownloader.yml @@ -131,6 +131,9 @@ jobs: key: ${{needs.spm-package-resolved.outputs.cache_key}} - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer + - name: Install visionOS, if needed. + if: matrix.target == 'visionOS' + run: xcodebuild -downloadPlatform visionOS - name: Initialize xcodebuild run: scripts/setup_spm_tests.sh - name: Unit Tests diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 124a53f907d..4513b92ed9e 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -112,7 +112,7 @@ jobs: needs: [buildup_SpecsTesting_repo_FirebaseCore, specs_checking] # 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 == 'workflow_dispatch' - runs-on: macos-14 + runs-on: macos-15 strategy: fail-fast: false matrix: ${{fromJson(needs.specs_checking.outputs.matrix)}} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94c878df047..8ae76876816 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,7 @@ jobs: needs: specs_checking # Don't run on private repo unless it is a PR. if: github.repository == 'Firebase/firebase-ios-sdk' - runs-on: macos-14 + runs-on: macos-15 env: bot_token_secret: ${{ secrets.GHASecretsGPGPassphrase1 }} local_repo: specsreleasing @@ -111,7 +111,7 @@ jobs: needs: [buildup_SpecsReleasing_repo_FirebaseCore, specs_checking] # Don't run on private repo unless it is a PR. if: github.repository == 'Firebase/firebase-ios-sdk' || github.event_name == 'workflow_dispatch' - runs-on: macos-14 + runs-on: macos-15 strategy: fail-fast: false matrix: ${{fromJson(needs.specs_checking.outputs.matrix)}} diff --git a/.github/workflows/remoteconfig.yml b/.github/workflows/remoteconfig.yml index 53457d35e70..b8a02e62b1f 100644 --- a/.github/workflows/remoteconfig.yml +++ b/.github/workflows/remoteconfig.yml @@ -155,6 +155,9 @@ jobs: key: ${{needs.spm-package-resolved.outputs.cache_key}} - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer + - name: Install visionOS, if needed. + if: matrix.target == 'visionOS' + run: xcodebuild -downloadPlatform visionOS - name: Initialize xcodebuild run: scripts/setup_spm_tests.sh - name: Unit Tests diff --git a/.github/workflows/sessions.yml b/.github/workflows/sessions.yml index 2fa03e3af31..e30510d6fdd 100644 --- a/.github/workflows/sessions.yml +++ b/.github/workflows/sessions.yml @@ -111,6 +111,9 @@ jobs: key: ${{needs.spm-package-resolved.outputs.cache_key}} - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer + - name: Install visionOS, if needed. + if: matrix.target == 'visionOS' + run: xcodebuild -downloadPlatform visionOS - name: Initialize xcodebuild run: scripts/setup_spm_tests.sh - uses: nick-fields/retry@v3 diff --git a/.github/workflows/shared-swift.yml b/.github/workflows/shared-swift.yml index fd775bbd065..21120da609d 100644 --- a/.github/workflows/shared-swift.yml +++ b/.github/workflows/shared-swift.yml @@ -27,8 +27,13 @@ jobs: build-env: - os: macos-14 xcode: Xcode_15.2 + swift_version: 5.9 - os: macos-15 xcode: Xcode_16.2 + swift_version: 5.9 + - os: macos-15 + xcode: Xcode_16.2 + swift_version: 6.0 runs-on: ${{ matrix.build-env.os }} steps: - uses: actions/checkout@v4 @@ -37,6 +42,8 @@ jobs: run: scripts/setup_bundler.sh - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.build-env.xcode }}.app/Contents/Developer + - name: Set Swift swift_version + run: sed -i "" "s/s.swift_version[[:space:]]*=[[:space:]]*'5.9'/s.swift_version = '${{ matrix.build-env.swift_version }}'/" FirebaseSharedSwift.podspec - name: Build and test run: scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseSharedSwift.podspec --platforms=${{ matrix.target }} diff --git a/.github/workflows/storage.yml b/.github/workflows/storage.yml index 6c2756f23ca..dff243d5204 100644 --- a/.github/workflows/storage.yml +++ b/.github/workflows/storage.yml @@ -124,6 +124,9 @@ jobs: run: ls -l /Applications/Xcode* - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer + - name: Install visionOS, if needed. + if: matrix.target == 'visionOS spm' + run: xcodebuild -downloadPlatform visionOS - name: Initialize xcodebuild run: scripts/setup_spm_tests.sh - name: Unit Tests diff --git a/.github/workflows/vertexai.yml b/.github/workflows/vertexai.yml index bc2bb18f361..46965c0b5e8 100644 --- a/.github/workflows/vertexai.yml +++ b/.github/workflows/vertexai.yml @@ -81,6 +81,9 @@ jobs: run: scripts/update_vertexai_responses.sh - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer + - name: Install visionOS, if needed. + if: matrix.target == 'visionOS' + run: xcodebuild -downloadPlatform visionOS - name: Initialize xcodebuild run: scripts/setup_spm_tests.sh - uses: nick-fields/retry@v3 @@ -115,6 +118,9 @@ jobs: - name: Install Secret GoogleService-Info.plist run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info.plist.gpg \ FirebaseVertexAI/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 \ + FirebaseVertexAI/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 \ FirebaseVertexAI/Tests/TestApp/Tests/Integration/Credentials.swift "$secrets_passphrase" @@ -128,11 +134,19 @@ jobs: if: (github.repository == 'Firebase/firebase-ios-sdk' && github.event_name == 'schedule') || github.event_name == 'pull_request' strategy: matrix: - target: [ios] - os: [macos-14] include: - os: macos-14 xcode: Xcode_15.2 + swift_version: 5.9 + warnings: --allow-warnings + - os: macos-15 + xcode: Xcode_16.2 + swift_version: 5.9 + warnings: + - os: macos-15 + xcode: Xcode_16.2 + swift_version: 6.0 + warnings: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -143,37 +157,7 @@ jobs: run: scripts/setup_bundler.sh - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer + - name: Set Swift swift_version + run: sed -i "" "s#s.swift_version = '5.9'#s.swift_version = '${{ matrix.swift_version}}'#" FirebaseVertexAI.podspec - name: Build and test - run: scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseVertexAI.podspec --platforms=${{ matrix.target }} - - sample: - strategy: - matrix: - include: - - os: macos-13 - xcode: Xcode_15.2 - - os: macos-14 - xcode: Xcode_15.4 - - os: macos-15 - xcode: Xcode_16.2 - runs-on: ${{ matrix.os }} - needs: spm-package-resolved - env: - FIREBASECI_USE_LATEST_GOOGLEAPPMEASUREMENT: 1 - steps: - - uses: actions/checkout@v4 - - uses: actions/cache/restore@v4 - with: - path: .build - key: ${{needs.spm-package-resolved.outputs.cache_key}} - - name: Xcode - run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer - - name: Placeholder GoogleService-Info.plist for build testing - run: cp FirebaseCore/Tests/Unit/Resources/GoogleService-Info.plist FirebaseVertexAI/Sample/ - - uses: nick-fields/retry@v3 - with: - timeout_minutes: 120 - max_attempts: 3 - retry_on: error - retry_wait_seconds: 120 - command: scripts/build.sh VertexSample iOS + run: scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseVertexAI.podspec --platforms=${{ matrix.target }} ${{ matrix.warnings }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5722110bb01..578ffb947fc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -132,7 +132,7 @@ To develop Firebase software, **install**: To install [clang-format] and [mint] using [Homebrew]: ```console - brew install clang-format@19 + brew install clang-format@20 brew install mint ``` diff --git a/Crashlytics/CHANGELOG.md b/Crashlytics/CHANGELOG.md index d7ebe887305..14e65e3dae1 100644 --- a/Crashlytics/CHANGELOG.md +++ b/Crashlytics/CHANGELOG.md @@ -1,5 +1,5 @@ # 11.9.0 -- [fixed] Made on-demand fatal recording thread suspension configurable through setting to imrpove performance and avoid audio glitch on Unity. Change is for framework only. +- [fixed] Made on-demand fatal recording thread suspension configurable through setting to improve performance and avoid audio glitch on Unity. Change is for framework only. # 11.7.0 - [fixed] Updated `upload-symbols` to version 3.20, wait for `debug.dylib` DWARF content getting generated when build with `--build-phase` option. Added `debug.dylib` DWARF content to run script input file list for user who enabled user script sandboxing (#14054). diff --git a/Crashlytics/Crashlytics/Components/FIRCLSApplication.h b/Crashlytics/Crashlytics/Components/FIRCLSApplication.h index a04c8669969..0661d3128d5 100644 --- a/Crashlytics/Crashlytics/Components/FIRCLSApplication.h +++ b/Crashlytics/Crashlytics/Components/FIRCLSApplication.h @@ -83,10 +83,6 @@ UIApplication* FIRCLSApplicationSharedInstance(void); id FIRCLSApplicationSharedInstance(void); #endif -void FIRCLSApplicationOpenURL(NSURL* url, - NSExtensionContext* extensionContext, - void (^completionBlock)(BOOL success)); - id FIRCLSApplicationBeginActivity(NSActivityOptions options, NSString* reason); void FIRCLSApplicationEndActivity(id activity); diff --git a/Crashlytics/Crashlytics/Components/FIRCLSApplication.m b/Crashlytics/Crashlytics/Components/FIRCLSApplication.m index e1a0db632ec..67e452bd84d 100644 --- a/Crashlytics/Crashlytics/Components/FIRCLSApplication.m +++ b/Crashlytics/Crashlytics/Components/FIRCLSApplication.m @@ -155,32 +155,6 @@ id FIRCLSApplicationSharedInstance(void) { } #endif -void FIRCLSApplicationOpenURL(NSURL* url, - NSExtensionContext* extensionContext, - void (^completionBlock)(BOOL success)) { - if (extensionContext) { - [extensionContext openURL:url completionHandler:completionBlock]; - return; - } - - BOOL result = NO; - -#if TARGET_OS_IOS - // What's going on here is the value returned is a scalar, but we really need an object to - // call this dynamically. Hoops must be jumped. - NSInvocationOperation* op = - [[NSInvocationOperation alloc] initWithTarget:FIRCLSApplicationSharedInstance() - selector:@selector(openURL:) - object:url]; - [op start]; - [op.result getValue:&result]; -#elif CLS_TARGET_OS_OSX - result = [[NSClassFromString(@"NSWorkspace") sharedWorkspace] openURL:url]; -#endif - - completionBlock(result); -} - id FIRCLSApplicationBeginActivity(NSActivityOptions options, NSString* reason) { if ([[NSProcessInfo processInfo] respondsToSelector:@selector(beginActivityWithOptions: reason:)]) { diff --git a/Firebase.podspec b/Firebase.podspec index d3ceb4e68df..edf2335e02b 100644 --- a/Firebase.podspec +++ b/Firebase.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Firebase' - s.version = '11.9.0' + s.version = '11.10.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 = '12.0' ss.osx.deployment_target = '10.15' ss.tvos.deployment_target = '13.0' - ss.ios.dependency 'FirebaseAnalytics', '~> 11.9.0' - ss.osx.dependency 'FirebaseAnalytics', '~> 11.9.0' - ss.tvos.dependency 'FirebaseAnalytics', '~> 11.9.0' + ss.ios.dependency 'FirebaseAnalytics', '~> 11.10.0' + ss.osx.dependency 'FirebaseAnalytics', '~> 11.10.0' + ss.tvos.dependency 'FirebaseAnalytics', '~> 11.10.0' ss.dependency 'Firebase/CoreOnly' end s.subspec 'CoreOnly' do |ss| - ss.dependency 'FirebaseCore', '~> 11.9.0' + ss.dependency 'FirebaseCore', '~> 11.10.0' ss.source_files = 'CoreOnly/Sources/Firebase.h' ss.preserve_paths = 'CoreOnly/Sources/module.modulemap' if ENV['FIREBASE_POD_REPO_FOR_DEV_POD'] then @@ -79,13 +79,13 @@ Simplify your app development, grow your user base, and monetize more effectivel ss.ios.deployment_target = '12.0' ss.osx.deployment_target = '10.15' ss.tvos.deployment_target = '13.0' - ss.dependency 'FirebaseAnalytics/WithoutAdIdSupport', '~> 11.9.0' + ss.dependency 'FirebaseAnalytics/WithoutAdIdSupport', '~> 11.10.0' ss.dependency 'Firebase/CoreOnly' end s.subspec 'ABTesting' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseABTesting', '~> 11.9.0' + ss.dependency 'FirebaseABTesting', '~> 11.10.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '13.0' ss.osx.deployment_target = '10.15' @@ -95,13 +95,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', '~> 11.9.0-beta' + ss.ios.dependency 'FirebaseAppDistribution', '~> 11.10.0-beta' ss.ios.deployment_target = '13.0' end s.subspec 'AppCheck' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseAppCheck', '~> 11.9.0' + ss.dependency 'FirebaseAppCheck', '~> 11.10.0' ss.ios.deployment_target = '13.0' ss.osx.deployment_target = '10.15' ss.tvos.deployment_target = '13.0' @@ -110,7 +110,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', '~> 11.9.0' + ss.dependency 'FirebaseAuth', '~> 11.10.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '13.0' ss.osx.deployment_target = '10.15' @@ -120,7 +120,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', '~> 11.9.0' + ss.dependency 'FirebaseCrashlytics', '~> 11.10.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '12.0' ss.osx.deployment_target = '10.15' @@ -130,7 +130,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', '~> 11.9.0' + ss.dependency 'FirebaseDatabase', '~> 11.10.0' # Standard platforms PLUS watchOS 7. ss.ios.deployment_target = '13.0' ss.osx.deployment_target = '10.15' @@ -140,13 +140,13 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'DynamicLinks' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.ios.dependency 'FirebaseDynamicLinks', '~> 11.9.0' + ss.ios.dependency 'FirebaseDynamicLinks', '~> 11.10.0' ss.ios.deployment_target = '13.0' end s.subspec 'Firestore' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseFirestore', '~> 11.9.0' + ss.dependency 'FirebaseFirestore', '~> 11.10.0' ss.ios.deployment_target = '13.0' ss.osx.deployment_target = '10.15' ss.tvos.deployment_target = '13.0' @@ -154,7 +154,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', '~> 11.9.0' + ss.dependency 'FirebaseFunctions', '~> 11.10.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '13.0' ss.osx.deployment_target = '10.15' @@ -164,20 +164,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', '~> 11.9.0-beta' - ss.tvos.dependency 'FirebaseInAppMessaging', '~> 11.9.0-beta' + ss.ios.dependency 'FirebaseInAppMessaging', '~> 11.10.0-beta' + ss.tvos.dependency 'FirebaseInAppMessaging', '~> 11.10.0-beta' ss.ios.deployment_target = '13.0' ss.tvos.deployment_target = '13.0' end s.subspec 'Installations' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseInstallations', '~> 11.9.0' + ss.dependency 'FirebaseInstallations', '~> 11.10.0' end s.subspec 'Messaging' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseMessaging', '~> 11.9.0' + ss.dependency 'FirebaseMessaging', '~> 11.10.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '13.0' ss.osx.deployment_target = '10.15' @@ -187,7 +187,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', '~> 11.9.0-beta' + ss.dependency 'FirebaseMLModelDownloader', '~> 11.10.0-beta' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '13.0' ss.osx.deployment_target = '10.15' @@ -197,15 +197,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', '~> 11.9.0' - ss.tvos.dependency 'FirebasePerformance', '~> 11.9.0' + ss.ios.dependency 'FirebasePerformance', '~> 11.10.0' + ss.tvos.dependency 'FirebasePerformance', '~> 11.10.0' ss.ios.deployment_target = '13.0' ss.tvos.deployment_target = '13.0' end s.subspec 'RemoteConfig' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseRemoteConfig', '~> 11.9.0' + ss.dependency 'FirebaseRemoteConfig', '~> 11.10.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '13.0' ss.osx.deployment_target = '10.15' @@ -215,7 +215,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', '~> 11.9.0' + ss.dependency 'FirebaseStorage', '~> 11.10.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '13.0' ss.osx.deployment_target = '10.15' diff --git a/FirebaseABTesting.podspec b/FirebaseABTesting.podspec index c734a386c2c..91f1b67347a 100644 --- a/FirebaseABTesting.podspec +++ b/FirebaseABTesting.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseABTesting' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Firebase ABTesting' s.description = <<-DESC @@ -52,7 +52,7 @@ Firebase Cloud Messaging and Firebase Remote Config in your app. 'GCC_C_LANGUAGE_STANDARD' => 'c99', 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' } - s.dependency 'FirebaseCore', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' s.test_spec 'unit' do |unit_tests| unit_tests.scheme = { :code_coverage => true } diff --git a/FirebaseAnalytics.podspec b/FirebaseAnalytics.podspec index 77065b91c55..ce3c28cd544 100644 --- a/FirebaseAnalytics.podspec +++ b/FirebaseAnalytics.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAnalytics' - s.version = '11.9.0' + s.version = '11.10.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/cedc11ee5fe7e01c/FirebaseAnalytics-11.9.0.tar.gz' + :http => 'https://dl.google.com/firebase/ios/analytics/f4ae251137d1f29b/FirebaseAnalytics-11.10.0.tar.gz' } s.cocoapods_version = '>= 1.12.0' @@ -26,7 +26,7 @@ Pod::Spec.new do |s| s.libraries = 'c++', 'sqlite3', 'z' s.frameworks = 'StoreKit' - s.dependency 'FirebaseCore', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' s.dependency 'FirebaseInstallations', '~> 11.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.0' s.dependency 'GoogleUtilities/MethodSwizzler', '~> 8.0' @@ -37,12 +37,12 @@ Pod::Spec.new do |s| s.default_subspecs = 'AdIdSupport' s.subspec 'AdIdSupport' do |ss| - ss.dependency 'GoogleAppMeasurement', '11.9.0' + ss.dependency 'GoogleAppMeasurement', '11.10.0' ss.vendored_frameworks = 'Frameworks/FirebaseAnalytics.xcframework' end s.subspec 'WithoutAdIdSupport' do |ss| - ss.dependency 'GoogleAppMeasurement/WithoutAdIdSupport', '11.9.0' + ss.dependency 'GoogleAppMeasurement/WithoutAdIdSupport', '11.10.0' ss.vendored_frameworks = 'Frameworks/FirebaseAnalytics.xcframework' end diff --git a/FirebaseAnalyticsOnDeviceConversion.podspec b/FirebaseAnalyticsOnDeviceConversion.podspec index 735152fca77..4b252fcc0bb 100644 --- a/FirebaseAnalyticsOnDeviceConversion.podspec +++ b/FirebaseAnalyticsOnDeviceConversion.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAnalyticsOnDeviceConversion' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'On device conversion measurement plugin for FirebaseAnalytics. Not intended for direct use.' s.description = <<-DESC @@ -18,7 +18,7 @@ Pod::Spec.new do |s| s.cocoapods_version = '>= 1.12.0' - s.dependency 'GoogleAppMeasurementOnDeviceConversion', '11.9.0' + s.dependency 'GoogleAppMeasurementOnDeviceConversion', '11.10.0' s.static_framework = true diff --git a/FirebaseAppCheck.podspec b/FirebaseAppCheck.podspec index 6a26b59dbf2..3917fd9041f 100644 --- a/FirebaseAppCheck.podspec +++ b/FirebaseAppCheck.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAppCheck' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Firebase App Check SDK.' s.description = <<-DESC @@ -46,7 +46,7 @@ Pod::Spec.new do |s| s.dependency 'AppCheckCore', '~> 11.0' s.dependency 'FirebaseAppCheckInterop', '~> 11.0' - s.dependency 'FirebaseCore', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' s.dependency 'GoogleUtilities/Environment', '~> 8.0' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.0' diff --git a/FirebaseAppCheckInterop.podspec b/FirebaseAppCheckInterop.podspec index ae50c0b7ccc..5c15ded0a94 100644 --- a/FirebaseAppCheckInterop.podspec +++ b/FirebaseAppCheckInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAppCheckInterop' - s.version = '11.9.0' + s.version = '11.10.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 c51c27b916f..cf39ec63695 100644 --- a/FirebaseAppDistribution.podspec +++ b/FirebaseAppDistribution.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAppDistribution' - s.version = '11.9.0-beta' + s.version = '11.10.0-beta' s.summary = 'App Distribution for Firebase iOS SDK.' s.description = <<-DESC @@ -30,7 +30,7 @@ iOS SDK for App Distribution for Firebase. ] s.public_header_files = base_dir + 'Public/FirebaseAppDistribution/*.h' - s.dependency 'FirebaseCore', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.0' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.0' s.dependency 'FirebaseInstallations', '~> 11.0' diff --git a/FirebaseAuth.podspec b/FirebaseAuth.podspec index b6e9a1d8fee..970e2475c33 100644 --- a/FirebaseAuth.podspec +++ b/FirebaseAuth.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAuth' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Apple platform client for Firebase Authentication' s.description = <<-DESC @@ -58,8 +58,8 @@ supports email and password accounts, as well as several 3rd party authenticatio s.ios.framework = 'SafariServices' s.dependency 'FirebaseAuthInterop', '~> 11.0' s.dependency 'FirebaseAppCheckInterop', '~> 11.0' - s.dependency 'FirebaseCore', '~> 11.9.0' - s.dependency 'FirebaseCoreExtension', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' + s.dependency 'FirebaseCoreExtension', '~> 11.10.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.0' s.dependency 'GoogleUtilities/Environment', '~> 8.0' s.dependency 'GTMSessionFetcher/Core', '>= 3.4', '< 5.0' diff --git a/FirebaseAuthInterop.podspec b/FirebaseAuthInterop.podspec index a0b2c7b0528..391f93aabe6 100644 --- a/FirebaseAuthInterop.podspec +++ b/FirebaseAuthInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAuthInterop' - s.version = '11.9.0' + s.version = '11.10.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 a53d06384f8..a69c13b9f48 100644 --- a/FirebaseCombineSwift.podspec +++ b/FirebaseCombineSwift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCombineSwift' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Swift extensions with Combine support for Firebase' s.description = <<-DESC @@ -51,7 +51,7 @@ for internal testing only. It should not be published. s.osx.framework = 'AppKit' s.tvos.framework = 'UIKit' - s.dependency 'FirebaseCore', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' s.dependency 'FirebaseAuth', '~> 11.0' s.dependency 'FirebaseFunctions', '~> 11.0' s.dependency 'FirebaseFirestore', '~> 11.0' diff --git a/FirebaseCore.podspec b/FirebaseCore.podspec index 259cb9941ac..5462b954b3a 100644 --- a/FirebaseCore.podspec +++ b/FirebaseCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCore' - s.version = '11.9.0' + s.version = '11.10.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.0' s.dependency 'GoogleUtilities/Logger', '~> 8.0' - s.dependency 'FirebaseCoreInternal', '~> 11.9.0' + s.dependency 'FirebaseCoreInternal', '~> 11.10.0' s.pod_target_xcconfig = { 'GCC_C_LANGUAGE_STANDARD' => 'c99', diff --git a/FirebaseCore/Internal/Tests/Integration/HeartbeatLoggingIntegrationTests.swift b/FirebaseCore/Internal/Tests/Integration/HeartbeatLoggingIntegrationTests.swift index 3c5cdba3950..2ee9b6832d6 100644 --- a/FirebaseCore/Internal/Tests/Integration/HeartbeatLoggingIntegrationTests.swift +++ b/FirebaseCore/Internal/Tests/Integration/HeartbeatLoggingIntegrationTests.swift @@ -16,9 +16,6 @@ import XCTest class HeartbeatLoggingIntegrationTests: XCTestCase { - // 2021-11-01 @ 00:00:00 (EST) - let date = Date(timeIntervalSince1970: 1_635_739_200) - override func setUpWithError() throws { try HeartbeatLoggingTestUtils.removeUnderlyingHeartbeatStorageContainers() } @@ -52,7 +49,7 @@ class HeartbeatLoggingIntegrationTests: XCTestCase { ) } - func testLogAndFlushAsync() throws { + @MainActor func testLogAndFlushAsync() throws { // Given let heartbeatController = HeartbeatController(id: #function) let expectedDate = HeartbeatsPayload.dateFormatter.string(from: Date()) @@ -123,8 +120,9 @@ class HeartbeatLoggingIntegrationTests: XCTestCase { func testMultipleControllersWithTheSameIDUseTheSameStorageInstance() throws { // Given - let heartbeatController1 = HeartbeatController(id: #function, dateProvider: { self.date }) - let heartbeatController2 = HeartbeatController(id: #function, dateProvider: { self.date }) + let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST) + let heartbeatController1 = HeartbeatController(id: #function, dateProvider: { date }) + let heartbeatController2 = HeartbeatController(id: #function, dateProvider: { date }) // When heartbeatController1.log("dummy_agent") // Then @@ -146,92 +144,85 @@ class HeartbeatLoggingIntegrationTests: XCTestCase { assertHeartbeatControllerFlushesEmptyPayload(heartbeatController1) } - func testLogAndFlushConcurrencyStressTest() throws { + @MainActor func testLogAndFlushConcurrencyStressTest() throws { // Given - let heartbeatController = HeartbeatController(id: #function, dateProvider: { self.date }) + let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST) + let heartbeatController = HeartbeatController(id: #function, dateProvider: { date }) // When DispatchQueue.concurrentPerform(iterations: 100) { _ in heartbeatController.log("dummy_agent") } - var payloads: [HeartbeatsPayload] = [] + let expectation = self.expectation(description: #function) DispatchQueue.concurrentPerform(iterations: 100) { _ in let payload = heartbeatController.flush() - payloads.append(payload) - } - - // Then - let nonEmptyPayloads = payloads.filter { payload in - // Filter out non-empty payloads. - !payload.userAgentPayloads.isEmpty - } - - XCTAssertEqual(nonEmptyPayloads.count, 1) - - let payload = try XCTUnwrap(nonEmptyPayloads.first) - try HeartbeatLoggingTestUtils.assertEqualPayloadStrings( - payload.headerValue(), - """ - { - "version": 2, - "heartbeats": [ + if !payload.userAgentPayloads.isEmpty { + try! HeartbeatLoggingTestUtils.assertEqualPayloadStrings( + payload.headerValue(), + """ { - "agent": "dummy_agent", - "dates": ["2021-11-01"] + "version": 2, + "heartbeats": [ + { + "agent": "dummy_agent", + "dates": ["2021-11-01"] + } + ] } - ] + """ + ) + expectation.fulfill() } - """ - ) + } + + // Then + wait(for: [expectation]) } - func testLogAndFlushHeartbeatFromTodayConcurrencyStressTest() throws { + @MainActor func testLogAndFlushHeartbeatFromTodayConcurrencyStressTest() throws { // Given - let heartbeatController = HeartbeatController(id: #function, dateProvider: { self.date }) + let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST) + let heartbeatController = HeartbeatController(id: #function, dateProvider: { date }) // When DispatchQueue.concurrentPerform(iterations: 100) { _ in heartbeatController.log("dummy_agent") } - var payloads: [HeartbeatsPayload] = [] + let expectation = self.expectation(description: #function) DispatchQueue.concurrentPerform(iterations: 100) { _ in let payload = heartbeatController.flushHeartbeatFromToday() - payloads.append(payload) - } - - // Then - let nonEmptyPayloads = payloads.filter { payload in - // Filter out non-empty payloads. - !payload.userAgentPayloads.isEmpty - } - - XCTAssertEqual(nonEmptyPayloads.count, 1) - - let payload = try XCTUnwrap(nonEmptyPayloads.first) - try HeartbeatLoggingTestUtils.assertEqualPayloadStrings( - payload.headerValue(), - """ - { - "version": 2, - "heartbeats": [ + if !payload.userAgentPayloads.isEmpty { + try! HeartbeatLoggingTestUtils.assertEqualPayloadStrings( + payload.headerValue(), + """ { - "agent": "dummy_agent", - "dates": ["2021-11-01"], + "version": 2, + "heartbeats": [ + { + "agent": "dummy_agent", + "dates": ["2021-11-01"], + } + ] } - ] + """ + ) + expectation.fulfill() } - """ - ) + } + + // Then + wait(for: [expectation]) assertHeartbeatControllerFlushesEmptyPayload(heartbeatController) } func testLogRepeatedly_WithoutFlushing_LimitsOnWrite() throws { // Given + let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST) let testDate = AdjustableDate(date: date) let heartbeatController = HeartbeatController(id: #function, dateProvider: { testDate.date }) @@ -313,7 +304,8 @@ class HeartbeatLoggingIntegrationTests: XCTestCase { func testLogAndFlush_AfterUnderlyingStorageIsDeleted_CreatesNewStorage() throws { // Given - let heartbeatController = HeartbeatController(id: #function, dateProvider: { self.date }) + let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST) + let heartbeatController = HeartbeatController(id: #function, dateProvider: { date }) heartbeatController.log("dummy_agent") _ = XCTWaiter.wait(for: [expectation(description: "Wait for async log.")], timeout: 0.1) diff --git a/FirebaseCore/Internal/Tests/Unit/HeartbeatControllerTests.swift b/FirebaseCore/Internal/Tests/Unit/HeartbeatControllerTests.swift index bac98cf4647..a9f746b019b 100644 --- a/FirebaseCore/Internal/Tests/Unit/HeartbeatControllerTests.swift +++ b/FirebaseCore/Internal/Tests/Unit/HeartbeatControllerTests.swift @@ -16,9 +16,6 @@ import XCTest class HeartbeatControllerTests: XCTestCase { - // 2021-11-01 @ 00:00:00 (EST) - let date = Date(timeIntervalSince1970: 1_635_739_200) - func testFlush_WhenEmpty_ReturnsEmptyPayload() throws { // Given let controller = HeartbeatController(storage: HeartbeatStorageFake()) @@ -28,9 +25,10 @@ class HeartbeatControllerTests: XCTestCase { func testLogAndFlush() throws { // Given + let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST) let controller = HeartbeatController( storage: HeartbeatStorageFake(), - dateProvider: { self.date } + dateProvider: { date } ) assertHeartbeatControllerFlushesEmptyPayload(controller) @@ -58,11 +56,12 @@ class HeartbeatControllerTests: XCTestCase { assertHeartbeatControllerFlushesEmptyPayload(controller) } - func testLogAndFlushAsync() throws { + @MainActor func testLogAndFlushAsync() throws { // Given + let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST) let controller = HeartbeatController( storage: HeartbeatStorageFake(), - dateProvider: { self.date } + dateProvider: { date } ) let expectation = expectation(description: #function) @@ -99,6 +98,7 @@ class HeartbeatControllerTests: XCTestCase { func testLogAtEndOfTimePeriodAndAcceptAtStartOfNextOne() throws { // Given + let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST) let testDate = AdjustableDate(date: date) let controller = HeartbeatController( @@ -148,9 +148,10 @@ class HeartbeatControllerTests: XCTestCase { func testDoNotLogMoreThanOnceInACalendarDay() throws { // Given + let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST) let controller = HeartbeatController( storage: HeartbeatStorageFake(), - dateProvider: { self.date } + dateProvider: { date } ) // When @@ -178,9 +179,10 @@ class HeartbeatControllerTests: XCTestCase { func testDoNotLogMoreThanOnceInACalendarDay_AfterFlushing() throws { // Given + let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST) let controller = HeartbeatController( storage: HeartbeatStorageFake(), - dateProvider: { self.date } + dateProvider: { date } ) // When @@ -308,6 +310,7 @@ class HeartbeatControllerTests: XCTestCase { func testLoggingDependsOnDateNotUserAgent() throws { // Given + let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST) let testDate = AdjustableDate(date: date) let heartbeatController = HeartbeatController( storage: HeartbeatStorageFake(), @@ -355,6 +358,7 @@ class HeartbeatControllerTests: XCTestCase { func testFlushHeartbeatFromToday_WhenTodayHasAHeartbeat_ReturnsPayloadWithOnlyTodaysHeartbeat() throws { // Given + let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST) let yesterdaysDate = date.addingTimeInterval(-1 * 60 * 60 * 24) let todaysDate = date let tomorrowsDate = date.addingTimeInterval(60 * 60 * 24) @@ -416,7 +420,8 @@ class HeartbeatControllerTests: XCTestCase { func testFlushHeartbeatFromToday_WhenTodayDoesNotHaveAHeartbeat_ReturnsEmptyPayload() throws { // Given - let heartbeatController = HeartbeatController(id: #function, dateProvider: { self.date }) + let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST) + let heartbeatController = HeartbeatController(id: #function, dateProvider: { date }) // When heartbeatController.flushHeartbeatFromToday() // Then diff --git a/FirebaseCore/Internal/Tests/Unit/HeartbeatStorageTests.swift b/FirebaseCore/Internal/Tests/Unit/HeartbeatStorageTests.swift index 5e704a7fb89..8d5a03f942c 100644 --- a/FirebaseCore/Internal/Tests/Unit/HeartbeatStorageTests.swift +++ b/FirebaseCore/Internal/Tests/Unit/HeartbeatStorageTests.swift @@ -16,11 +16,12 @@ import XCTest extension HeartbeatsBundle { - static let testHeartbeatBundle: Self = { + static func testHeartbeatBundle() -> HeartbeatsBundle { var heartbeatBundle = HeartbeatsBundle(capacity: 1) - heartbeatBundle.append(Heartbeat(agent: "dummy_agent", date: Date())) + let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST) + heartbeatBundle.append(Heartbeat(agent: "dummy_agent", date: date)) return heartbeatBundle - }() + } } class HeartbeatStorageTests: XCTestCase { @@ -108,7 +109,7 @@ class HeartbeatStorageTests: XCTestCase { // Assert that heartbeat storage is empty. XCTAssertNil(heartbeatsBundle) // Write new value. - return HeartbeatsBundle.testHeartbeatBundle + return HeartbeatsBundle.testHeartbeatBundle() } heartbeatStorage.readAndWriteAsync { heartbeatsBundle in @@ -116,7 +117,7 @@ class HeartbeatStorageTests: XCTestCase { // Assert old value is read. XCTAssertEqual( heartbeatsBundle?.makeHeartbeatsPayload(), - HeartbeatsBundle.testHeartbeatBundle.makeHeartbeatsPayload() + HeartbeatsBundle.testHeartbeatBundle().makeHeartbeatsPayload() ) // Write some new value. return heartbeatsBundle @@ -158,7 +159,7 @@ class HeartbeatStorageTests: XCTestCase { heartbeatStorage.readAndWriteAsync { heartbeatsBundle in expectation.fulfill() - return HeartbeatsBundle.testHeartbeatBundle + return HeartbeatsBundle.testHeartbeatBundle() } // Then @@ -166,10 +167,10 @@ class HeartbeatStorageTests: XCTestCase { expectation.fulfill() XCTAssertNotEqual( heartbeatsBundle?.makeHeartbeatsPayload(), - HeartbeatsBundle.testHeartbeatBundle.makeHeartbeatsPayload(), + HeartbeatsBundle.testHeartbeatBundle().makeHeartbeatsPayload(), "They should not be equal because the previous save failed." ) - return HeartbeatsBundle.testHeartbeatBundle + return HeartbeatsBundle.testHeartbeatBundle() } wait(for: [expectation], timeout: 0.5) @@ -220,7 +221,7 @@ class HeartbeatStorageTests: XCTestCase { // Assert that heartbeat storage is empty. XCTAssertNil(heartbeatsBundle) // Write new value. - return HeartbeatsBundle.testHeartbeatBundle + return HeartbeatsBundle.testHeartbeatBundle() } completion: { result in switch result { case .success: break @@ -236,7 +237,7 @@ class HeartbeatStorageTests: XCTestCase { // Assert old value is read. XCTAssertEqual( heartbeatsBundle?.makeHeartbeatsPayload(), - HeartbeatsBundle.testHeartbeatBundle.makeHeartbeatsPayload() + HeartbeatsBundle.testHeartbeatBundle().makeHeartbeatsPayload() ) // Write some new value. expectation2.fulfill() diff --git a/FirebaseCore/Sources/Public/FirebaseCore/FIROptions.h b/FirebaseCore/Sources/Public/FirebaseCore/FIROptions.h index 8f8d945d765..14e60fcde33 100644 --- a/FirebaseCore/Sources/Public/FirebaseCore/FIROptions.h +++ b/FirebaseCore/Sources/Public/FirebaseCore/FIROptions.h @@ -102,12 +102,12 @@ NS_SWIFT_NAME(FirebaseOptions) * This will read the file synchronously from disk. * For example: * ```swift - * if let path = Bundle.main.path(forResource:"GoogleServices-Info", ofType:"plist") { + * if let path = Bundle.main.path(forResource:"GoogleService-Info", ofType:"plist") { * let options = FirebaseOptions(contentsOfFile: path) * } * ``` * Note that it is not possible to customize `FirebaseOptions` for Firebase Analytics which expects - * a static file named `GoogleServices-Info.plist` - + * a static file named `GoogleService-Info.plist` - * https://github.com/firebase/firebase-ios-sdk/issues/230. * Returns `nil` if the plist file does not exist or is invalid. */ diff --git a/FirebaseCoreExtension.podspec b/FirebaseCoreExtension.podspec index 955dfc85de4..22885e43bd9 100644 --- a/FirebaseCoreExtension.podspec +++ b/FirebaseCoreExtension.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCoreExtension' - s.version = '11.9.0' + s.version = '11.10.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', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' end diff --git a/FirebaseCoreInternal.podspec b/FirebaseCoreInternal.podspec index f3d23d97c32..033df32b75b 100644 --- a/FirebaseCoreInternal.podspec +++ b/FirebaseCoreInternal.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCoreInternal' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'APIs for internal FirebaseCore usage.' s.description = <<-DESC diff --git a/FirebaseCrashlytics.podspec b/FirebaseCrashlytics.podspec index 01bfd35d710..c6e63214abc 100644 --- a/FirebaseCrashlytics.podspec +++ b/FirebaseCrashlytics.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCrashlytics' - s.version = '11.9.0' + s.version = '11.10.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,7 +59,7 @@ Pod::Spec.new do |s| cp -f ./Crashlytics/CrashlyticsInputFiles.xcfilelist ./CrashlyticsInputFiles.xcfilelist PREPARE_COMMAND_END - s.dependency 'FirebaseCore', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' s.dependency 'FirebaseInstallations', '~> 11.0' s.dependency 'FirebaseSessions', '~> 11.0' s.dependency 'FirebaseRemoteConfigInterop', '~> 11.0' diff --git a/FirebaseDatabase.podspec b/FirebaseDatabase.podspec index fcab457bf64..c19fddfd96e 100644 --- a/FirebaseDatabase.podspec +++ b/FirebaseDatabase.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseDatabase' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Firebase Realtime Database' s.description = <<-DESC @@ -48,7 +48,7 @@ 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', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' s.dependency 'FirebaseAppCheckInterop', '~> 11.0' s.dependency 'FirebaseSharedSwift', '~> 11.0' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.0' diff --git a/FirebaseDynamicLinks.podspec b/FirebaseDynamicLinks.podspec index 4a6a88df9b8..eb25ddf09b5 100644 --- a/FirebaseDynamicLinks.podspec +++ b/FirebaseDynamicLinks.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseDynamicLinks' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Firebase Dynamic Links' s.description = <<-DESC @@ -37,7 +37,7 @@ Firebase Dynamic Links are deep links that enhance user experience and increase } s.frameworks = 'QuartzCore' s.weak_framework = 'WebKit' - s.dependency 'FirebaseCore', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' s.pod_target_xcconfig = { 'GCC_C_LANGUAGE_STANDARD' => 'c99', diff --git a/FirebaseFirestore.podspec b/FirebaseFirestore.podspec index 5767123823f..6bdad5c55b3 100644 --- a/FirebaseFirestore.podspec +++ b/FirebaseFirestore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseFirestore' - s.version = '11.9.0' + s.version = '11.10.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', '~> 11.9.0' - s.dependency 'FirebaseCoreExtension', '~> 11.9.0' - s.dependency 'FirebaseFirestoreInternal', '11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' + s.dependency 'FirebaseCoreExtension', '~> 11.10.0' + s.dependency 'FirebaseFirestoreInternal', '11.10.0' s.dependency 'FirebaseSharedSwift', '~> 11.0' end diff --git a/FirebaseFirestoreInternal.podspec b/FirebaseFirestoreInternal.podspec index 40b4f79601e..ca40cbafa6b 100644 --- a/FirebaseFirestoreInternal.podspec +++ b/FirebaseFirestoreInternal.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseFirestoreInternal' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Google Cloud Firestore' s.description = <<-DESC @@ -93,7 +93,7 @@ Google Cloud Firestore is a NoSQL document database built for automatic scaling, } s.dependency 'FirebaseAppCheckInterop', '~> 11.0' - s.dependency 'FirebaseCore', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' abseil_version = '~> 1.20240722.0' s.dependency 'abseil/algorithm', abseil_version diff --git a/FirebaseFunctions.podspec b/FirebaseFunctions.podspec index 1ad3cd403e2..c70e03349d8 100644 --- a/FirebaseFunctions.podspec +++ b/FirebaseFunctions.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseFunctions' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Cloud Functions for Firebase' s.description = <<-DESC @@ -35,8 +35,8 @@ Cloud Functions for Firebase. 'FirebaseFunctions/Sources/**/*.swift', ] - s.dependency 'FirebaseCore', '~> 11.9.0' - s.dependency 'FirebaseCoreExtension', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' + s.dependency 'FirebaseCoreExtension', '~> 11.10.0' s.dependency 'FirebaseAppCheckInterop', '~> 11.0' s.dependency 'FirebaseAuthInterop', '~> 11.0' s.dependency 'FirebaseMessagingInterop', '~> 11.0' diff --git a/FirebaseFunctions/Backend/index.js b/FirebaseFunctions/Backend/index.js index 3bfd6f31328..bebcc0f421b 100644 --- a/FirebaseFunctions/Backend/index.js +++ b/FirebaseFunctions/Backend/index.js @@ -16,6 +16,14 @@ const assert = require('assert'); const functionsV1 = require('firebase-functions/v1'); const functionsV2 = require('firebase-functions/v2'); +// MARK: - Utilities + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +}; + +// MARK: - Callable Functions + exports.dataTest = functionsV1.https.onRequest((request, response) => { assert.deepEqual(request.body, { data: { @@ -121,14 +129,10 @@ exports.timeoutTest = functionsV1.https.onRequest((request, response) => { const streamData = ["hello", "world", "this", "is", "cool"] -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -}; - async function* generateText() { for (const chunk of streamData) { yield chunk; - await sleep(1000); + await sleep(100); } }; @@ -136,7 +140,7 @@ exports.genStream = functionsV2.https.onCall( async (request, response) => { if (request.acceptsStreaming) { for await (const chunk of generateText()) { - response.sendChunk({ chunk }); + response.sendChunk(chunk); } } return streamData.join(" "); @@ -145,11 +149,81 @@ exports.genStream = functionsV2.https.onCall( exports.genStreamError = functionsV2.https.onCall( async (request, response) => { + // Note: The functions backend does not pass the error message to the + // client at this time. + throw Error("BOOM") + } +); + +const weatherForecasts = { + Toronto: { conditions: 'snowy', temperature: 25 }, + London: { conditions: 'rainy', temperature: 50 }, + Dubai: { conditions: 'sunny', temperature: 75 } +}; + +async function* generateForecast(locations) { + for (const location of locations) { + yield { 'location': location, ...weatherForecasts[location.name] }; + await sleep(100); + } +}; + +exports.genStreamWeather = functionsV2.https.onCall( + async (request, response) => { + const forecasts = []; if (request.acceptsStreaming) { - for await (const chunk of generateText()) { - response.write({ chunk }); + for await (const chunk of generateForecast(request.data)) { + forecasts.push(chunk) + response.sendChunk(chunk); + } + } + return { forecasts }; + } +); + +exports.genStreamWeatherError = functionsV2.https.onCall( + async (request, response) => { + if (request.acceptsStreaming) { + for await (const chunk of generateForecast(request.data)) { + // Remove the location field, since the SDK cannot decode the message + // if it's there. + delete chunk.location; + response.sendChunk(chunk); + } + } + return "Number of forecasts generated: " + request.data.length; + } +); + +exports.genStreamEmpty = functionsV2.https.onCall( + async (request, response) => { + if (request.acceptsStreaming) { + // Send no chunks + } + // Implicitly return null. + } +); + +exports.genStreamResultOnly = functionsV2.https.onCall( + async (request, response) => { + if (request.acceptsStreaming) { + // Do not send any chunks. + } + return "Only a result"; + } +); + +exports.genStreamLargeData = functionsV2.https.onCall( + async (request, response) => { + if (request.acceptsStreaming) { + const largeString = 'A'.repeat(10000); + const chunkSize = 1024; + for (let i = 0; i < largeString.length; i += chunkSize) { + const chunk = largeString.substring(i, i + chunkSize); + response.sendChunk(chunk); + await sleep(100); } - throw Error("BOOM") } + return "Stream Completed"; } ); diff --git a/FirebaseFunctions/Backend/start.sh b/FirebaseFunctions/Backend/start.sh index 1ee3777cdc8..8afecf1c387 100755 --- a/FirebaseFunctions/Backend/start.sh +++ b/FirebaseFunctions/Backend/start.sh @@ -57,6 +57,11 @@ FUNCTIONS_BIN="./node_modules/.bin/functions" "${FUNCTIONS_BIN}" deploy timeoutTest --trigger-http "${FUNCTIONS_BIN}" deploy genStream --trigger-http "${FUNCTIONS_BIN}" deploy genStreamError --trigger-http +"${FUNCTIONS_BIN}" deploy genStreamWeather --trigger-http +"${FUNCTIONS_BIN}" deploy genStreamWeatherError --trigger-http +"${FUNCTIONS_BIN}" deploy genStreamEmpty --trigger-http +"${FUNCTIONS_BIN}" deploy genStreamResultOnly --trigger-http +"${FUNCTIONS_BIN}" deploy genStreamLargeData --trigger-http if [ "$1" != "synchronous" ]; then # Wait for the user to tell us to stop the server. diff --git a/FirebaseFunctions/CHANGELOG.md b/FirebaseFunctions/CHANGELOG.md index 89a663ec718..f244f3f58e8 100644 --- a/FirebaseFunctions/CHANGELOG.md +++ b/FirebaseFunctions/CHANGELOG.md @@ -1,3 +1,6 @@ +# 11.10.0 +- [added] Streaming callable functions are now supported. + # 11.9.0 - [fixed] Fixed App Check token reporting to enable differentiating outdated (`MISSING`) and inauthentic (`INVALID`) clients; see [Monitor App Check diff --git a/FirebaseFunctions/Sources/Callable+Codable.swift b/FirebaseFunctions/Sources/Callable+Codable.swift index 489433a0a7e..287eff55ebb 100644 --- a/FirebaseFunctions/Sources/Callable+Codable.swift +++ b/FirebaseFunctions/Sources/Callable+Codable.swift @@ -15,7 +15,11 @@ import FirebaseSharedSwift import Foundation -/// A `Callable` is reference to a particular Callable HTTPS trigger in Cloud Functions. +/// A `Callable` is a reference to a particular Callable HTTPS trigger in Cloud Functions. +/// +/// - Note: If the Callable HTTPS trigger accepts no parameters, ``Never`` can be used for +/// iOS 17.0+. Otherwise, a simple encodable placeholder type (e.g., +/// `struct EmptyRequest: Encodable {}`) can be used. public struct Callable { /// The timeout to use when calling the function. Defaults to 70 seconds. public var timeoutInterval: TimeInterval { @@ -160,3 +164,175 @@ public struct Callable { return try await call(data) } } + +/// Used to determine when a `StreamResponse<_, _>` is being decoded. +private protocol StreamResponseProtocol {} + +/// A convenience type used to receive both the streaming callable function's yielded messages and +/// its return value. +/// +/// This can be used as the generic `Response` parameter to ``Callable`` to receive both the +/// yielded messages and final return value of the streaming callable function. +@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) +public enum StreamResponse: Decodable, + StreamResponseProtocol { + /// The message yielded by the callable function. + case message(Message) + /// The final result returned by the callable function. + case result(Result) + + private enum CodingKeys: String, CodingKey { + case message + case result + } + + public init(from decoder: any Decoder) throws { + do { + let container = try decoder + .container(keyedBy: Self.CodingKeys.self) + guard let onlyKey = container.allKeys.first, container.allKeys.count == 1 else { + throw DecodingError + .typeMismatch( + Self.self, + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Invalid number of keys found, expected one.", + underlyingError: nil + ) + ) + } + + switch onlyKey { + case .message: + self = try Self + .message(container.decode(Message.self, forKey: .message)) + case .result: + self = try Self + .result(container.decode(Result.self, forKey: .result)) + } + } catch { + throw FunctionsError(.dataLoss, userInfo: [NSUnderlyingErrorKey: error]) + } + } +} + +@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) +public extension Callable where Request: Sendable, Response: Sendable { + /// Creates a stream that yields responses from the streaming callable function. + /// + /// The request to the Cloud Functions backend made by this method automatically includes a FCM + /// token to identify the app instance. If a user is logged in with Firebase Auth, an auth ID + /// token for the user is included. If App Check is integrated, an app check token is included. + /// + /// Firebase Cloud Messaging sends data to the Firebase backend periodically to collect + /// information regarding the app instance. To stop this, see `Messaging.deleteData()`. It + /// resumes with a new FCM Token the next time you call this method. + /// + /// - Important: The final result returned by the callable function is only accessible when + /// using `StreamResponse` as the `Response` generic type. + /// + /// Example of using `stream` _without_ `StreamResponse`: + /// ```swift + /// let callable: Callable = // ... + /// let request: MyRequest = // ... + /// let stream = try callable.stream(request) + /// for try await response in stream { + /// // Process each `MyResponse` message + /// print(response) + /// } + /// ``` + /// + /// Example of using `stream` _with_ `StreamResponse`: + /// ```swift + /// let callable: Callable> = // ... + /// let request: MyRequest = // ... + /// let stream = try callable.stream(request) + /// for try await response in stream { + /// switch response { + /// case .message(let message): + /// // Process each `MyMessage` + /// print(message) + /// case .result(let result): + /// // Process the final `MyResult` + /// print(result) + /// } + /// } + /// ``` + /// + /// - Parameter data: The `Request` data to pass to the callable function. + /// - Throws: A ``FunctionsError`` if the parameter `data` cannot be encoded. + /// - Returns: A stream wrapping responses yielded by the streaming callable function or + /// a ``FunctionsError`` if an error occurred. + func stream(_ data: Request? = nil) throws -> AsyncThrowingStream { + let encoded: Any + do { + encoded = try encoder.encode(data) + } catch { + throw FunctionsError(.invalidArgument, userInfo: [NSUnderlyingErrorKey: error]) + } + + return AsyncThrowingStream { continuation in + Task { + do { + for try await response in callable.stream(encoded) { + do { + // This response JSON should only be able to be decoded to an `StreamResponse<_, _>` + // instance. If the decoding succeeds and the decoded response conforms to + // `StreamResponseProtocol`, we know the `Response` generic argument + // is `StreamResponse<_, _>`. + let responseJSON = switch response { + case .message(let json), .result(let json): json + } + let response = try decoder.decode(Response.self, from: responseJSON) + if response is StreamResponseProtocol { + continuation.yield(response) + } else { + // `Response` is a custom type that matched the decoding logic as the + // `StreamResponse<_, _>` type. Only the `StreamResponse<_, _>` type should decode + // successfully here to avoid exposing the `result` value in a custom type. + throw FunctionsError(.internal) + } + } catch let error as FunctionsError where error.code == .dataLoss { + // `Response` is of type `StreamResponse<_, _>`, but failed to decode. Rethrow. + throw error + } catch { + // `Response` is *not* of type `StreamResponse<_, _>`, and needs to be unboxed and + // decoded. + guard case let .message(messageJSON) = response else { + // Since `Response` is not a `StreamResponse<_, _>`, only messages should be + // decoded. + continue + } + + do { + let boxedMessage = try decoder.decode( + StreamResponseMessage.self, + from: messageJSON + ) + continuation.yield(boxedMessage.message) + } catch { + throw FunctionsError(.dataLoss, userInfo: [NSUnderlyingErrorKey: error]) + } + } + } + } catch { + continuation.finish(throwing: error) + } + continuation.finish() + } + } + } + + /// A container type for the type-safe decoding of the message object from the generic `Response` + /// type. + private struct StreamResponseMessage: Decodable { + let message: Response + } +} + +/// A container type for differentiating between message and result responses. +enum JSONStreamResponse { + case message([String: Any]) + case result([String: Any]) +} diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 51e405b2f39..d9e00afb34a 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -401,9 +401,9 @@ enum FunctionsConstants { do { let rawData = try await fetcher.beginFetch() - return try callableResult(fromResponseData: rawData) + return try callableResult(fromResponseData: rawData, endpointURL: url) } catch { - throw processedError(fromResponseError: error) + throw processedError(fromResponseError: error, endpointURL: url) } } @@ -454,10 +454,10 @@ enum FunctionsConstants { fetcher.beginFetch { [self] data, error in let result: Result if let error { - result = .failure(processedError(fromResponseError: error)) + result = .failure(processedError(fromResponseError: error, endpointURL: url)) } else if let data { do { - result = try .success(callableResult(fromResponseData: data)) + result = try .success(callableResult(fromResponseData: data, endpointURL: url)) } catch { result = .failure(error) } @@ -471,6 +471,201 @@ enum FunctionsConstants { } } + @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) + func stream(at url: URL, + data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval) + -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + let urlRequest: URLRequest + do { + let context = try await contextProvider.context(options: options) + urlRequest = try makeRequestForStreamableContent( + url: url, + data: data, + options: options, + timeout: timeout, + context: context + ) + } catch { + continuation.finish(throwing: FunctionsError( + .invalidArgument, + userInfo: [NSUnderlyingErrorKey: error] + )) + return + } + + let stream: URLSession.AsyncBytes + let rawResponse: URLResponse + do { + (stream, rawResponse) = try await URLSession.shared.bytes(for: urlRequest) + } catch { + continuation.finish(throwing: FunctionsError( + .unavailable, + userInfo: [NSUnderlyingErrorKey: error] + )) + return + } + + // Verify the status code is an HTTP response. + guard let response = rawResponse as? HTTPURLResponse else { + continuation.finish( + throwing: FunctionsError( + .unavailable, + userInfo: [NSLocalizedDescriptionKey: "Response was not an HTTP response."] + ) + ) + return + } + + // Verify the status code is a 200. + guard response.statusCode == 200 else { + continuation.finish( + throwing: FunctionsError( + httpStatusCode: response.statusCode, + region: region, + url: url, + body: nil, + serializer: serializer + ) + ) + return + } + + do { + for try await line in stream.lines { + guard line.hasPrefix("data:") else { + continuation.finish( + throwing: FunctionsError( + .dataLoss, + userInfo: [NSLocalizedDescriptionKey: "Unexpected format for streamed response."] + ) + ) + return + } + + do { + // We can assume 5 characters since it's utf-8 encoded, removing `data:`. + let jsonText = String(line.dropFirst(5)) + let data = try jsonData(jsonText: jsonText) + // Handle the content and parse it. + let content = try callableStreamResult(fromResponseData: data, endpointURL: url) + continuation.yield(content) + } catch { + continuation.finish(throwing: error) + return + } + } + } catch { + continuation.finish( + throwing: FunctionsError( + .dataLoss, + userInfo: [ + NSLocalizedDescriptionKey: "Unexpected format for streamed response.", + NSUnderlyingErrorKey: error, + ] + ) + ) + return + } + + continuation.finish() + } + } + } + + @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) + private func callableStreamResult(fromResponseData data: Data, + endpointURL url: URL) throws -> JSONStreamResponse { + let data = try processedData(fromResponseData: data, endpointURL: url) + + let responseJSONObject: Any + do { + responseJSONObject = try JSONSerialization.jsonObject(with: data) + } catch { + throw FunctionsError(.dataLoss, userInfo: [NSUnderlyingErrorKey: error]) + } + + guard let responseJSON = responseJSONObject as? [String: Any] else { + let userInfo = [NSLocalizedDescriptionKey: "Response was not a dictionary."] + throw FunctionsError(.dataLoss, userInfo: userInfo) + } + + if let _ = responseJSON["result"] { + return .result(responseJSON) + } else if let _ = responseJSON["message"] { + return .message(responseJSON) + } else { + throw FunctionsError( + .dataLoss, + userInfo: [NSLocalizedDescriptionKey: "Response is missing result or message field."] + ) + } + } + + private func jsonData(jsonText: String) throws -> Data { + guard let data = jsonText.data(using: .utf8) else { + throw FunctionsError(.dataLoss, userInfo: [ + NSUnderlyingErrorKey: DecodingError.dataCorrupted(DecodingError.Context( + codingPath: [], + debugDescription: "Could not parse response as UTF8." + )), + ]) + } + return data + } + + private func makeRequestForStreamableContent(url: URL, + data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval, + context: FunctionsContext) throws + -> URLRequest { + var urlRequest = URLRequest( + url: url, + cachePolicy: .useProtocolCachePolicy, + timeoutInterval: timeout + ) + + let data = data ?? NSNull() + let encoded = try serializer.encode(data) + let body = ["data": encoded] + let payload = try JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed]) + urlRequest.httpBody = payload + + // Set the headers for starting a streaming session. + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.setValue("text/event-stream", forHTTPHeaderField: "Accept") + urlRequest.httpMethod = "POST" + + if let authToken = context.authToken { + let value = "Bearer \(authToken)" + urlRequest.setValue(value, forHTTPHeaderField: "Authorization") + } + + if let fcmToken = context.fcmToken { + urlRequest.setValue(fcmToken, forHTTPHeaderField: Constants.fcmTokenHeader) + } + + if options?.requireLimitedUseAppCheckTokens == true { + if let appCheckToken = context.limitedUseAppCheckToken { + urlRequest.setValue( + appCheckToken, + forHTTPHeaderField: Constants.appCheckTokenHeader + ) + } + } else if let appCheckToken = context.appCheckToken { + urlRequest.setValue( + appCheckToken, + forHTTPHeaderField: Constants.appCheckTokenHeader + ) + } + + return urlRequest + } + private func makeFetcher(url: URL, data: Any?, options: HTTPSCallableOptions?, @@ -523,11 +718,14 @@ enum FunctionsConstants { return fetcher } - private func processedError(fromResponseError error: any Error) -> any Error { + private func processedError(fromResponseError error: any Error, + endpointURL url: URL) -> any Error { let error = error as NSError let localError: (any Error)? = if error.domain == kGTMSessionFetcherStatusDomain { FunctionsError( httpStatusCode: error.code, + region: region, + url: url, body: error.userInfo["data"] as? Data, serializer: serializer ) @@ -538,18 +736,23 @@ enum FunctionsConstants { return localError ?? error } - private func callableResult(fromResponseData data: Data) throws -> HTTPSCallableResult { - let processedData = try processedData(fromResponseData: data) + private func callableResult(fromResponseData data: Data, + endpointURL url: URL) throws -> HTTPSCallableResult { + let processedData = try processedData(fromResponseData: data, endpointURL: url) let json = try responseDataJSON(from: processedData) - // TODO: Refactor `decode(_:)` so it either returns a non-optional object or throws let payload = try serializer.decode(json) - // TODO: Remove `as Any` once `decode(_:)` is refactored - return HTTPSCallableResult(data: payload as Any) + return HTTPSCallableResult(data: payload) } - private func processedData(fromResponseData data: Data) throws -> Data { + private func processedData(fromResponseData data: Data, endpointURL url: URL) throws -> Data { // `data` might specify a custom error. If so, throw the error. - if let bodyError = FunctionsError(httpStatusCode: 200, body: data, serializer: serializer) { + if let bodyError = FunctionsError( + httpStatusCode: 200, + region: region, + url: url, + body: data, + serializer: serializer + ) { throw bodyError } diff --git a/FirebaseFunctions/Sources/FunctionsError.swift b/FirebaseFunctions/Sources/FunctionsError.swift index f8815b3ce60..34e896b63d4 100644 --- a/FirebaseFunctions/Sources/FunctionsError.swift +++ b/FirebaseFunctions/Sources/FunctionsError.swift @@ -180,7 +180,8 @@ struct FunctionsError: CustomNSError { /// } /// ``` /// - serializer: The `FunctionsSerializer` used to decode `details` in the error body. - init?(httpStatusCode: Int, body: Data?, serializer: FunctionsSerializer) { + init?(httpStatusCode: Int, region: String, url: URL, body: Data?, + serializer: FunctionsSerializer) { // Start with reasonable defaults from the status code. var code = FunctionsErrorCode(httpStatusCode: httpStatusCode) var description = Self.errorDescription(from: code) @@ -224,6 +225,8 @@ struct FunctionsError: CustomNSError { var userInfo = [String: Any]() userInfo[NSLocalizedDescriptionKey] = description + userInfo["region"] = region + userInfo["url"] = url if let details { userInfo[FunctionsErrorDetailsKey] = details } diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index c2281e54866..b423ac4195a 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -143,4 +143,9 @@ open class HTTPSCallable: NSObject { try await functions .callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval) } + + @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) + func stream(_ data: Any? = nil) -> AsyncThrowingStream { + functions.stream(at: url, data: data, options: options, timeout: timeoutInterval) + } } diff --git a/FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift b/FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift index d7f6ec5ad65..5fa9d4e3fe2 100644 --- a/FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift +++ b/FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift @@ -31,34 +31,29 @@ extension FunctionsSerializer { final class FunctionsSerializer { // MARK: - Internal APIs - func encode(_ object: Any) throws -> AnyObject { + func encode(_ object: Any) throws -> Any { if object is NSNull { - return object as AnyObject + return object } else if object is NSNumber { return try encodeNumber(object as! NSNumber) } else if object is NSString { - return object as AnyObject - } else if object is NSDictionary { - let dict = object as! NSDictionary + return object + } else if let dict = object as? NSDictionary { let encoded = NSMutableDictionary() try dict.forEach { key, value in encoded[key] = try encode(value) } return encoded - } else if object is NSArray { - let array = object as! NSArray - let encoded = NSMutableArray() - try array.forEach { element in - try encoded.add(encode(element)) + } else if let array = object as? NSArray { + return try array.map { element in + try encode(element) } - return encoded - } else { throw Error.unsupportedType(typeName: typeName(of: object)) } } - func decode(_ object: Any) throws -> AnyObject? { + func decode(_ object: Any) throws -> Any { // Return these types as is. PORTING NOTE: Moved from the bottom of the func for readability. if let dict = object as? NSDictionary { if let requestedType = dict["@type"] as? String { @@ -66,8 +61,9 @@ final class FunctionsSerializer { // Seems like we should throw here - but this maintains compatibility. return dict } - let result = try decodeWrappedType(requestedType, value) - if result != nil { return result } + if let result = try decodeWrappedType(requestedType, value) { + return result + } // Treat unknown types as dictionaries, so we don't crash old clients when we add types. } diff --git a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift index 5260bd10b2b..878cec1c9a0 100644 --- a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift +++ b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift @@ -65,6 +65,7 @@ struct DataTestResponse: Decodable, Equatable { var code: Int32 } +/// - Important: These tests require the emulator. Run `./FirebaseFunctions/Backend/start.sh` class IntegrationTests: XCTestCase { let functions = Functions(projectID: "functions-integration-test", region: "us-central1", @@ -868,6 +869,427 @@ class IntegrationTests: XCTestCase { } } +// MARK: - Streaming + +/// A convenience type used to represent that a callable function does not +/// accept parameters. +/// +/// This can be used as the generic `Request` parameter to ``Callable`` to +/// indicate the callable function does not accept parameters. +private struct EmptyRequest: Encodable {} + +@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) +extension IntegrationTests { + func testStream_NoArgs() async throws { + // 1. Custom `EmptyRequest` struct is passed as a placeholder generic arg. + let callable: Callable = functions.httpsCallable("genStream") + // 2. No request data is passed when creating stream. + let stream = try callable.stream() + var streamContents: [String] = [] + for try await response in stream { + streamContents.append(response) + } + XCTAssertEqual( + streamContents, + ["hello", "world", "this", "is", "cool"] + ) + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + func testStream_NoArgs_UeeNever() async throws { + let callable: Callable = functions.httpsCallable("genStream") + let stream = try callable.stream() + var streamContents: [String] = [] + for try await response in stream { + streamContents.append(response) + } + XCTAssertEqual( + streamContents, + ["hello", "world", "this", "is", "cool"] + ) + } + + func testStream_SimpleStreamResponse() async throws { + let callable: Callable> = functions + .httpsCallable("genStream") + let stream = try callable.stream() + var streamContents: [String] = [] + for try await response in stream { + switch response { + case let .message(message): + streamContents.append(message) + case let .result(result): + streamContents.append(result) + } + } + XCTAssertEqual( + streamContents, + ["hello", "world", "this", "is", "cool", "hello world this is cool"] + ) + } + + func testStream_CodableString() async throws { + let byName: Callable = functions.httpsCallable("genStream") + let stream = try byName.stream() + let result: [String] = try await stream.reduce([]) { $0 + [$1] } + XCTAssertEqual(result, ["hello", "world", "this", "is", "cool"]) + } + + private struct Location: Codable, Equatable { + let name: String + } + + private struct WeatherForecast: Decodable, Equatable { + enum Conditions: String, Decodable { + case sunny + case rainy + case snowy + } + + let location: Location + let temperature: Int + let conditions: Conditions + } + + private struct WeatherForecastReport: Decodable, Equatable { + let forecasts: [WeatherForecast] + } + + func testStream_CodableObject() async throws { + let callable: Callable<[Location], WeatherForecast> = functions + .httpsCallable("genStreamWeather") + let stream = try callable.stream([ + Location(name: "Toronto"), + Location(name: "London"), + Location(name: "Dubai"), + ]) + let result: [WeatherForecast] = try await stream.reduce([]) { $0 + [$1] } + XCTAssertEqual( + result, + [ + WeatherForecast(location: Location(name: "Toronto"), temperature: 25, conditions: .snowy), + WeatherForecast(location: Location(name: "London"), temperature: 50, conditions: .rainy), + WeatherForecast(location: Location(name: "Dubai"), temperature: 75, conditions: .sunny), + ] + ) + } + + func testStream_ResponseMessageDecodingFailure() async throws { + let callable: Callable<[Location], StreamResponse> = + functions + .httpsCallable("genStreamWeatherError") + let stream = try callable.stream([Location(name: "Toronto")]) + do { + for try await _ in stream { + XCTFail("Expected error to be thrown from stream.") + } + } catch let error as FunctionsError where error.code == .dataLoss { + XCTAssertNotNil(error.errorUserInfo[NSUnderlyingErrorKey] as? DecodingError) + } + } + + func testStream_ResponseResultDecodingFailure() async throws { + let callable: Callable<[Location], StreamResponse> = functions + .httpsCallable("genStreamWeather") + let stream = try callable.stream([Location(name: "Toronto")]) + do { + for try await response in stream { + if case .result = response { + XCTFail("Expected error to be thrown from stream.") + } + } + } catch let error as FunctionsError where error.code == .dataLoss { + XCTAssertNotNil(error.errorUserInfo[NSUnderlyingErrorKey] as? DecodingError) + } + } + + func testStream_ComplexStreamResponse() async throws { + let callable: Callable<[Location], StreamResponse> = + functions + .httpsCallable("genStreamWeather") + let stream = try callable.stream([ + Location(name: "Toronto"), + Location(name: "London"), + Location(name: "Dubai"), + ]) + var streamContents: [WeatherForecast] = [] + var streamResult: WeatherForecastReport? + for try await response in stream { + switch response { + case let .message(message): + streamContents.append(message) + case let .result(result): + streamResult = result + } + } + XCTAssertEqual( + streamContents, + [ + WeatherForecast(location: Location(name: "Toronto"), temperature: 25, conditions: .snowy), + WeatherForecast(location: Location(name: "London"), temperature: 50, conditions: .rainy), + WeatherForecast(location: Location(name: "Dubai"), temperature: 75, conditions: .sunny), + ] + ) + + try XCTAssertEqual( + XCTUnwrap(streamResult), WeatherForecastReport(forecasts: streamContents) + ) + } + + func testStream_ComplexStreamResponse_Functional() async throws { + let callable: Callable<[Location], StreamResponse> = + functions + .httpsCallable("genStreamWeather") + let stream = try callable.stream([ + Location(name: "Toronto"), + Location(name: "London"), + Location(name: "Dubai"), + ]) + let result: (accumulatedMessages: [WeatherForecast], result: WeatherForecastReport?) = + try await stream.reduce(([], nil)) { partialResult, streamResponse in + switch streamResponse { + case let .message(message): + (partialResult.accumulatedMessages + [message], partialResult.result) + case let .result(result): + (partialResult.accumulatedMessages, result) + } + } + XCTAssertEqual( + result.accumulatedMessages, + [ + WeatherForecast(location: Location(name: "Toronto"), temperature: 25, conditions: .snowy), + WeatherForecast(location: Location(name: "London"), temperature: 50, conditions: .rainy), + WeatherForecast(location: Location(name: "Dubai"), temperature: 75, conditions: .sunny), + ] + ) + + try XCTAssertEqual( + XCTUnwrap(result.result), WeatherForecastReport(forecasts: result.accumulatedMessages) + ) + } + + func testStream_Canceled() async throws { + let task = Task.detached { [self] in + let callable: Callable = functions.httpsCallable("genStream") + let stream = try callable.stream() + // Since we cancel the call we are expecting an empty array. + return try await stream.reduce([]) { $0 + [$1] } as [String] + } + // We cancel the task and we expect a null response even if the stream was initiated. + task.cancel() + let respone = try await task.value + XCTAssertEqual(respone, []) + } + + func testStream_NonexistentFunction() async throws { + let callable: Callable = functions.httpsCallable( + "nonexistentFunction" + ) + let stream = try callable.stream() + do { + for try await _ in stream { + XCTFail("Expected error to be thrown from stream.") + } + } catch let error as FunctionsError where error.code == .notFound { + XCTAssertEqual(error.localizedDescription, "NOT FOUND") + } + } + + func testStream_StreamError() async throws { + let callable: Callable = functions.httpsCallable("genStreamError") + let stream = try callable.stream() + do { + for try await _ in stream { + XCTFail("Expected error to be thrown from stream.") + } + } catch let error as FunctionsError where error.code == .internal { + XCTAssertEqual(error.localizedDescription, "INTERNAL") + } + } + + func testStream_RequestEncodingFailure() async throws { + struct Foo: Encodable { + enum CodingKeys: CodingKey {} + + func encode(to encoder: any Encoder) throws { + throw EncodingError + .invalidValue("", EncodingError.Context(codingPath: [], debugDescription: "")) + } + } + let callable: Callable = functions + .httpsCallable("genStream") + do { + _ = try callable.stream(Foo()) + } catch let error as FunctionsError where error.code == .invalidArgument { + _ = try XCTUnwrap(error.errorUserInfo[NSUnderlyingErrorKey] as? EncodingError) + } + } + + /// This tests an edge case to assert that if a custom `Response` is used + /// that matches the decoding logic of `StreamResponse`, the custom + /// `Response` does not decode successfully. + func testStream_ResultIsOnlyExposedInStreamResponse() async throws { + // The implementation is copied from `StreamResponse`. The only difference is the do-catch is + // removed from the decoding initializer. + enum MyStreamResponse: Decodable { + /// The message yielded by the callable function. + case message(Message) + /// The final result returned by the callable function. + case result(Result) + + private enum CodingKeys: String, CodingKey { + case message + case result + } + + public init(from decoder: any Decoder) throws { + let container = try decoder + .container(keyedBy: Self.CodingKeys.self) + var allKeys = ArraySlice(container.allKeys) + guard let onlyKey = allKeys.popFirst(), allKeys.isEmpty else { + throw DecodingError + .typeMismatch( + Self.self, + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Invalid number of keys found, expected one.", + underlyingError: nil + ) + ) + } + + switch onlyKey { + case .message: + self = try Self + .message(container.decode(Message.self, forKey: .message)) + case .result: + self = try Self + .result(container.decode(Result.self, forKey: .result)) + } + } + } + + let callable: Callable<[Location], MyStreamResponse> = + functions + .httpsCallable("genStreamWeather") + let stream = try callable.stream([Location(name: "Toronto")]) + do { + for try await _ in stream { + XCTFail("Expected error to be thrown from stream.") + } + } catch let error as FunctionsError where error.code == .dataLoss { + XCTAssertNotNil(error.errorUserInfo[NSUnderlyingErrorKey] as? DecodingError) + } + } + + func testStream_ForNonStreamingCF3() async throws { + let callable: Callable = functions.httpsCallable("scalarTest") + let stream = try callable.stream(17) + do { + for try await _ in stream { + XCTFail("Expected error to be thrown from stream.") + } + } catch let error as FunctionsError where error.code == .dataLoss { + XCTAssertEqual(error.localizedDescription, "Unexpected format for streamed response.") + } + } + + func testStream_EmptyStream() async throws { + let callable: Callable = functions.httpsCallable("genStreamEmpty") + var streamContents: [String] = [] + for try await response in try callable.stream() { + streamContents.append(response) + } + XCTAssertEqual(streamContents, []) + } + + func testStream_ResultOnly() async throws { + let callable: Callable = functions.httpsCallable("genStreamResultOnly") + let stream = try callable.stream() + for try await _ in stream { + // The stream should not yield anything, so this should not be reached. + XCTFail("Stream should not yield any messages") + } + // Because StreamResponse was not used, the result is not accessible, + // but the message should not throw. + } + + func testStream_ResultOnly_StreamResponse() async throws { + struct EmptyResponse: Decodable {} + let callable: Callable> = functions + .httpsCallable( + "genStreamResultOnly" + ) + let stream = try callable.stream() + var streamResult = "" + for try await response in stream { + switch response { + case .message: + XCTFail("Stream should not yield any messages") + case let .result(result): + streamResult = result + } + } + // The hardcoded string matches the CF3's return value. + XCTAssertEqual(streamResult, "Only a result") + } + + func testStream_UnexpectedType() async throws { + // This function yields strings, not integers. + let callable: Callable = functions.httpsCallable("genStream") + let stream = try callable.stream() + do { + for try await _ in stream { + XCTFail("Expected error to be thrown from stream.") + } + } catch let error as FunctionsError where error.code == .dataLoss { + XCTAssertNotNil(error.errorUserInfo[NSUnderlyingErrorKey] as? DecodingError) + } + } + + func testStream_Timeout() async throws { + var callable: Callable = functions.httpsCallable("timeoutTest") + // Set a short timeout + callable.timeoutInterval = 0.01 // 10 milliseconds + + let stream = try callable.stream() + + do { + for try await _ in stream { + XCTFail("Expected error to be thrown from stream.") + } + } catch let error as FunctionsError where error.code == .unavailable { + // This should be a timeout error. + XCTAssertEqual( + error.localizedDescription, + "The operation couldn’t be completed. (com.firebase.functions error 14.)" + ) + XCTAssertNotNil(error.errorUserInfo[NSUnderlyingErrorKey] as? URLError) + } + } + + func testStream_LargeData() async throws { + func generateLargeString() -> String { + var largeString = "" + for _ in 0 ..< 10000 { + largeString += "A" + } + return largeString + } + let callable: Callable = functions.httpsCallable("genStreamLargeData") + let stream = try callable.stream() + var concatenatedData = "" + for try await response in stream { + concatenatedData += response + } + // Assert that the concatenated data matches the expected large data. + XCTAssertEqual(concatenatedData, generateLargeString()) + } +} + +// MARK: - Helpers + private class AuthTokenProvider: AuthInterop { func getUserID() -> String? { return "fake user" diff --git a/FirebaseFunctions/Tests/Unit/FunctionsErrorTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsErrorTests.swift index 99b4c8334b3..5288097aeaa 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsErrorTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsErrorTests.swift @@ -45,6 +45,8 @@ final class FunctionsErrorTests: XCTestCase { // The error should be `nil`. let error = FunctionsError( httpStatusCode: 200, + region: "my-region", + url: URL(string: "https://example.com/fake_func")!, body: nil, serializer: FunctionsSerializer() ) @@ -56,6 +58,8 @@ final class FunctionsErrorTests: XCTestCase { // The error should be inferred from the HTTP status code. let error = FunctionsError( httpStatusCode: 429, + region: "my-region", + url: URL(string: "https://example.com/fake_func")!, body: nil, serializer: FunctionsSerializer() ) @@ -66,7 +70,7 @@ final class FunctionsErrorTests: XCTestCase { XCTAssertEqual(nsError.domain, "com.firebase.functions") XCTAssertEqual(nsError.code, 8) XCTAssertEqual(nsError.localizedDescription, "RESOURCE EXHAUSTED") - XCTAssertEqual(nsError.userInfo.count, 1) + XCTAssertEqual(nsError.userInfo.count, 3) } func testInitWithOKStatusCodeAndIncompleteErrorBody() { @@ -75,6 +79,8 @@ final class FunctionsErrorTests: XCTestCase { let error = FunctionsError( httpStatusCode: 200, + region: "my-region", + url: URL(string: "https://example.com/fake_func")!, body: responseData, serializer: FunctionsSerializer() ) @@ -85,7 +91,7 @@ final class FunctionsErrorTests: XCTestCase { XCTAssertEqual(nsError.domain, "com.firebase.functions") XCTAssertEqual(nsError.code, 11) XCTAssertEqual(nsError.localizedDescription, "OUT OF RANGE") - XCTAssertEqual(nsError.userInfo.count, 1) + XCTAssertEqual(nsError.userInfo.count, 3) } func testInitWithErrorStatusCodeAndErrorBody() { @@ -96,6 +102,8 @@ final class FunctionsErrorTests: XCTestCase { let error = FunctionsError( httpStatusCode: 499, + region: "my-region", + url: URL(string: "https://example.com/fake_func")!, body: responseData, serializer: FunctionsSerializer() ) @@ -106,7 +114,7 @@ final class FunctionsErrorTests: XCTestCase { XCTAssertEqual(nsError.domain, "com.firebase.functions") XCTAssertEqual(nsError.code, 11) XCTAssertEqual(nsError.localizedDescription, "TEST_ErrorMessage") - XCTAssertEqual(nsError.userInfo.count, 2) + XCTAssertEqual(nsError.userInfo.count, 4) XCTAssertEqual(nsError.userInfo["details"] as? Int, 123) } @@ -119,6 +127,8 @@ final class FunctionsErrorTests: XCTestCase { let error = FunctionsError( httpStatusCode: 401, + region: "my-region", + url: URL(string: "https://example.com/fake_func")!, body: responseData, serializer: FunctionsSerializer() ) @@ -133,6 +143,8 @@ final class FunctionsErrorTests: XCTestCase { let error = FunctionsError( httpStatusCode: 403, + region: "my-region", + url: URL(string: "https://example.com/fake_func")!, body: responseData, serializer: FunctionsSerializer() ) @@ -143,7 +155,7 @@ final class FunctionsErrorTests: XCTestCase { XCTAssertEqual(nsError.domain, "com.firebase.functions") XCTAssertEqual(nsError.code, 7) // `permissionDenied`, inferred from the HTTP status code XCTAssertEqual(nsError.localizedDescription, "TEST_ErrorMessage") - XCTAssertEqual(nsError.userInfo.count, 2) + XCTAssertEqual(nsError.userInfo.count, 4) XCTAssertEqual(nsError.userInfo["details"] as? NSNull, NSNull()) } @@ -155,6 +167,8 @@ final class FunctionsErrorTests: XCTestCase { let error = FunctionsError( httpStatusCode: 503, + region: "my-region", + url: URL(string: "https://example.com/fake_func")!, body: responseData, serializer: FunctionsSerializer() ) diff --git a/FirebaseInAppMessaging.podspec b/FirebaseInAppMessaging.podspec index 2cad4e8360f..b260d106c29 100644 --- a/FirebaseInAppMessaging.podspec +++ b/FirebaseInAppMessaging.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseInAppMessaging' - s.version = '11.9.0-beta' + s.version = '11.10.0-beta' s.summary = 'Firebase In-App Messaging for iOS' s.description = <<-DESC @@ -80,7 +80,7 @@ See more product details at https://firebase.google.com/products/in-app-messagin s.framework = 'UIKit' - s.dependency 'FirebaseCore', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' s.dependency 'FirebaseInstallations', '~> 11.0' s.dependency 'FirebaseABTesting', '~> 11.0' s.dependency 'GoogleUtilities/Environment', '~> 8.0' diff --git a/FirebaseInAppMessaging/Sources/Analytics/FIRIAMAnalyticsEventLoggerImpl.m b/FirebaseInAppMessaging/Sources/Analytics/FIRIAMAnalyticsEventLoggerImpl.m index f4f8a8bc577..ac7458ad0a5 100644 --- a/FirebaseInAppMessaging/Sources/Analytics/FIRIAMAnalyticsEventLoggerImpl.m +++ b/FirebaseInAppMessaging/Sources/Analytics/FIRIAMAnalyticsEventLoggerImpl.m @@ -51,11 +51,6 @@ @interface FIRIAMAnalyticsEventLoggerImpl () static NSString *const kFAUserPropertyForLastNotification = @"_ln"; static NSString *const kFAUserPropertyPrefixForFIAM = @"fiam:"; -// This user defaults key is for the entry to tell when we should remove the private user -// property from a prior action url click to stop conversion attribution for a campaign -static NSString *const kFIAMUserDefaualtsKeyForRemoveUserPropertyTimeInSeconds = - @"firebase-iam-conversion-tracking-expires-in-seconds"; - @implementation FIRIAMAnalyticsEventLoggerImpl { id _analytics; } diff --git a/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutUploader.m b/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutUploader.m index a1a0646f5f3..6a4fad08089 100644 --- a/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutUploader.m +++ b/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutUploader.m @@ -109,7 +109,7 @@ - (instancetype)initWithRequestSender:(FIRIAMClearcutHttpRequestSender *)request object:nil]; } _userDefaults = userDefaults ? userDefaults : [GULUserDefaults standardUserDefaults]; - // it would be 0 if it does not exist, which is equvilent to saying that + // it would be 0 if it does not exist, which is equivalent to saying that // you can send now _nextValidSendTimeInMills = (int64_t)[_userDefaults doubleForKey:FIRIAM_UserDefaultsKeyForNextValidClearcutUploadTimeInMills]; diff --git a/FirebaseInstallations.podspec b/FirebaseInstallations.podspec index 5ed6bda5eb8..2288f15dbdd 100644 --- a/FirebaseInstallations.podspec +++ b/FirebaseInstallations.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseInstallations' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Firebase Installations' s.description = <<-DESC @@ -45,7 +45,7 @@ Pod::Spec.new do |s| } s.framework = 'Security' - s.dependency 'FirebaseCore', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' s.dependency 'PromisesObjC', '~> 2.4' s.dependency 'GoogleUtilities/Environment', '~> 8.0' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.0' diff --git a/FirebaseMLModelDownloader.podspec b/FirebaseMLModelDownloader.podspec index 1b5fd7a8eba..e45316db820 100644 --- a/FirebaseMLModelDownloader.podspec +++ b/FirebaseMLModelDownloader.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseMLModelDownloader' - s.version = '11.9.0-beta' + s.version = '11.10.0-beta' s.summary = 'Firebase ML Model Downloader' s.description = <<-DESC @@ -36,8 +36,8 @@ Pod::Spec.new do |s| ] s.framework = 'Foundation' - s.dependency 'FirebaseCore', '~> 11.9.0' - s.dependency 'FirebaseCoreExtension', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' + s.dependency 'FirebaseCoreExtension', '~> 11.10.0' s.dependency 'FirebaseInstallations', '~> 11.0' s.dependency 'GoogleDataTransport', '~> 10.0' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.0' diff --git a/FirebaseMessaging.podspec b/FirebaseMessaging.podspec index 9ed8d682690..4e1dcca5821 100644 --- a/FirebaseMessaging.podspec +++ b/FirebaseMessaging.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseMessaging' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Firebase Messaging' s.description = <<-DESC @@ -62,7 +62,7 @@ device, and it is completely free. s.osx.framework = 'SystemConfiguration' s.weak_framework = 'UserNotifications' s.dependency 'FirebaseInstallations', '~> 11.0' - s.dependency 'FirebaseCore', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.0' s.dependency 'GoogleUtilities/Reachability', '~> 8.0' s.dependency 'GoogleUtilities/Environment', '~> 8.0' diff --git a/FirebaseMessaging/Sources/Public/FirebaseMessaging/FIRMessaging.h b/FirebaseMessaging/Sources/Public/FirebaseMessaging/FIRMessaging.h index f8282d30b30..fded673f5fb 100644 --- a/FirebaseMessaging/Sources/Public/FirebaseMessaging/FIRMessaging.h +++ b/FirebaseMessaging/Sources/Public/FirebaseMessaging/FIRMessaging.h @@ -158,7 +158,7 @@ NS_SWIFT_NAME(MessagingDelegate) @end /** - * Firebase Messaging lets you reliably deliver messages at no cost. + * Firebase Messaging lets you reliably deliver messages. * * To send or receive messages, the app must get a * registration token. This token authorizes an diff --git a/FirebaseMessaging/Tests/UnitTestsSwift/FIRMessagingAPITest.swift b/FirebaseMessaging/Tests/UnitTestsSwift/FIRMessagingAPITest.swift index 66441a37ae9..8705e09a07a 100644 --- a/FirebaseMessaging/Tests/UnitTestsSwift/FIRMessagingAPITest.swift +++ b/FirebaseMessaging/Tests/UnitTestsSwift/FIRMessagingAPITest.swift @@ -85,7 +85,7 @@ func apis() { @unknown default: () } - // TODO: Mark the initializer as unavialable, as devs shouldn't be able to instantiate this. + // TODO: Mark the initializer as unavailable, as devs shouldn't be able to instantiate this. _ = MessagingMessageInfo().status NotificationCenter.default.post(name: .MessagingRegistrationTokenRefreshed, object: nil) diff --git a/FirebaseMessagingInterop.podspec b/FirebaseMessagingInterop.podspec index e0f7d6c981f..9d7f3465ba9 100644 --- a/FirebaseMessagingInterop.podspec +++ b/FirebaseMessagingInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseMessagingInterop' - s.version = '11.9.0' + s.version = '11.10.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 2dcbccbb9aa..4620b245cde 100644 --- a/FirebasePerformance.podspec +++ b/FirebasePerformance.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebasePerformance' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Firebase Performance' s.description = <<-DESC @@ -59,7 +59,7 @@ Firebase Performance library to measure performance of Mobile and Web Apps. s.ios.framework = 'CoreTelephony' s.framework = 'QuartzCore' s.framework = 'SystemConfiguration' - s.dependency 'FirebaseCore', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' s.dependency 'FirebaseInstallations', '~> 11.0' s.dependency 'FirebaseRemoteConfig', '~> 11.0' s.dependency 'FirebaseSessions', '~> 11.0' diff --git a/FirebaseRemoteConfig.podspec b/FirebaseRemoteConfig.podspec index b68200fb7c2..ed3069901c5 100644 --- a/FirebaseRemoteConfig.podspec +++ b/FirebaseRemoteConfig.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseRemoteConfig' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Firebase Remote Config' s.description = <<-DESC @@ -52,7 +52,7 @@ app update. } s.dependency 'FirebaseABTesting', '~> 11.0' s.dependency 'FirebaseSharedSwift', '~> 11.0' - s.dependency 'FirebaseCore', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' s.dependency 'FirebaseInstallations', '~> 11.0' s.dependency 'GoogleUtilities/Environment', '~> 8.0' s.dependency 'GoogleUtilities/NSData+zlib', '~> 8.0' diff --git a/FirebaseRemoteConfig/CHANGELOG.md b/FirebaseRemoteConfig/CHANGELOG.md index b90b6cc2871..b1d16a9c1ae 100644 --- a/FirebaseRemoteConfig/CHANGELOG.md +++ b/FirebaseRemoteConfig/CHANGELOG.md @@ -1,3 +1,6 @@ +# 11.10.0 +- [fixed] Fix intermittent `RCNConfigRealtime` crash due to incorrect parsing of fragmented JSON. (#14518) + # 11.9.0 - [fixed] Mark internal `fetchSession` property as `atomic` to prevent a concurrency related crash. (#14449) diff --git a/FirebaseRemoteConfig/Sources/RCNConfigRealtime.m b/FirebaseRemoteConfig/Sources/RCNConfigRealtime.m index f7f5d1e44a1..ddc6f21ccec 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigRealtime.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigRealtime.m @@ -573,20 +573,27 @@ - (void)URLSession:(NSURLSession *)session return; } - NSRange endRange = [strData rangeOfString:@"}"]; NSRange beginRange = [strData rangeOfString:@"{"]; - if (beginRange.location != NSNotFound && endRange.location != NSNotFound) { - FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000015", - @"Received config update message on stream."); - NSRange msgRange = - NSMakeRange(beginRange.location, endRange.location - beginRange.location + 1); - strData = [strData substringWithRange:msgRange]; - data = [strData dataUsingEncoding:NSUTF8StringEncoding]; - NSDictionary *response = [NSJSONSerialization JSONObjectWithData:data - options:NSJSONReadingMutableContainers - error:&dataError]; - - [self evaluateStreamResponse:response error:dataError]; + if (beginRange.location != NSNotFound) { + NSRange endRange = + [strData rangeOfString:@"}" + options:0 + range:NSMakeRange(beginRange.location + 1, + strData.length - beginRange.location - 1)]; + if (endRange.location != NSNotFound) { + FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000015", + @"Received config update message on stream."); + NSRange msgRange = + NSMakeRange(beginRange.location, endRange.location - beginRange.location + 1); + strData = [strData substringWithRange:msgRange]; + data = [strData dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *response = + [NSJSONSerialization JSONObjectWithData:data + options:NSJSONReadingMutableContainers + error:&dataError]; + + [self evaluateStreamResponse:response error:dataError]; + } } } diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m b/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m index 6c96c1a7dbe..1021d7014d1 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m @@ -1834,6 +1834,31 @@ - (void)testFetchAndActivateRolloutsNotifyInterop { [self waitForExpectations:@[ notificationExpectation ] timeout:_expectationTimeout]; } +- (void)testURLSessionDelegateHandlesChunkedJSON { + NSString *testString = @"} {\"testKey\":\"testValue\"}"; + NSData *testData = [testString dataUsingEncoding:NSUTF8StringEncoding]; + + NSMutableArray *expectations = + [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances]; + for (int i = 0; i < RCNTestRCNumTotalInstances; i++) { + expectations[i] = [self + expectationWithDescription: + [NSString + stringWithFormat:@"Test delegate method handling chunked JSON - instance %d", i]]; + + NSURLSession *networkSession = [_configFetch[i] currentNetworkSession]; + NSURLSessionDataTask *dataTask = [_configFetch[i] URLSessionDataTaskWithContent:[OCMArg any] + fetchTypeHeader:[OCMArg any] + completionHandler:nil]; + + XCTAssertNoThrow([_configRealtime[i] URLSession:networkSession + dataTask:dataTask + didReceiveData:testData]); + [expectations[i] fulfill]; + } + [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil]; +} + - (void)testSetCustomSignals { NSMutableArray *expectations = [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances]; diff --git a/FirebaseRemoteConfigInterop.podspec b/FirebaseRemoteConfigInterop.podspec index 3d79e275393..6c8fe49d690 100644 --- a/FirebaseRemoteConfigInterop.podspec +++ b/FirebaseRemoteConfigInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseRemoteConfigInterop' - s.version = '11.9.0' + s.version = '11.10.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 817be12fae8..152e3094eda 100644 --- a/FirebaseSessions.podspec +++ b/FirebaseSessions.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseSessions' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Firebase Sessions' s.description = <<-DESC @@ -39,8 +39,8 @@ Pod::Spec.new do |s| base_dir + 'SourcesObjC/**/*.{c,h,m,mm}', ] - s.dependency 'FirebaseCore', '~> 11.9.0' - s.dependency 'FirebaseCoreExtension', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' + s.dependency 'FirebaseCoreExtension', '~> 11.10.0' s.dependency 'FirebaseInstallations', '~> 11.0' s.dependency 'GoogleDataTransport', '~> 10.0' s.dependency 'GoogleUtilities/Environment', '~> 8.0' diff --git a/FirebaseSharedSwift.podspec b/FirebaseSharedSwift.podspec index 020f18aa7b4..6e1b3e544a5 100644 --- a/FirebaseSharedSwift.podspec +++ b/FirebaseSharedSwift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseSharedSwift' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Shared Swift Extensions for Firebase' s.description = <<-DESC diff --git a/FirebaseSharedSwift/Sources/third_party/FirebaseDataEncoder/FirebaseDataEncoder.swift b/FirebaseSharedSwift/Sources/third_party/FirebaseDataEncoder/FirebaseDataEncoder.swift index e411e796507..c783718c50d 100644 --- a/FirebaseSharedSwift/Sources/third_party/FirebaseDataEncoder/FirebaseDataEncoder.swift +++ b/FirebaseSharedSwift/Sources/third_party/FirebaseDataEncoder/FirebaseDataEncoder.swift @@ -2611,11 +2611,19 @@ fileprivate struct _JSONKey : CodingKey { //===----------------------------------------------------------------------===// // NOTE: This value is implicitly lazy and _must_ be lazy. We're compiled against the latest SDK (w/ ISO8601DateFormatter), but linked against whichever Foundation the user has. ISO8601DateFormatter might not exist, so we better not hit this code path on an older OS. +#if compiler(>=6) +nonisolated(unsafe) fileprivate var _iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = .withInternetDateTime + return formatter +}() +#else fileprivate var _iso8601Formatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = .withInternetDateTime return formatter }() +#endif //===----------------------------------------------------------------------===// // Error Utilities diff --git a/FirebaseStorage.podspec b/FirebaseStorage.podspec index 4f1f3fd0fd3..70b4f66ff28 100644 --- a/FirebaseStorage.podspec +++ b/FirebaseStorage.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseStorage' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Firebase Storage' s.description = <<-DESC @@ -39,8 +39,8 @@ Firebase Storage provides robust, secure file uploads and downloads from Firebas s.dependency 'FirebaseAppCheckInterop', '~> 11.0' s.dependency 'FirebaseAuthInterop', '~> 11.0' - s.dependency 'FirebaseCore', '~> 11.9.0' - s.dependency 'FirebaseCoreExtension', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' + s.dependency 'FirebaseCoreExtension', '~> 11.10.0' s.dependency 'GTMSessionFetcher/Core', '>= 3.4', '< 5.0' s.dependency 'GoogleUtilities/Environment', '~> 8.0' diff --git a/FirebaseVertexAI.podspec b/FirebaseVertexAI.podspec index 92256eba00d..f435ff4b9bf 100644 --- a/FirebaseVertexAI.podspec +++ b/FirebaseVertexAI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseVertexAI' - s.version = '11.9.0' + s.version = '11.10.0' s.summary = 'Vertex AI in Firebase SDK' s.description = <<-DESC @@ -46,8 +46,8 @@ Firebase SDK. s.dependency 'FirebaseAppCheckInterop', '~> 11.4' s.dependency 'FirebaseAuthInterop', '~> 11.4' - s.dependency 'FirebaseCore', '~> 11.9.0' - s.dependency 'FirebaseCoreExtension', '~> 11.9.0' + s.dependency 'FirebaseCore', '~> 11.10.0' + s.dependency 'FirebaseCoreExtension', '~> 11.10.0' s.test_spec 'unit' do |unit_tests| unit_tests_dir = 'FirebaseVertexAI/Tests/Unit/' diff --git a/FirebaseVertexAI/CHANGELOG.md b/FirebaseVertexAI/CHANGELOG.md index 383651f4fb8..2c84aaaafa3 100644 --- a/FirebaseVertexAI/CHANGELOG.md +++ b/FirebaseVertexAI/CHANGELOG.md @@ -1,3 +1,11 @@ +# 11.10.0 +- [feature] The Vertex AI SDK no longer requires `@preconcurrency` when imported in Swift 6. +- [feature] The Vertex AI Sample App now includes an image generation example. +- [changed] The Vertex AI Sample App is now part of the + [quickstart-ios repo](https://github.com/firebase/quickstart-ios/tree/main/vertexai). +- [changed] The `role` in system instructions is now ignored; no code changes + are required. (#14558) + # 11.9.0 - [feature] **Public Preview**: Added support for generating images using the Imagen 3 model. @@ -137,7 +145,7 @@ instead of the `Any` type. (#13575) - [added] Added support for specifying a JSON `responseSchema` in `GenerationConfig`; see - [control generated output](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/control-generated-output) + [control generated output](https://firebase.google.com/docs/vertex-ai/structured-output?platform=ios) for more details. (#13576) # 10.29.0 diff --git a/FirebaseVertexAI/README.md b/FirebaseVertexAI/README.md index 84182e15ad9..0392f86e996 100644 --- a/FirebaseVertexAI/README.md +++ b/FirebaseVertexAI/README.md @@ -1,7 +1,7 @@ # Vertex AI for Firebase SDK - For developer documentation, please visit https://firebase.google.com/docs/vertex-ai. -- Try out the [sample app](Sample/README.md) to get started. +- Try out the [sample app](https://github.com/firebase/quickstart-ios/tree/main/vertexai to get started. ## Development diff --git a/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/AccentColor.colorset/Contents.json b/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb878970081..00000000000 --- a/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/AppIcon.appiconset/Contents.json b/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 13613e3ee1a..00000000000 --- a/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/Contents.json b/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a7f..00000000000 --- a/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/FirebaseVertexAI/Sample/ChatSample/Models/ChatMessage.swift b/FirebaseVertexAI/Sample/ChatSample/Models/ChatMessage.swift deleted file mode 100644 index 6f7ab321b12..00000000000 --- a/FirebaseVertexAI/Sample/ChatSample/Models/ChatMessage.swift +++ /dev/null @@ -1,64 +0,0 @@ -// 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 Foundation - -enum Participant { - case system - case user -} - -struct ChatMessage: Identifiable, Equatable { - let id = UUID().uuidString - var message: String - let participant: Participant - var pending = false - - static func pending(participant: Participant) -> ChatMessage { - Self(message: "", participant: participant, pending: true) - } -} - -extension ChatMessage { - static var samples: [ChatMessage] = [ - .init(message: "Hello. What can I do for you today?", participant: .system), - .init(message: "Show me a simple loop in Swift.", participant: .user), - .init(message: """ - Sure, here is a simple loop in Swift: - - # Example 1 - ``` - for i in 1...5 { - print("Hello, world!") - } - ``` - - This loop will print the string "Hello, world!" five times. The for loop iterates over a range of numbers, - in this case the numbers from 1 to 5. The variable i is assigned each number in the range, and the code inside the loop is executed. - - **Here is another example of a simple loop in Swift:** - ```swift - var sum = 0 - for i in 1...100 { - sum += i - } - print("The sum of the numbers from 1 to 100 is \\(sum).") - ``` - - This loop calculates the sum of the numbers from 1 to 100. The variable sum is initialized to 0, and then the for loop iterates over the range of numbers from 1 to 100. The variable i is assigned each number in the range, and the value of i is added to the sum variable. After the loop has finished executing, the value of sum is printed to the console. - """, participant: .system), - ] - - static var sample = samples[0] -} diff --git a/FirebaseVertexAI/Sample/ChatSample/Preview Content/Preview Assets.xcassets/Contents.json b/FirebaseVertexAI/Sample/ChatSample/Preview Content/Preview Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a7f..00000000000 --- a/FirebaseVertexAI/Sample/ChatSample/Preview Content/Preview Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/FirebaseVertexAI/Sample/ChatSample/Screens/ConversationScreen.swift b/FirebaseVertexAI/Sample/ChatSample/Screens/ConversationScreen.swift deleted file mode 100644 index 78c903e3412..00000000000 --- a/FirebaseVertexAI/Sample/ChatSample/Screens/ConversationScreen.swift +++ /dev/null @@ -1,129 +0,0 @@ -// 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 FirebaseVertexAI -import GenerativeAIUIComponents -import SwiftUI - -struct ConversationScreen: View { - @EnvironmentObject - var viewModel: ConversationViewModel - - @State - private var userPrompt = "" - - enum FocusedField: Hashable { - case message - } - - @FocusState - var focusedField: FocusedField? - - var body: some View { - VStack { - ScrollViewReader { scrollViewProxy in - List { - ForEach(viewModel.messages) { message in - MessageView(message: message) - } - if let error = viewModel.error { - ErrorView(error: error) - .tag("errorView") - } - } - .listStyle(.plain) - .onChange(of: viewModel.messages, perform: { newValue in - if viewModel.hasError { - // wait for a short moment to make sure we can actually scroll to the bottom - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - withAnimation { - scrollViewProxy.scrollTo("errorView", anchor: .bottom) - } - focusedField = .message - } - } else { - guard let lastMessage = viewModel.messages.last else { return } - - // wait for a short moment to make sure we can actually scroll to the bottom - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - withAnimation { - scrollViewProxy.scrollTo(lastMessage.id, anchor: .bottom) - } - focusedField = .message - } - } - }) - } - InputField("Message...", text: $userPrompt) { - Image(systemName: viewModel.busy ? "stop.circle.fill" : "arrow.up.circle.fill") - .font(.title) - } - .focused($focusedField, equals: .message) - .onSubmit { sendOrStop() } - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(action: newChat) { - Image(systemName: "square.and.pencil") - } - } - } - .navigationTitle("Chat sample") - .onAppear { - focusedField = .message - } - } - - private func sendMessage() { - Task { - let prompt = userPrompt - userPrompt = "" - await viewModel.sendMessage(prompt, streaming: true) - } - } - - private func sendOrStop() { - focusedField = nil - - if viewModel.busy { - viewModel.stop() - } else { - sendMessage() - } - } - - private func newChat() { - viewModel.startNewChat() - } -} - -struct ConversationScreen_Previews: PreviewProvider { - struct ContainerView: View { - @StateObject var viewModel = ConversationViewModel() - - var body: some View { - ConversationScreen() - .environmentObject(viewModel) - .onAppear { - viewModel.messages = ChatMessage.samples - } - } - } - - static var previews: some View { - NavigationStack { - ConversationScreen() - } - } -} diff --git a/FirebaseVertexAI/Sample/ChatSample/ViewModels/ConversationViewModel.swift b/FirebaseVertexAI/Sample/ChatSample/ViewModels/ConversationViewModel.swift deleted file mode 100644 index a2c8305c8b4..00000000000 --- a/FirebaseVertexAI/Sample/ChatSample/ViewModels/ConversationViewModel.swift +++ /dev/null @@ -1,130 +0,0 @@ -// 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 FirebaseVertexAI -import Foundation -import UIKit - -@MainActor -class ConversationViewModel: ObservableObject { - /// This array holds both the user's and the system's chat messages - @Published var messages = [ChatMessage]() - - /// Indicates we're waiting for the model to finish - @Published var busy = false - - @Published var error: Error? - var hasError: Bool { - return error != nil - } - - private var model: GenerativeModel - private var chat: Chat - private var stopGenerating = false - - private var chatTask: Task? - - init() { - model = VertexAI.vertexAI().generativeModel(modelName: "gemini-2.0-flash-001") - chat = model.startChat() - } - - func sendMessage(_ text: String, streaming: Bool = true) async { - error = nil - if streaming { - await internalSendMessageStreaming(text) - } else { - await internalSendMessage(text) - } - } - - func startNewChat() { - stop() - error = nil - chat = model.startChat() - messages.removeAll() - } - - func stop() { - chatTask?.cancel() - error = nil - } - - private func internalSendMessageStreaming(_ text: String) async { - chatTask?.cancel() - - chatTask = Task { - busy = true - defer { - busy = false - } - - // first, add the user's message to the chat - let userMessage = ChatMessage(message: text, participant: .user) - messages.append(userMessage) - - // add a pending message while we're waiting for a response from the backend - let systemMessage = ChatMessage.pending(participant: .system) - messages.append(systemMessage) - - do { - let responseStream = try chat.sendMessageStream(text) - for try await chunk in responseStream { - messages[messages.count - 1].pending = false - if let text = chunk.text { - messages[messages.count - 1].message += text - } - } - } catch { - self.error = error - print(error.localizedDescription) - messages.removeLast() - } - } - } - - private func internalSendMessage(_ text: String) async { - chatTask?.cancel() - - chatTask = Task { - busy = true - defer { - busy = false - } - - // first, add the user's message to the chat - let userMessage = ChatMessage(message: text, participant: .user) - messages.append(userMessage) - - // add a pending message while we're waiting for a response from the backend - let systemMessage = ChatMessage.pending(participant: .system) - messages.append(systemMessage) - - do { - var response: GenerateContentResponse? - response = try await chat.sendMessage(text) - - if let responseText = response?.text { - // replace pending message with backend response - messages[messages.count - 1].message = responseText - messages[messages.count - 1].pending = false - } - } catch { - self.error = error - print(error.localizedDescription) - messages.removeLast() - } - } - } -} diff --git a/FirebaseVertexAI/Sample/ChatSample/Views/BouncingDots.swift b/FirebaseVertexAI/Sample/ChatSample/Views/BouncingDots.swift deleted file mode 100644 index 6895e6723da..00000000000 --- a/FirebaseVertexAI/Sample/ChatSample/Views/BouncingDots.swift +++ /dev/null @@ -1,77 +0,0 @@ -// 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 SwiftUI - -struct BouncingDots: View { - @State - private var dot1YOffset: CGFloat = 0.0 - - @State - private var dot2YOffset: CGFloat = 0.0 - - @State - private var dot3YOffset: CGFloat = 0.0 - - let animation = Animation.easeInOut(duration: 0.8) - .repeatForever(autoreverses: true) - - var body: some View { - HStack(spacing: 8) { - Circle() - .fill(Color.white) - .frame(width: 10, height: 10) - .offset(y: dot1YOffset) - .onAppear { - withAnimation(self.animation.delay(0.0)) { - self.dot1YOffset = -5 - } - } - Circle() - .fill(Color.white) - .frame(width: 10, height: 10) - .offset(y: dot2YOffset) - .onAppear { - withAnimation(self.animation.delay(0.2)) { - self.dot2YOffset = -5 - } - } - Circle() - .fill(Color.white) - .frame(width: 10, height: 10) - .offset(y: dot3YOffset) - .onAppear { - withAnimation(self.animation.delay(0.4)) { - self.dot3YOffset = -5 - } - } - } - .onAppear { - let baseOffset: CGFloat = -2 - - self.dot1YOffset = baseOffset - self.dot2YOffset = baseOffset - self.dot3YOffset = baseOffset - } - } -} - -struct BouncingDots_Previews: PreviewProvider { - static var previews: some View { - BouncingDots() - .frame(width: 200, height: 50) - .background(.blue) - .roundedCorner(10, corners: [.allCorners]) - } -} diff --git a/FirebaseVertexAI/Sample/ChatSample/Views/ErrorDetailsView.swift b/FirebaseVertexAI/Sample/ChatSample/Views/ErrorDetailsView.swift deleted file mode 100644 index 38c4ed0c410..00000000000 --- a/FirebaseVertexAI/Sample/ChatSample/Views/ErrorDetailsView.swift +++ /dev/null @@ -1,260 +0,0 @@ -// 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 FirebaseVertexAI -import MarkdownUI -import SwiftUI - -private extension HarmCategory { - /// Returns a description of the `HarmCategory` suitable for displaying in the UI. - var displayValue: String { - switch self { - case .dangerousContent: "Dangerous content" - case .harassment: "Harassment" - case .hateSpeech: "Hate speech" - case .sexuallyExplicit: "Sexually explicit" - case .civicIntegrity: "Civic integrity" - default: "Unknown HarmCategory: \(rawValue)" - } - } -} - -private extension SafetyRating.HarmProbability { - /// Returns a description of the `HarmProbability` suitable for displaying in the UI. - var displayValue: String { - switch self { - case .high: "High" - case .low: "Low" - case .medium: "Medium" - case .negligible: "Negligible" - default: "Unknown HarmProbability: \(rawValue)" - } - } -} - -private struct SubtitleFormRow: View { - var title: String - var value: String - - var body: some View { - VStack(alignment: .leading) { - Text(title) - .font(.subheadline) - Text(value) - } - } -} - -private struct SubtitleMarkdownFormRow: View { - var title: String - var value: String - - var body: some View { - VStack(alignment: .leading) { - Text(title) - .font(.subheadline) - Markdown(value) - } - } -} - -private struct SafetyRatingsSection: View { - var ratings: [SafetyRating] - - var body: some View { - Section("Safety ratings") { - List(ratings, id: \.self) { rating in - HStack { - Text(rating.category.displayValue).font(.subheadline) - Spacer() - Text(rating.probability.displayValue) - } - } - } - } -} - -struct ErrorDetailsView: View { - var error: Error - - var body: some View { - NavigationView { - Form { - switch error { - case let GenerateContentError.internalError(underlying: underlyingError): - Section("Error Type") { - Text("Internal error") - } - - Section("Details") { - SubtitleFormRow(title: "Error description", - value: underlyingError.localizedDescription) - } - - case let GenerateContentError.promptBlocked(response: generateContentResponse): - Section("Error Type") { - Text("Your prompt was blocked") - } - - Section("Details") { - if let reason = generateContentResponse.promptFeedback?.blockReason { - SubtitleFormRow(title: "Reason for blocking", value: reason.rawValue) - } - - if let text = generateContentResponse.text { - SubtitleMarkdownFormRow(title: "Last chunk for the response", value: text) - } - } - - if let ratings = generateContentResponse.candidates.first?.safetyRatings { - SafetyRatingsSection(ratings: ratings) - } - - case let GenerateContentError.responseStoppedEarly( - reason: finishReason, - response: generateContentResponse - ): - - Section("Error Type") { - Text("Response stopped early") - } - - Section("Details") { - SubtitleFormRow(title: "Reason for finishing early", value: finishReason.rawValue) - - if let text = generateContentResponse.text { - SubtitleMarkdownFormRow(title: "Last chunk for the response", value: text) - } - } - - if let ratings = generateContentResponse.candidates.first?.safetyRatings { - SafetyRatingsSection(ratings: ratings) - } - - default: - Section("Error Type") { - Text("Some other error") - } - - Section("Details") { - SubtitleFormRow(title: "Error description", value: error.localizedDescription) - } - } - } - .navigationTitle("Error details") - .navigationBarTitleDisplayMode(.inline) - } - } -} - -#Preview("Response Stopped Early") { - let error = GenerateContentError.responseStoppedEarly( - reason: .maxTokens, - response: GenerateContentResponse(candidates: [ - Candidate(content: ModelContent(role: "model", parts: - """ - A _hypothetical_ model response. - Cillum ex aliqua amet aliquip labore amet eiusmod consectetur reprehenderit sit commodo. - """), - safetyRatings: [ - SafetyRating( - category: .dangerousContent, - probability: .medium, - probabilityScore: 0.8, - severity: .medium, - severityScore: 0.9, - blocked: false - ), - SafetyRating( - category: .harassment, - probability: .low, - probabilityScore: 0.5, - severity: .low, - severityScore: 0.6, - blocked: false - ), - SafetyRating( - category: .hateSpeech, - probability: .low, - probabilityScore: 0.3, - severity: .medium, - severityScore: 0.2, - blocked: false - ), - SafetyRating( - category: .sexuallyExplicit, - probability: .low, - probabilityScore: 0.2, - severity: .negligible, - severityScore: 0.5, - blocked: false - ), - ], - finishReason: FinishReason.maxTokens, - citationMetadata: nil), - ]) - ) - - return ErrorDetailsView(error: error) -} - -#Preview("Prompt Blocked") { - let error = GenerateContentError.promptBlocked( - response: GenerateContentResponse(candidates: [ - Candidate(content: ModelContent(role: "model", parts: - """ - A _hypothetical_ model response. - Cillum ex aliqua amet aliquip labore amet eiusmod consectetur reprehenderit sit commodo. - """), - safetyRatings: [ - SafetyRating( - category: .dangerousContent, - probability: .low, - probabilityScore: 0.8, - severity: .medium, - severityScore: 0.9, - blocked: false - ), - SafetyRating( - category: .harassment, - probability: .low, - probabilityScore: 0.5, - severity: .low, - severityScore: 0.6, - blocked: false - ), - SafetyRating( - category: .hateSpeech, - probability: .low, - probabilityScore: 0.3, - severity: .medium, - severityScore: 0.2, - blocked: false - ), - SafetyRating( - category: .sexuallyExplicit, - probability: .low, - probabilityScore: 0.2, - severity: .negligible, - severityScore: 0.5, - blocked: false - ), - ], - finishReason: FinishReason.other, - citationMetadata: nil), - ]) - ) - - return ErrorDetailsView(error: error) -} diff --git a/FirebaseVertexAI/Sample/ChatSample/Views/ErrorView.swift b/FirebaseVertexAI/Sample/ChatSample/Views/ErrorView.swift deleted file mode 100644 index a5d43c30b2d..00000000000 --- a/FirebaseVertexAI/Sample/ChatSample/Views/ErrorView.swift +++ /dev/null @@ -1,96 +0,0 @@ -// 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 FirebaseVertexAI -import SwiftUI - -struct ErrorView: View { - var error: Error - @State private var isDetailsSheetPresented = false - var body: some View { - HStack { - Text("An error occurred.") - Button(action: { isDetailsSheetPresented.toggle() }) { - Image(systemName: "info.circle") - } - } - .frame(maxWidth: .infinity, alignment: .center) - .listRowSeparator(.hidden) - .sheet(isPresented: $isDetailsSheetPresented) { - ErrorDetailsView(error: error) - } - } -} - -#Preview { - NavigationView { - let errorPromptBlocked = GenerateContentError.promptBlocked( - response: GenerateContentResponse( - candidates: [ - Candidate( - content: ModelContent(role: "model", parts: [ - """ - A _hypothetical_ model response. - Cillum ex aliqua amet aliquip labore amet eiusmod consectetur reprehenderit sit commodo. - """, - ]), - safetyRatings: [ - SafetyRating( - category: .dangerousContent, - probability: .high, - probabilityScore: 0.8, - severity: .medium, - severityScore: 0.9, - blocked: true - ), - SafetyRating( - category: .harassment, - probability: .low, - probabilityScore: 0.5, - severity: .low, - severityScore: 0.6, - blocked: false - ), - SafetyRating( - category: .hateSpeech, - probability: .low, - probabilityScore: 0.3, - severity: .medium, - severityScore: 0.2, - blocked: false - ), - SafetyRating( - category: .sexuallyExplicit, - probability: .low, - probabilityScore: 0.2, - severity: .negligible, - severityScore: 0.5, - blocked: false - ), - ], - finishReason: FinishReason.other, - citationMetadata: nil - ), - ] - ) - ) - List { - MessageView(message: ChatMessage.samples[0]) - MessageView(message: ChatMessage.samples[1]) - ErrorView(error: errorPromptBlocked) - } - .listStyle(.plain) - .navigationTitle("Chat sample") - } -} diff --git a/FirebaseVertexAI/Sample/ChatSample/Views/MessageView.swift b/FirebaseVertexAI/Sample/ChatSample/Views/MessageView.swift deleted file mode 100644 index 79894503ffd..00000000000 --- a/FirebaseVertexAI/Sample/ChatSample/Views/MessageView.swift +++ /dev/null @@ -1,108 +0,0 @@ -// 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 MarkdownUI -import SwiftUI - -struct RoundedCorner: Shape { - var radius: CGFloat = .infinity - var corners: UIRectCorner = .allCorners - - func path(in rect: CGRect) -> Path { - let path = UIBezierPath( - roundedRect: rect, - byRoundingCorners: corners, - cornerRadii: CGSize(width: radius, height: radius) - ) - return Path(path.cgPath) - } -} - -extension View { - func roundedCorner(_ radius: CGFloat, corners: UIRectCorner) -> some View { - clipShape(RoundedCorner(radius: radius, corners: corners)) - } -} - -struct MessageContentView: View { - var message: ChatMessage - - var body: some View { - if message.pending { - BouncingDots() - } else { - Markdown(message.message) - .markdownTextStyle { - FontFamilyVariant(.normal) - FontSize(.em(0.85)) - ForegroundColor(message.participant == .system ? Color(UIColor.label) : .white) - } - .markdownBlockStyle(\.codeBlock) { configuration in - configuration.label - .relativeLineSpacing(.em(0.25)) - .markdownTextStyle { - FontFamilyVariant(.monospaced) - FontSize(.em(0.85)) - ForegroundColor(Color(.label)) - } - .padding() - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .markdownMargin(top: .zero, bottom: .em(0.8)) - } - } - } -} - -struct MessageView: View { - var message: ChatMessage - - var body: some View { - HStack { - if message.participant == .user { - Spacer() - } - MessageContentView(message: message) - .padding(10) - .background(message.participant == .system - ? Color(UIColor.systemFill) - : Color(UIColor.systemBlue)) - .roundedCorner(10, - corners: [ - .topLeft, - .topRight, - message.participant == .system ? .bottomRight : .bottomLeft, - ]) - if message.participant == .system { - Spacer() - } - } - .listRowSeparator(.hidden) - } -} - -struct MessageView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - List { - MessageView(message: ChatMessage.samples[0]) - MessageView(message: ChatMessage.samples[1]) - MessageView(message: ChatMessage.samples[2]) - MessageView(message: ChatMessage(message: "Hello!", participant: .system, pending: true)) - } - .listStyle(.plain) - .navigationTitle("Chat sample") - } - } -} diff --git a/FirebaseVertexAI/Sample/FunctionCallingSample/Screens/FunctionCallingScreen.swift b/FirebaseVertexAI/Sample/FunctionCallingSample/Screens/FunctionCallingScreen.swift deleted file mode 100644 index f16da39e22f..00000000000 --- a/FirebaseVertexAI/Sample/FunctionCallingSample/Screens/FunctionCallingScreen.swift +++ /dev/null @@ -1,131 +0,0 @@ -// 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 FirebaseVertexAI -import GenerativeAIUIComponents -import SwiftUI - -struct FunctionCallingScreen: View { - @EnvironmentObject - var viewModel: FunctionCallingViewModel - - @State - private var userPrompt = "What is 100 Euros in U.S. Dollars?" - - enum FocusedField: Hashable { - case message - } - - @FocusState - var focusedField: FocusedField? - - var body: some View { - VStack { - ScrollViewReader { scrollViewProxy in - List { - Text("Interact with a currency conversion API using function calling in Gemini.") - ForEach(viewModel.messages) { message in - MessageView(message: message) - } - if let error = viewModel.error { - ErrorView(error: error) - .tag("errorView") - } - } - .listStyle(.plain) - .onChange(of: viewModel.messages, perform: { newValue in - if viewModel.hasError { - // Wait for a short moment to make sure we can actually scroll to the bottom. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - withAnimation { - scrollViewProxy.scrollTo("errorView", anchor: .bottom) - } - focusedField = .message - } - } else { - guard let lastMessage = viewModel.messages.last else { return } - - // Wait for a short moment to make sure we can actually scroll to the bottom. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - withAnimation { - scrollViewProxy.scrollTo(lastMessage.id, anchor: .bottom) - } - focusedField = .message - } - } - }) - .onTapGesture { - focusedField = nil - } - } - InputField("Message...", text: $userPrompt) { - Image(systemName: viewModel.busy ? "stop.circle.fill" : "arrow.up.circle.fill") - .font(.title) - } - .focused($focusedField, equals: .message) - .onSubmit { sendOrStop() } - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(action: newChat) { - Image(systemName: "square.and.pencil") - } - } - } - .navigationTitle("Function Calling") - .onAppear { - focusedField = .message - } - } - - private func sendMessage() { - Task { - let prompt = userPrompt - userPrompt = "" - await viewModel.sendMessage(prompt, streaming: true) - } - } - - private func sendOrStop() { - if viewModel.busy { - viewModel.stop() - } else { - sendMessage() - } - } - - private func newChat() { - viewModel.startNewChat() - } -} - -struct FunctionCallingScreen_Previews: PreviewProvider { - struct ContainerView: View { - @EnvironmentObject - var viewModel: FunctionCallingViewModel - - var body: some View { - FunctionCallingScreen() - .onAppear { - viewModel.messages = ChatMessage.samples - } - } - } - - static var previews: some View { - NavigationStack { - FunctionCallingScreen().environmentObject(FunctionCallingViewModel()) - } - } -} diff --git a/FirebaseVertexAI/Sample/FunctionCallingSample/ViewModels/FunctionCallingViewModel.swift b/FirebaseVertexAI/Sample/FunctionCallingSample/ViewModels/FunctionCallingViewModel.swift deleted file mode 100644 index 569cf770dd5..00000000000 --- a/FirebaseVertexAI/Sample/FunctionCallingSample/ViewModels/FunctionCallingViewModel.swift +++ /dev/null @@ -1,255 +0,0 @@ -// 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 FirebaseVertexAI -import Foundation -import UIKit - -@MainActor -class FunctionCallingViewModel: ObservableObject { - /// This array holds both the user's and the system's chat messages - @Published var messages = [ChatMessage]() - - /// Indicates we're waiting for the model to finish - @Published var busy = false - - @Published var error: Error? - var hasError: Bool { - return error != nil - } - - /// Function calls pending processing - private var functionCalls = [FunctionCallPart]() - - private var model: GenerativeModel - private var chat: Chat - - private var chatTask: Task? - - init() { - model = VertexAI.vertexAI().generativeModel( - modelName: "gemini-2.0-flash-001", - tools: [.functionDeclarations([ - FunctionDeclaration( - name: "get_exchange_rate", - description: "Get the exchange rate for currencies between countries", - parameters: [ - "currency_from": .enumeration( - values: ["USD", "EUR", "JPY", "GBP", "AUD", "CAD"], - description: "The currency to convert from in ISO 4217 format" - ), - "currency_to": .enumeration( - values: ["USD", "EUR", "JPY", "GBP", "AUD", "CAD"], - description: "The currency to convert to in ISO 4217 format" - ), - ] - ), - ])] - ) - chat = model.startChat() - } - - func sendMessage(_ text: String, streaming: Bool = true) async { - error = nil - chatTask?.cancel() - - chatTask = Task { - busy = true - defer { - busy = false - } - - // first, add the user's message to the chat - let userMessage = ChatMessage(message: text, participant: .user) - messages.append(userMessage) - - // add a pending message while we're waiting for a response from the backend - let systemMessage = ChatMessage.pending(participant: .system) - messages.append(systemMessage) - - print(messages) - do { - repeat { - if streaming { - try await internalSendMessageStreaming(text) - } else { - try await internalSendMessage(text) - } - } while !functionCalls.isEmpty - } catch { - self.error = error - print(error.localizedDescription) - messages.removeLast() - } - } - } - - func startNewChat() { - stop() - error = nil - chat = model.startChat() - messages.removeAll() - } - - func stop() { - chatTask?.cancel() - error = nil - } - - private func internalSendMessageStreaming(_ text: String) async throws { - let functionResponses = try await processFunctionCalls() - let responseStream: AsyncThrowingStream - if functionResponses.isEmpty { - responseStream = try chat.sendMessageStream(text) - } else { - for functionResponse in functionResponses { - messages.insert(functionResponse.chatMessage(), at: messages.count - 1) - } - responseStream = try chat.sendMessageStream([functionResponses.modelContent()]) - } - for try await chunk in responseStream { - processResponseContent(content: chunk) - } - } - - private func internalSendMessage(_ text: String) async throws { - let functionResponses = try await processFunctionCalls() - let response: GenerateContentResponse - if functionResponses.isEmpty { - response = try await chat.sendMessage(text) - } else { - for functionResponse in functionResponses { - messages.insert(functionResponse.chatMessage(), at: messages.count - 1) - } - response = try await chat.sendMessage([functionResponses.modelContent()]) - } - processResponseContent(content: response) - } - - func processResponseContent(content: GenerateContentResponse) { - guard let candidate = content.candidates.first else { - fatalError("No candidate.") - } - - for part in candidate.content.parts { - switch part { - case let textPart as TextPart: - // replace pending message with backend response - messages[messages.count - 1].message += textPart.text - messages[messages.count - 1].pending = false - case let functionCallPart as FunctionCallPart: - messages.insert(functionCallPart.chatMessage(), at: messages.count - 1) - functionCalls.append(functionCallPart) - default: - fatalError("Unsupported response part: \(part)") - } - } - } - - func processFunctionCalls() async throws -> [FunctionResponsePart] { - var functionResponses = [FunctionResponsePart]() - for functionCall in functionCalls { - switch functionCall.name { - case "get_exchange_rate": - let exchangeRates = getExchangeRate(args: functionCall.args) - functionResponses.append(FunctionResponsePart( - name: "get_exchange_rate", - response: exchangeRates - )) - default: - fatalError("Unknown function named \"\(functionCall.name)\".") - } - } - functionCalls = [] - - return functionResponses - } - - // MARK: - Callable Functions - - func getExchangeRate(args: JSONObject) -> JSONObject { - // 1. Validate and extract the parameters provided by the model (from a `FunctionCall`) - guard case let .string(from) = args["currency_from"] else { - fatalError("Missing `currency_from` parameter.") - } - guard case let .string(to) = args["currency_to"] else { - fatalError("Missing `currency_to` parameter.") - } - - // 2. Get the exchange rate - let allRates: [String: [String: Double]] = [ - "AUD": ["CAD": 0.89265, "EUR": 0.6072, "GBP": 0.51714, "JPY": 97.75, "USD": 0.66379], - "CAD": ["AUD": 1.1203, "EUR": 0.68023, "GBP": 0.57933, "JPY": 109.51, "USD": 0.74362], - "EUR": ["AUD": 1.6469, "CAD": 1.4701, "GBP": 0.85168, "JPY": 160.99, "USD": 1.0932], - "GBP": ["AUD": 1.9337, "CAD": 1.7261, "EUR": 1.1741, "JPY": 189.03, "USD": 1.2836], - "JPY": ["AUD": 0.01023, "CAD": 0.00913, "EUR": 0.00621, "GBP": 0.00529, "USD": 0.00679], - "USD": ["AUD": 1.5065, "CAD": 1.3448, "EUR": 0.91475, "GBP": 0.77907, "JPY": 147.26], - ] - guard let fromRates = allRates[from] else { - return ["error": .string("No data for currency \(from).")] - } - guard let toRate = fromRates[to] else { - return ["error": .string("No data for currency \(to).")] - } - - // 3. Return the exchange rates as a JSON object (returned to the model in a `FunctionResponse`) - return ["rates": .number(toRate)] - } -} - -private extension FunctionCallPart { - func chatMessage() -> ChatMessage { - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - - let jsonData: Data - do { - jsonData = try encoder.encode(self) - } catch { - fatalError("JSON Encoding Failed: \(error.localizedDescription)") - } - guard let json = String(data: jsonData, encoding: .utf8) else { - fatalError("Failed to convert JSON data to a String.") - } - let messageText = "Function call requested by model:\n```\n\(json)\n```" - - return ChatMessage(message: messageText, participant: .system) - } -} - -private extension FunctionResponsePart { - func chatMessage() -> ChatMessage { - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - - let jsonData: Data - do { - jsonData = try encoder.encode(self) - } catch { - fatalError("JSON Encoding Failed: \(error.localizedDescription)") - } - guard let json = String(data: jsonData, encoding: .utf8) else { - fatalError("Failed to convert JSON data to a String.") - } - let messageText = "Function response returned by app:\n```\n\(json)\n```" - - return ChatMessage(message: messageText, participant: .user) - } -} - -private extension [FunctionResponsePart] { - func modelContent() -> ModelContent { - return ModelContent(role: "function", parts: self) - } -} diff --git a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/AccentColor.colorset/Contents.json b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb878970081..00000000000 --- a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/AppIcon.appiconset/Contents.json b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 13613e3ee1a..00000000000 --- a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/Contents.json b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a7f..00000000000 --- a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Preview Content/Preview Assets.xcassets/Contents.json b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Preview Content/Preview Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a7f..00000000000 --- a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Preview Content/Preview Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Screens/PhotoReasoningScreen.swift b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Screens/PhotoReasoningScreen.swift deleted file mode 100644 index 930214770d8..00000000000 --- a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Screens/PhotoReasoningScreen.swift +++ /dev/null @@ -1,78 +0,0 @@ -// 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 GenerativeAIUIComponents -import MarkdownUI -import PhotosUI -import SwiftUI - -struct PhotoReasoningScreen: View { - @StateObject var viewModel = PhotoReasoningViewModel() - - enum FocusedField: Hashable { - case message - } - - @FocusState - var focusedField: FocusedField? - - var body: some View { - VStack { - MultimodalInputField(text: $viewModel.userInput, selection: $viewModel.selectedItems) - .focused($focusedField, equals: .message) - .onSubmit { - onSendTapped() - } - - ScrollViewReader { scrollViewProxy in - List { - if let outputText = viewModel.outputText { - HStack(alignment: .top) { - if viewModel.inProgress { - ProgressView() - } else { - Image(systemName: "cloud.circle.fill") - .font(.title2) - } - - Markdown("\(outputText)") - } - .listRowSeparator(.hidden) - } - } - .listStyle(.plain) - } - } - .navigationTitle("Multimodal sample") - .onAppear { - focusedField = .message - } - } - - // MARK: - Actions - - private func onSendTapped() { - focusedField = nil - - Task { - await viewModel.reason() - } - } -} - -#Preview { - NavigationStack { - PhotoReasoningScreen() - } -} diff --git a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/ViewModels/PhotoReasoningViewModel.swift b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/ViewModels/PhotoReasoningViewModel.swift deleted file mode 100644 index 722e8ede238..00000000000 --- a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/ViewModels/PhotoReasoningViewModel.swift +++ /dev/null @@ -1,119 +0,0 @@ -// 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 FirebaseVertexAI -import Foundation -import OSLog -import PhotosUI -import SwiftUI - -@MainActor -class PhotoReasoningViewModel: ObservableObject { - // Maximum value for the larger of the two image dimensions (height and width) in pixels. This is - // being used to reduce the image size in bytes. - private static let largestImageDimension = 768.0 - - private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "generative-ai") - - @Published - var userInput: String = "" - - @Published - var selectedItems = [PhotosPickerItem]() - - @Published - var outputText: String? = nil - - @Published - var errorMessage: String? - - @Published - var inProgress = false - - private var model: GenerativeModel? - - init() { - model = VertexAI.vertexAI().generativeModel(modelName: "gemini-2.0-flash-001") - } - - func reason() async { - defer { - inProgress = false - } - guard let model else { - return - } - - do { - inProgress = true - errorMessage = nil - outputText = "" - - let prompt = "Look at the image(s), and then answer the following question: \(userInput)" - - var images = [any PartsRepresentable]() - for item in selectedItems { - if let data = try? await item.loadTransferable(type: Data.self) { - guard let image = UIImage(data: data) else { - logger.error("Failed to parse data as an image, skipping.") - continue - } - if image.size.fits(largestDimension: PhotoReasoningViewModel.largestImageDimension) { - images.append(image) - } else { - guard let resizedImage = image - .preparingThumbnail(of: image.size - .aspectFit(largestDimension: PhotoReasoningViewModel.largestImageDimension)) else { - logger.error("Failed to resize image: \(image)") - continue - } - - images.append(resizedImage) - } - } - } - - let outputContentStream = try model.generateContentStream(prompt, images) - - // stream response - for try await outputContent in outputContentStream { - guard let line = outputContent.text else { - return - } - - outputText = (outputText ?? "") + line - } - } catch { - logger.error("\(error.localizedDescription)") - errorMessage = error.localizedDescription - } - } -} - -private extension CGSize { - func fits(largestDimension length: CGFloat) -> Bool { - return width <= length && height <= length - } - - func aspectFit(largestDimension length: CGFloat) -> CGSize { - let aspectRatio = width / height - if width > height { - let width = min(self.width, length) - return CGSize(width: width, height: round(width / aspectRatio)) - } else { - let height = min(self.height, length) - return CGSize(width: round(height * aspectRatio), height: height) - } - } -} diff --git a/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/AccentColor.colorset/Contents.json b/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb878970081..00000000000 --- a/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/AppIcon.appiconset/Contents.json b/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 13613e3ee1a..00000000000 --- a/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/Contents.json b/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a7f..00000000000 --- a/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/FirebaseVertexAI/Sample/GenerativeAITextSample/Preview Content/Preview Assets.xcassets/Contents.json b/FirebaseVertexAI/Sample/GenerativeAITextSample/Preview Content/Preview Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a7f..00000000000 --- a/FirebaseVertexAI/Sample/GenerativeAITextSample/Preview Content/Preview Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/FirebaseVertexAI/Sample/GenerativeAITextSample/Screens/SummarizeScreen.swift b/FirebaseVertexAI/Sample/GenerativeAITextSample/Screens/SummarizeScreen.swift deleted file mode 100644 index 748c1addd5f..00000000000 --- a/FirebaseVertexAI/Sample/GenerativeAITextSample/Screens/SummarizeScreen.swift +++ /dev/null @@ -1,80 +0,0 @@ -// 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 MarkdownUI -import SwiftUI - -struct SummarizeScreen: View { - @StateObject var viewModel = SummarizeViewModel() - @State var userInput = "" - - enum FocusedField: Hashable { - case message - } - - @FocusState - var focusedField: FocusedField? - - var body: some View { - VStack { - VStack(alignment: .leading) { - Text("Enter some text, then tap on _Go_ to summarize it.") - .padding(.horizontal, 6) - HStack(alignment: .top) { - TextField("Enter text summarize", text: $userInput, axis: .vertical) - .focused($focusedField, equals: .message) - .textFieldStyle(.roundedBorder) - .onSubmit { - onSummarizeTapped() - } - Button("Go") { - onSummarizeTapped() - } - .padding(.top, 4) - } - } - .padding(.horizontal, 16) - - List { - HStack(alignment: .top) { - if viewModel.inProgress { - ProgressView() - } else { - Image(systemName: "cloud.circle.fill") - .font(.title2) - } - - Markdown("\(viewModel.outputText)") - } - .listRowSeparator(.hidden) - } - .listStyle(.plain) - } - .navigationTitle("Text sample") - } - - private func onSummarizeTapped() { - focusedField = nil - - Task { - await viewModel.summarize(inputText: userInput) - } - } -} - -#Preview { - NavigationStack { - SummarizeScreen() - } -} diff --git a/FirebaseVertexAI/Sample/GenerativeAITextSample/ViewModels/SummarizeViewModel.swift b/FirebaseVertexAI/Sample/GenerativeAITextSample/ViewModels/SummarizeViewModel.swift deleted file mode 100644 index 927f124b8e7..00000000000 --- a/FirebaseVertexAI/Sample/GenerativeAITextSample/ViewModels/SummarizeViewModel.swift +++ /dev/null @@ -1,68 +0,0 @@ -// 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 FirebaseVertexAI -import Foundation -import OSLog - -@MainActor -class SummarizeViewModel: ObservableObject { - private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "generative-ai") - - @Published - var outputText = "" - - @Published - var errorMessage: String? - - @Published - var inProgress = false - - private var model: GenerativeModel? - - init() { - model = VertexAI.vertexAI().generativeModel(modelName: "gemini-2.0-flash-001") - } - - func summarize(inputText: String) async { - defer { - inProgress = false - } - guard let model else { - return - } - - do { - inProgress = true - errorMessage = nil - outputText = "" - - let prompt = "Summarize the following text for me: \(inputText)" - - let outputContentStream = try model.generateContentStream(prompt) - - // stream response - for try await outputContent in outputContentStream { - guard let line = outputContent.text else { - return - } - - outputText = outputText + line - } - } catch { - logger.error("\(error.localizedDescription)") - errorMessage = error.localizedDescription - } - } -} diff --git a/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Package.swift b/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Package.swift deleted file mode 100644 index 808f5f42a97..00000000000 --- a/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Package.swift +++ /dev/null @@ -1,35 +0,0 @@ -// swift-tools-version: 5.9 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -// 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 PackageDescription - -let package = Package( - name: "GenerativeAIUIComponents", - platforms: [ - .iOS(.v16), - ], - products: [ - .library( - name: "GenerativeAIUIComponents", - targets: ["GenerativeAIUIComponents"] - ), - ], - targets: [ - .target( - name: "GenerativeAIUIComponents" - ), - ] -) diff --git a/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Sources/GenerativeAIUIComponents/InputField.swift b/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Sources/GenerativeAIUIComponents/InputField.swift deleted file mode 100644 index 67941c370cb..00000000000 --- a/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Sources/GenerativeAIUIComponents/InputField.swift +++ /dev/null @@ -1,83 +0,0 @@ -// 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 SwiftUI - -public struct InputField