diff --git a/.github/actions/dataconnect-send-notifications/action.yml b/.github/actions/dataconnect-send-notifications/action.yml new file mode 100644 index 00000000000..27133b5031c --- /dev/null +++ b/.github/actions/dataconnect-send-notifications/action.yml @@ -0,0 +1,71 @@ +name: Data Connect Workflow Notifications +description: Notify a GitHub Issue with the results of a workflow. + +inputs: + python-version: + required: true + default: "3.13" + github-issue-for-scheduled-runs: + required: true + job-results-file: + required: true + +runs: + using: "composite" + steps: + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + with: + python-version: ${{ inputs.python-version }} + + - run: pip install -r requirements.txt + shell: bash + working-directory: firebase-dataconnect/ci + + - id: issue-id + name: Determine GitHub Issue For Commenting + working-directory: firebase-dataconnect/ci + shell: bash + run: | + args=( + python + calculate_github_issue_for_commenting.py + --issue-output-file=github_issue_number.txt + --github-repository='${{ github.repository }}' + --github-ref='${{ github.ref }}' + --github-event-name='${{ github.event_name }}' + --pr-body-github-issue-key=trksmnkncd_notification_issue + --github-issue-for-scheduled-run='${{ inputs.github-issue-for-scheduled-runs }}' + ) + echo "${args[*]}" + "${args[@]}" + + set -xv + issue="$(cat github_issue_number.txt)" + echo "issue=$issue" >> "$GITHUB_OUTPUT" + + - name: Post Comment on GitHub Issue + if: steps.issue-id.outputs.issue != '' + working-directory: firebase-dataconnect/ci + shell: bash + run: | + args=( + python + post_comment_for_job_results.py + --github-issue='${{ steps.issue-id.outputs.issue }}' + --github-workflow='${{ github.workflow }}' + --github-repository='${{ github.repository }}' + --github-ref='${{ github.ref }}' + --github-event-name='${{ github.event_name }}' + --github-sha='${{ github.sha }}' + --github-repository-html-url='${{ github.event.repository.html_url }}' + --github-run-id='${{ github.run_id }}' + --github-run-number='${{ github.run_number }}' + --github-run-attempt='${{ github.run_attempt }}' + ) + + while read -r line; do + args=("${args[@]}" "$line") + done <'${{ inputs.job-results-file }}' + + echo "${args[*]}" + exec "${args[@]}" diff --git a/.github/workflows/api-information.yml b/.github/workflows/api-information.yml index f0f1c57d650..df514aa39d5 100644 --- a/.github/workflows/api-information.yml +++ b/.github/workflows/api-information.yml @@ -7,18 +7,18 @@ jobs: if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin cache: gradle - name: Set up Python 3.10 - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 with: python-version: '3.10' - name: Set up fireci diff --git a/.github/workflows/build-release-artifacts.yml b/.github/workflows/build-release-artifacts.yml index 313226dce97..328deabfdb6 100644 --- a/.github/workflows/build-release-artifacts.yml +++ b/.github/workflows/build-release-artifacts.yml @@ -12,10 +12,10 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin @@ -26,21 +26,21 @@ jobs: ./gradlew firebasePublish - name: Upload m2 repo - uses: actions/upload-artifact@v4.3.3 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: m2repository path: build/m2repository/ retention-days: 15 - name: Upload release notes - uses: actions/upload-artifact@v4.3.3 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: release_notes path: build/release-notes/ retention-days: 15 - name: Upload kotlindocs - uses: actions/upload-artifact@v4.3.3 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: kotlindocs path: build/firebase-kotlindoc/ diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 7937f67acd5..60660863235 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -13,11 +13,11 @@ jobs: env: BUNDLE_GEMFILE: ./ci/danger/Gemfile steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 100 submodules: true - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@1a615958ad9d422dd932dc1d5823942ee002799f # v1.227.0 with: ruby-version: '2.7' - name: Setup Bundler diff --git a/.github/workflows/check-vertexai-responses.yml b/.github/workflows/check-firebaseai-responses.yml similarity index 63% rename from .github/workflows/check-vertexai-responses.yml rename to .github/workflows/check-firebaseai-responses.yml index 482254c553d..80d43bb81fe 100644 --- a/.github/workflows/check-vertexai-responses.yml +++ b/.github/workflows/check-firebaseai-responses.yml @@ -1,40 +1,42 @@ -name: Check Vertex AI Responses +name: Check Firebase AI Responses on: pull_request jobs: check-version: runs-on: ubuntu-latest + permissions: + pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Clone mock responses - run: firebase-vertexai/update_responses.sh + run: firebase-ai/update_responses.sh - name: Find cloned and latest versions run: | CLONED=$(git describe --tags) LATEST=$(git tag --sort=v:refname | tail -n1) echo "cloned_tag=$CLONED" >> $GITHUB_ENV echo "latest_tag=$LATEST" >> $GITHUB_ENV - working-directory: firebase-vertexai/src/test/resources/vertexai-sdk-test-data + working-directory: firebase-ai/src/test/resources/vertexai-sdk-test-data - name: Find comment from previous run if exists - uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e + uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0 id: fc with: issue-number: ${{github.event.number}} - body-includes: Vertex AI Mock Responses Check + body-includes: Firebase AI Mock Responses Check - name: Comment on PR if newer version is available if: ${{env.cloned_tag != env.latest_tag && !steps.fc.outputs.comment-id}} - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 with: issue-number: ${{github.event.number}} body: > - ### Vertex AI Mock Responses Check :warning: - - A newer major version of the mock responses for Vertex AI unit tests is available. - [update_responses.sh](https://github.com/firebase/firebase-android-sdk/blob/main/firebase-vertexai/update_responses.sh) + ### Firebase AI Mock Responses Check :warning: + + A newer major version of the mock responses for Firebase AI unit tests is available. + [update_responses.sh](https://github.com/firebase/firebase-android-sdk/blob/main/firebase-ai/update_responses.sh) should be updated to clone the latest version of the responses: `${{env.latest_tag}}` - name: Delete comment when version gets updated if: ${{env.cloned_tag == env.latest_tag && steps.fc.outputs.comment-id}} - uses: detomarco/delete-comment@850734dd44d8b15fef55b45252613b903ceb06f0 + uses: detomarco/delete-comment@dd37d1026c669ebfb0ffa5d23890010759ff05d5 # v1.1.0 with: comment-id: ${{ steps.fc.outputs.comment-id }} diff --git a/.github/workflows/check-head-dependencies.yml b/.github/workflows/check-head-dependencies.yml index 088724bf1d4..189b0a0c87c 100644 --- a/.github/workflows/check-head-dependencies.yml +++ b/.github/workflows/check-head-dependencies.yml @@ -10,9 +10,9 @@ jobs: check-head-dependencies: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin diff --git a/.github/workflows/check_format.yml b/.github/workflows/check_format.yml index 6bdfb0ea4d1..83fdc3ec605 100644 --- a/.github/workflows/check_format.yml +++ b/.github/workflows/check_format.yml @@ -16,13 +16,13 @@ jobs: outputs: modules: ${{ steps.changed-modules.outputs.modules }} steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin @@ -44,13 +44,13 @@ jobs: module: ${{ fromJSON(needs.determine_changed.outputs.modules) }} steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index c706aa614bd..487d0229d3b 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -16,13 +16,13 @@ jobs: outputs: modules: ${{ steps.changed-modules.outputs.modules }} steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin @@ -44,30 +44,25 @@ jobs: module: ${{ fromJSON(needs.determine_changed.outputs.modules) }} steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin cache: gradle - - name: Pull genai-common + - name: Clone vertexai mock responses if: matrix.module == ':firebase-vertexai' - run: | - git clone https://github.com/google-gemini/generative-ai-android.git - cd generative-ai-android - ./gradlew :common:updateVersion common:publishToMavenLocal - cd .. + run: firebase-vertexai/update_responses.sh - - name: Clone mock responses - if: matrix.module == ':firebase-vertexai' - run: | - firebase-vertexai/update_responses.sh + - name: Clone ai mock responses + if: matrix.module == ':firebase-ai' + run: firebase-ai/update_responses.sh - name: Add google-services.json env: @@ -85,7 +80,7 @@ jobs: MODULE=${{matrix.module}} echo "ARTIFACT_NAME=${MODULE//:/_}" >> $GITHUB_ENV - name: Upload Test Results - uses: actions/upload-artifact@v4.3.3 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 if: always() with: name: unit-test-result-${{env.ARTIFACT_NAME}} @@ -122,13 +117,13 @@ jobs: - module: :firebase-functions:ktx steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin @@ -139,10 +134,10 @@ jobs: INTEG_TESTS_GOOGLE_SERVICES: ${{ secrets.INTEG_TESTS_GOOGLE_SERVICES }} run: | echo $INTEG_TESTS_GOOGLE_SERVICES | base64 -d > google-services.json - - uses: google-github-actions/auth@v2 + - uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8 with: credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT }} - - uses: google-github-actions/setup-gcloud@v2 + - uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4 - name: ${{ matrix.module }} Integ Tests env: FIREBASE_CI: 1 @@ -168,11 +163,11 @@ jobs: steps: - name: Download Artifacts - uses: actions/download-artifact@v4.1.7 + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 with: path: artifacts - name: Publish Test Results - uses: EnricoMi/publish-unit-test-result-action@82082dac68ad6a19d980f8ce817e108b9f496c2a + uses: EnricoMi/publish-unit-test-result-action@170bf24d20d201b842d7a52403b73ed297e6645b # v2.18.0 with: files: "artifacts/**/*.xml" diff --git a/.github/workflows/config-e2e.yml b/.github/workflows/config-e2e.yml index 604115b324d..15091c2d3f9 100644 --- a/.github/workflows/config-e2e.yml +++ b/.github/workflows/config-e2e.yml @@ -18,10 +18,10 @@ jobs: steps: - name: Checkout firebase-config - uses: actions/checkout@v4.1.1 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: '17' distribution: 'temurin' @@ -31,10 +31,10 @@ jobs: run: | echo $REMOTE_CONFIG_E2E_GOOGLE_SERVICES | base64 -d > google-services.json - - uses: google-github-actions/auth@v2 + - uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8 with: credentials_json: ${{ secrets.GCP_service_account }} - - uses: google-github-actions/setup-gcloud@v2 + - uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4 - name: Run Remote Config end-to-end tests env: FTL_RESULTS_BUCKET: fireescape diff --git a/.github/workflows/copyright-check.yml b/.github/workflows/copyright-check.yml index b9e3aeba227..4f90b26f7f6 100644 --- a/.github/workflows/copyright-check.yml +++ b/.github/workflows/copyright-check.yml @@ -10,8 +10,8 @@ jobs: copyright-check: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4.1.1 - - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 with: python-version: '3.9' - run: | diff --git a/.github/workflows/create_releases.yml b/.github/workflows/create_releases.yml index c47cfac9713..0da1384927e 100644 --- a/.github/workflows/create_releases.yml +++ b/.github/workflows/create_releases.yml @@ -15,6 +15,9 @@ on: jobs: create-branches: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: @@ -25,12 +28,15 @@ jobs: create-pull-request: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin @@ -40,7 +46,7 @@ jobs: ./gradlew generateReleaseConfig -PcurrentRelease=${{ inputs.name }} -PpastRelease=${{ inputs.past-name }} -PprintOutput=true - name: Create Pull Request - uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 with: base: 'releases/${{ inputs.name }}' branch: 'releases/${{ inputs.name }}.release' diff --git a/.github/workflows/dataconnect.yml b/.github/workflows/dataconnect.yml index 3a0b9aa4b93..2808b19e7d6 100644 --- a/.github/workflows/dataconnect.yml +++ b/.github/workflows/dataconnect.yml @@ -9,6 +9,7 @@ on: firebaseToolsVersion: gradleInfoLog: type: boolean + pythonVersion: pull_request: paths: - .github/workflows/dataconnect.yml @@ -24,9 +25,10 @@ env: FDC_JAVA_VERSION: ${{ inputs.javaVersion || '17' }} FDC_ANDROID_EMULATOR_API_LEVEL: ${{ inputs.androidEmulatorApiLevel || '34' }} FDC_NODEJS_VERSION: ${{ inputs.nodeJsVersion || '20' }} - FDC_FIREBASE_TOOLS_VERSION: ${{ inputs.firebaseToolsVersion || '13.29.1' }} + FDC_FIREBASE_TOOLS_VERSION: ${{ inputs.firebaseToolsVersion || '14.5.1' }} FDC_FIREBASE_TOOLS_DIR: /tmp/firebase-tools FDC_FIREBASE_COMMAND: /tmp/firebase-tools/node_modules/.bin/firebase + FDC_PYTHON_VERSION: ${{ inputs.pythonVersion || '3.13' }} concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -51,30 +53,36 @@ jobs: - 5432:5432 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: show-progress: false - - uses: actions/setup-java@v4 + - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: ${{ env.FDC_JAVA_VERSION }} distribution: temurin - - uses: actions/setup-node@v4 + - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0 with: node-version: ${{ env.FDC_NODEJS_VERSION }} - - name: install firebase-tools + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + with: + python-version: ${{ env.FDC_PYTHON_VERSION }} + + - run: pip install -r firebase-dataconnect/ci/requirements.txt + + - name: Install Firebase Tools ("firebase" command-line tool) run: | + set -euo pipefail set -v mkdir -p ${{ env.FDC_FIREBASE_TOOLS_DIR }} cd ${{ env.FDC_FIREBASE_TOOLS_DIR }} echo '{}' > package.json npm install --fund=false --audit=false --save --save-exact firebase-tools@${{ env.FDC_FIREBASE_TOOLS_VERSION }} - - name: Restore Gradle cache - id: restore-gradle-cache - uses: actions/cache/restore@v4 + - name: Restore Gradle Cache + uses: actions/cache/restore@d4323d4df104b026a6aa633fdb11d772146be0bf # 4.2.2 if: github.event_name != 'schedule' with: path: | @@ -84,9 +92,11 @@ jobs: restore-keys: | gradle-cache-jqnvfzw6w7- - - name: tool versions + - name: Print Command-Line Tool Versions continue-on-error: true run: | + set -euo pipefail + function run_cmd { echo "===============================================================================" echo "Running Command: $*" @@ -105,6 +115,7 @@ jobs: - name: Gradle assembleDebugAndroidTest run: | + set -euo pipefail set -v # Speed up build times and also avoid configuring firebase-crashlytics-ndk @@ -117,24 +128,25 @@ jobs: ${{ (inputs.gradleInfoLog && '--info') || '' }} \ :firebase-dataconnect:assembleDebugAndroidTest - - name: Save Gradle cache - uses: actions/cache/save@v4 + - name: Save Gradle Cache + uses: actions/cache/save@d4323d4df104b026a6aa633fdb11d772146be0bf # 4.2.2 if: github.event_name == 'schedule' with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ steps.restore-gradle-cache.outputs.cache-primary-key }} + key: gradle-cache-jqnvfzw6w7-${{ github.run_id }} - - name: Enable KVM group permissions for Android Emulator + - name: Enable KVM Group Permissions for Android Emulator run: | + set -euo pipefail echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Restore AVD cache - uses: actions/cache/restore@v4 + - name: Restore AVD Cache + uses: actions/cache/restore@d4323d4df104b026a6aa633fdb11d772146be0bf # 4.2.2 if: github.event_name != 'schedule' id: restore-avd-cache with: @@ -146,48 +158,50 @@ jobs: avd-cache-zhdsn586je-api${{ env.FDC_ANDROID_EMULATOR_API_LEVEL }}- - name: Create AVD - if: github.event_name == 'schedule' || steps.restore-avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@v2 + if: github.event_name == 'schedule' || steps.restore-avd-cache.outputs.cache-matched-key == '' + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d #v2.33.0 with: api-level: ${{ env.FDC_ANDROID_EMULATOR_API_LEVEL }} arch: x86_64 force-avd-creation: false emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: echo "Generated AVD snapshot for caching." + script: 'echo "Generated AVD snapshot for caching; event_name=${{ github.event_name }}, cache-matched-key=${{ steps.restore-avd-cache.outputs.cache-matched-key }}"' - - name: Save AVD cache - uses: actions/cache/save@v4 + - name: Save AVD Cache + uses: actions/cache/save@d4323d4df104b026a6aa633fdb11d772146be0bf # 4.2.2 if: github.event_name == 'schedule' with: path: | ~/.android/avd/* ~/.android/adb* - key: ${{ steps.restore-avd-cache.outputs.cache-primary-key }} + key: avd-cache-zhdsn586je-api${{ env.FDC_ANDROID_EMULATOR_API_LEVEL }}-${{ github.run_id }} - - name: Data Connect Emulator + - name: Start Firebase Emulators run: | - set -x + set -xveuo pipefail - echo 'emulator.postgresConnectionUrl=postgresql://postgres:password@127.0.0.1:5432?sslmode=disable' > firebase-dataconnect/dataconnect.local.properties + # Use the same dataconnect binary as was used for code generation in gradle assemble + DATACONNECT_EMULATOR_BINARY_PATH="$(find "$PWD"/firebase-dataconnect/connectors/build/intermediates/dataconnect/debug/executable -type f)" + if [[ -z $DATACONNECT_EMULATOR_BINARY_PATH ]] ; then + echo "INTERNAL ERROR v7kg2dfhbc: unable to find data connect binary" >&2 + exit 1 + fi + export DATACONNECT_EMULATOR_BINARY_PATH - ./gradlew \ - ${{ (inputs.gradleInfoLog && '--info') || '' }} \ - :firebase-dataconnect:connectors:runDebugDataConnectEmulator \ - >firebase.emulator.dataconnect.log 2>&1 & - - - name: Firebase Auth Emulator - run: | - set -x + export FIREBASE_DATACONNECT_POSTGRESQL_STRING='postgresql://postgres:password@127.0.0.1:5432?sslmode=disable' cd firebase-dataconnect/emulator - ${{ env.FDC_FIREBASE_COMMAND }} emulators:start --only=auth >firebase.emulator.auth.log 2>&1 & + ${{ env.FDC_FIREBASE_COMMAND }} emulators:start --only=auth,dataconnect >firebase.emulators.log 2>&1 & - - name: Capture Logcat Logs - run: adb logcat >logcat.log & + - name: Start Logcat Capture + continue-on-error: true + run: | + set -xveuo pipefail + "$ANDROID_HOME/platform-tools/adb" logcat >logcat.log 2>&1 & - name: Gradle connectedCheck id: connectedCheck - uses: reactivecircus/android-emulator-runner@v2 + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d #v2.33.0 # Allow this GitHub Actions "job" to continue even if the tests fail so that logs from a # failed test run get uploaded as "artifacts" and are available to investigate failed runs. # A later step in this "job" will fail the job if this step fails @@ -201,26 +215,36 @@ jobs: script: | set -eux && ./gradlew ${{ (inputs.gradleInfoLog && '--info') || '' }} :firebase-dataconnect:connectedCheck :firebase-dataconnect:connectors:connectedCheck - - name: Upload log file artifacts - uses: actions/upload-artifact@v4 + - name: Upload Log Files + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: integration_test_logs path: "**/*.log" if-no-files-found: warn compression-level: 9 - - name: Upload Gradle build report artifacts - uses: actions/upload-artifact@v4 + - name: Upload Gradle Build Reports + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: integration_test_gradle_build_reports path: firebase-dataconnect/**/build/reports/ if-no-files-found: warn compression-level: 9 - - name: Check test result + - name: Verify "Gradle connectedCheck" Step Was Successful if: steps.connectedCheck.outcome != 'success' run: | - echo "Failing the job since the connectedCheck step failed" + set -euo pipefail + + if [[ ! -e logcat.log ]] ; then + echo "WARNING dsdta43sxk: logcat log file not found; skipping scanning for test failures" >&2 + else + echo "Scanning logcat output for failure details" + python firebase-dataconnect/ci/logcat_error_report.py --logcat-file=logcat.log + echo + fi + + echo 'Failing because the outcome of the "Gradle connectedCheck" step ("${{ steps.connectedCheck.outcome }}") was not successful' exit 1 # Check this yml file with "actionlint": https://github.com/rhysd/actionlint @@ -230,9 +254,124 @@ jobs: continue-on-error: false runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: show-progress: false + sparse-checkout: '.github/' - uses: docker://rhysd/actionlint:1.7.7 with: args: -color /github/workspace/.github/workflows/dataconnect.yml + + python-ci-unit-tests: + continue-on-error: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + show-progress: false + sparse-checkout: 'firebase-dataconnect/ci/' + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + with: + python-version: ${{ env.FDC_PYTHON_VERSION }} + - run: pip install -r firebase-dataconnect/ci/requirements.txt + - name: pytest + working-directory: firebase-dataconnect/ci + run: pytest --verbose --full-trace --color=no --strict-config + + python-ci-lint: + continue-on-error: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + show-progress: false + sparse-checkout: 'firebase-dataconnect/ci/' + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + with: + python-version: ${{ env.FDC_PYTHON_VERSION }} + - run: pip install -r firebase-dataconnect/ci/requirements.txt + - name: ruff check + working-directory: firebase-dataconnect/ci + run: ruff check --diff --verbose --no-cache --output-format=github --exit-non-zero-on-fix + + python-ci-format: + continue-on-error: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + show-progress: false + sparse-checkout: 'firebase-dataconnect/ci/' + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + with: + python-version: ${{ env.FDC_PYTHON_VERSION }} + - run: pip install -r firebase-dataconnect/ci/requirements.txt + - name: ruff format + working-directory: firebase-dataconnect/ci + run: ruff format --diff --verbose --no-cache + + python-ci-type-check: + continue-on-error: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + show-progress: false + sparse-checkout: 'firebase-dataconnect/ci/' + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + with: + python-version: ${{ env.FDC_PYTHON_VERSION }} + - run: pip install -r firebase-dataconnect/ci/requirements.txt + - name: pyright + working-directory: firebase-dataconnect/ci + run: pyright --warnings --stats + + # The "send-notifications" job adds a comment to GitHub Issue + # https://github.com/firebase/firebase-android-sdk/issues/6857 with the results of the scheduled + # nightly runs. Interested parties can then subscribe to that issue to be aprised of the outcome + # of the nightly runs. + # + # When testing the comment-adding logic itself, you can add the line + # trksmnkncd_notification_issue=6863 + # into the PR's description to instead post a comment to issue #6863, an issue specifically + # created for testing, avoiding spamming the main issue to which others are subscribed. + send-notifications: + needs: + - 'integration-test' + - 'actionlint-dataconnect-yml' + - 'python-ci-unit-tests' + - 'python-ci-lint' + - 'python-ci-format' + - 'python-ci-type-check' + if: always() + permissions: + issues: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + show-progress: false + sparse-checkout: | + firebase-dataconnect/ci/ + .github/ + + - name: gh auth login + run: echo '${{ secrets.GITHUB_TOKEN }}' | gh auth login --with-token + + - name: Create Job Results File + run: | + set -xveuo pipefail + cat >'${{ runner.temp }}/job_results.txt' <github_actions_demo_test_cache_key.txt echo "${{ env.FDC_FIREBASE_TOOLS_VERSION }}" >github_actions_demo_assemble_firebase_tools_version.txt - - uses: actions/setup-node@v3 + - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0 with: node-version: ${{ env.FDC_NODE_VERSION }} cache: 'npm' cache-dependency-path: | - firebase-dataconnect/demo/github_actions_demo_test_cache_key.txt - firebase-dataconnect/demo/github_actions_demo_assemble_firebase_tools_version.txt + github_actions_demo_test_cache_key.txt + github_actions_demo_assemble_firebase_tools_version.txt - name: cache package-lock.json id: package_json_lock - uses: actions/cache@v4 + uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # 4.2.2 with: path: ${{ env.FDC_FIREBASE_TOOLS_DIR }}/package*.json key: firebase_tools_package_json-${{ env.FDC_FIREBASE_TOOLS_VERSION }} @@ -73,9 +70,9 @@ jobs: if: steps.package_json_lock.outputs.cache-hit == 'true' run: | cd ${{ env.FDC_FIREBASE_TOOLS_DIR }} - npm ci --fund=false --audit=false + npm ci --fund=false --audit=false - - uses: actions/setup-java@v4 + - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: ${{ env.FDC_JAVA_VERSION }} distribution: temurin @@ -84,7 +81,7 @@ jobs: firebase-dataconnect/demo/build.gradle.kts firebase-dataconnect/demo/gradle.properties firebase-dataconnect/demo/gradle/wrapper/gradle-wrapper.properties - firebase-dataconnect/demo/github_actions_demo_test_cache_key.txt + github_actions_demo_test_cache_key.txt - name: tool versions continue-on-error: true @@ -102,26 +99,27 @@ jobs: run_cmd which node run_cmd node --version run_cmd ${{ env.FDC_FIREBASE_COMMAND }} --version - run_cmd ./gradlew --version + run_cmd firebase-dataconnect/demo/gradlew --version - - name: ./gradlew assemble test + - name: gradle assemble test run: | set -x - ./gradlew \ + firebase-dataconnect/demo/gradlew \ + --project-dir firebase-dataconnect/demo \ --no-daemon \ ${{ (inputs.gradleInfoLog && '--info') || '' }} \ --profile \ -PdataConnect.minimalApp.firebaseCommand=${{ env.FDC_FIREBASE_COMMAND }} \ assemble test - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: apks path: firebase-dataconnect/demo/build/**/*.apk if-no-files-found: warn compression-level: 0 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: gradle_build_reports path: firebase-dataconnect/demo/build/reports/ @@ -132,14 +130,14 @@ jobs: continue-on-error: false runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: sparse-checkout: firebase-dataconnect/demo - name: Create Cache Key Files run: echo "h99ee4egfd" >github_actions_demo_spotless_cache_key.txt - - uses: actions/setup-java@v4 + - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: ${{ env.FDC_JAVA_VERSION }} distribution: temurin @@ -148,7 +146,7 @@ jobs: firebase-dataconnect/demo/build.gradle.kts firebase-dataconnect/demo/gradle.properties firebase-dataconnect/demo/gradle/wrapper/gradle-wrapper.properties - firebase-dataconnect/demo/github_actions_demo_spotless_cache_key.txt + github_actions_demo_spotless_cache_key.txt - name: tool versions continue-on-error: true @@ -158,12 +156,56 @@ jobs: java -version which javac javac -version - ./gradlew --version + firebase-dataconnect/demo/gradlew --version - - name: ./gradlew spotlessCheck + - name: gradle spotlessCheck run: | set -x - ./gradlew \ + firebase-dataconnect/demo/gradlew \ + --project-dir firebase-dataconnect/demo \ --no-daemon \ ${{ (inputs.gradleInfoLog && '--info') || '' }} \ spotlessCheck + + # The "send-notifications" job adds a comment to GitHub Issue + # https://github.com/firebase/firebase-android-sdk/issues/6891 with the results of the scheduled + # nightly runs. Interested parties can then subscribe to that issue to be aprised of the outcome + # of the nightly runs. + # + # When testing the comment-adding logic itself, you can add the line + # trksmnkncd_notification_issue=6863 + # into the PR's description to instead post a comment to issue #6863, an issue specifically + # created for testing, avoiding spamming the main issue to which others are subscribed. + send-notifications: + needs: + - 'test' + - 'spotlessCheck' + if: always() + permissions: + issues: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + show-progress: false + sparse-checkout: | + firebase-dataconnect/ci/ + .github/ + + - name: gh auth login + run: echo '${{ secrets.GITHUB_TOKEN }}' | gh auth login --with-token + + - name: Create Job Results File + id: create-job-results-file + run: | + set -xveuo pipefail + cat >'${{ runner.temp }}/job_results.txt' < google-services.json - name: Run fireperf end-to-end tests @@ -52,7 +52,7 @@ jobs: --target_environment=${{ matrix.environment }} - name: Notify developers upon failures if: ${{ failure() }} - uses: actions/github-script@v6 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | const owner = context.repo.owner; @@ -98,7 +98,7 @@ jobs: } - name: Upload test artifacts if: always() - uses: actions/upload-artifact@v4.3.3 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: test-artifacts (${{ matrix.environment }}) path: | diff --git a/.github/workflows/firestore_ci_tests.yml b/.github/workflows/firestore_ci_tests.yml index 00ce91b4e92..a7ea11b1624 100644 --- a/.github/workflows/firestore_ci_tests.yml +++ b/.github/workflows/firestore_ci_tests.yml @@ -16,13 +16,13 @@ jobs: outputs: modules: ${{ steps.changed-modules.outputs.modules }} steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin @@ -44,7 +44,7 @@ jobs: fail-fast: false steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 submodules: true @@ -53,10 +53,10 @@ jobs: run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm + sudo udevadm trigger --name-match=kvm - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin @@ -67,12 +67,12 @@ jobs: INTEG_TESTS_GOOGLE_SERVICES: ${{ secrets.INTEG_TESTS_GOOGLE_SERVICES }} run: | echo $INTEG_TESTS_GOOGLE_SERVICES | base64 -d > google-services.json - - uses: google-github-actions/auth@v2 + - uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8 with: credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT }} - - uses: google-github-actions/setup-gcloud@v2 + - uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4 - name: firebase-firestore Integ Tests - uses: reactivecircus/android-emulator-runner@v2 + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d #v2.33.0 env: FIREBASE_CI: 1 FTL_RESULTS_BUCKET: android-ci @@ -88,7 +88,7 @@ jobs: ./gradlew firebase-firestore:connectedCheck withErrorProne -PtargetBackend="prod" - name: Upload logs if: failure() - uses: actions/upload-artifact@v4.3.3 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: logcat.txt path: logcat.txt @@ -107,7 +107,7 @@ jobs: fail-fast: false steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 submodules: true @@ -116,10 +116,10 @@ jobs: run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm + sudo udevadm trigger --name-match=kvm - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin @@ -130,14 +130,14 @@ jobs: INTEG_TESTS_GOOGLE_SERVICES: ${{ secrets.INTEG_TESTS_GOOGLE_SERVICES }} run: | echo $INTEG_TESTS_GOOGLE_SERVICES | base64 -d > google-services.json - - uses: google-github-actions/auth@v2 + - uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8 with: credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT }} - - uses: google-github-actions/setup-gcloud@v2 + - uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4 # create composite indexes with Terraform - name: Setup Terraform - uses: hashicorp/setup-terraform@v2 + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 - name: Terraform Init run: | cd firebase-firestore @@ -164,7 +164,7 @@ jobs: - name: Firestore Named DB Integ Tests timeout-minutes: 20 - uses: reactivecircus/android-emulator-runner@v2 + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d #v2.33.0 env: FIREBASE_CI: 1 FTL_RESULTS_BUCKET: android-ci @@ -180,7 +180,7 @@ jobs: ./gradlew firebase-firestore:connectedCheck withErrorProne -PtargetBackend="prod" - name: Upload logs if: failure() - uses: actions/upload-artifact@v4.3.3 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: named-db-logcat.txt path: logcat.txt @@ -198,7 +198,7 @@ jobs: fail-fast: false steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 submodules: true @@ -207,10 +207,10 @@ jobs: run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm + sudo udevadm trigger --name-match=kvm - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin @@ -221,13 +221,13 @@ jobs: INTEG_TESTS_GOOGLE_SERVICES: ${{ secrets.NIGHTLY_INTEG_TESTS_GOOGLE_SERVICES }} run: | echo $INTEG_TESTS_GOOGLE_SERVICES > google-services.json - - uses: google-github-actions/auth@v2 + - uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8 with: credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT }} - - uses: google-github-actions/setup-gcloud@v2 + - uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4 - name: Firestore Nightly Integ Tests - uses: reactivecircus/android-emulator-runner@v2 + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d #v2.33.0 env: FIREBASE_CI: 1 FTL_RESULTS_BUCKET: android-ci @@ -243,7 +243,7 @@ jobs: ./gradlew firebase-firestore:connectedCheck withErrorProne -PtargetBackend="nightly" - name: Upload logs if: failure() - uses: actions/upload-artifact@v4.3.3 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: nightly-logcat.txt path: logcat.txt diff --git a/.github/workflows/health-metrics.yml b/.github/workflows/health-metrics.yml index 0b20dcd1078..9e086be9c3a 100644 --- a/.github/workflows/health-metrics.yml +++ b/.github/workflows/health-metrics.yml @@ -24,24 +24,24 @@ jobs: && github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin cache: gradle - name: Set up Python 3.10 - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 with: python-version: '3.10' - - uses: google-github-actions/auth@v2 + - uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8 with: credentials_json: '${{ secrets.GCP_SERVICE_ACCOUNT }}' - - uses: google-github-actions/setup-gcloud@v2 + - uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4 - name: Set up fireci run: pip3 install -e ci/fireci - name: Run coverage tests (presubmit) @@ -59,24 +59,24 @@ jobs: && github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin cache: gradle - name: Set up Python 3.10 - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 with: python-version: '3.10' - - uses: google-github-actions/auth@v2 + - uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8 with: credentials_json: '${{ secrets.GCP_SERVICE_ACCOUNT }}' - - uses: google-github-actions/setup-gcloud@v2 + - uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4 - name: Set up fireci run: pip3 install -e ci/fireci - name: Run size tests (presubmit) @@ -95,24 +95,24 @@ jobs: && github.event.pull_request.base.ref == 'main') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin cache: gradle - name: Set up Python 3.10 - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 with: python-version: '3.10' - - uses: google-github-actions/auth@v2 + - uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8 with: credentials_json: '${{ secrets.GCP_SERVICE_ACCOUNT }}' - - uses: google-github-actions/setup-gcloud@v2 + - uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4 - name: Set up fireci run: pip3 install -e ci/fireci - name: Add google-services.json diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/jekyll-gh-pages.yml index c1683b58de8..077b5b465b2 100644 --- a/.github/workflows/jekyll-gh-pages.yml +++ b/.github/workflows/jekyll-gh-pages.yml @@ -31,16 +31,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.1.1 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Pages - uses: actions/configure-pages@v2 + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 - name: Build with Jekyll - uses: actions/jekyll-build-pages@v1 + uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13 with: source: ./contributor-docs destination: ./_site - name: Upload artifact - uses: actions/upload-pages-artifact@v1 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa #v3.0.1 deploy: if: ${{ github.event_name == 'push' && github.repository == 'firebase/firebase-android-sdk' }} @@ -52,4 +52,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v1 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e #v4.0.5 diff --git a/.github/workflows/make-bom.yml b/.github/workflows/make-bom.yml index 0e7d63f5c96..4643217a528 100644 --- a/.github/workflows/make-bom.yml +++ b/.github/workflows/make-bom.yml @@ -8,14 +8,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up Python 3.10 - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 with: python-version: '3.10' - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin @@ -26,21 +26,21 @@ jobs: ./gradlew buildBomBundleZip - name: Upload bom - uses: actions/upload-artifact@v4.3.3 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: bom path: build/bom/ retention-days: 15 - name: Upload release notes - uses: actions/upload-artifact@v4.3.3 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: bom_release_notes path: build/bomReleaseNotes.md retention-days: 15 - name: Upload recipe version update - uses: actions/upload-artifact@v4.3.3 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: recipe_version path: build/recipeVersionUpdate.txt diff --git a/.github/workflows/merge-to-main.yml b/.github/workflows/merge-to-main.yml index 4df37c57891..cfc5f72125f 100644 --- a/.github/workflows/merge-to-main.yml +++ b/.github/workflows/merge-to-main.yml @@ -6,8 +6,7 @@ on: - main types: - opened - - labeled - - unlabeled + - synchronize jobs: pr-message: @@ -15,7 +14,37 @@ jobs: permissions: pull-requests: write steps: - - uses: mshick/add-pr-comment@a65df5f64fc741e91c59b8359a4bc56e57aaf5b1 + - name: Checkout repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + submodules: false + + - name: Filter paths + id: filter + run: | + MERGE_BASE=$(git merge-base origin/${GITHUB_BASE_REF} origin/${GITHUB_HEAD_REF}) + FILES=$(git diff --name-only $MERGE_BASE origin/${GITHUB_HEAD_REF}) + IGNORE=true + for FILE in $FILES; do + if [[ $FILE != plugins/* && $FILE != .github/* ]]; then + IGNORE=false + break + fi + done + + if $IGNORE; then + echo "ignore=true" >> $GITHUB_OUTPUT + echo "Filter result code: ignore = true" + else + echo "ignore=false" >> $GITHUB_OUTPUT + echo "Filter result code: ignore = false" + fi + shell: bash + + - name: Add PR comment + if: steps.filter.outputs.ignore == 'false' + uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2 with: message: > ### 📝 PRs merging into main branch @@ -27,4 +56,5 @@ jobs: branch when the code complete and ready to be released. - name: Success + if: steps.filter.outputs.ignore == 'false' run: exit 0 diff --git a/.github/workflows/metalava-semver-check.yml b/.github/workflows/metalava-semver-check.yml new file mode 100644 index 00000000000..0ec7f35e49c --- /dev/null +++ b/.github/workflows/metalava-semver-check.yml @@ -0,0 +1,32 @@ +name: Metalava SemVer Check + +on: + pull_request: + +jobs: + semver-check: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Checkout PR + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up JDK 17 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 + with: + java-version: 17 + distribution: temurin + cache: gradle + + - name: Copy new api.txt files + run: ./gradlew copyApiTxtFile + + - name: Checkout main + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.base_ref }} + clean: false + + - name: Run Metalava SemVer check + run: ./gradlew metalavaSemver diff --git a/.github/workflows/plugins-check.yml b/.github/workflows/plugins-check.yml index fa482c36d35..3cbb6d2d01b 100644 --- a/.github/workflows/plugins-check.yml +++ b/.github/workflows/plugins-check.yml @@ -11,11 +11,14 @@ concurrency: jobs: plugins-check: + permissions: + checks: write + pull-requests: write runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin @@ -26,7 +29,7 @@ jobs: run: | ./gradlew plugins:check - name: Publish Test Results - uses: EnricoMi/publish-unit-test-result-action@82082dac68ad6a19d980f8ce817e108b9f496c2a + uses: EnricoMi/publish-unit-test-result-action@170bf24d20d201b842d7a52403b73ed297e6645b # v2.18.0 with: files: "**/build/test-results/**/*.xml" check_name: "plugins test results" diff --git a/.github/workflows/post_release_cleanup.yml b/.github/workflows/post_release_cleanup.yml index 8206b735a11..d7ee562bb51 100644 --- a/.github/workflows/post_release_cleanup.yml +++ b/.github/workflows/post_release_cleanup.yml @@ -12,11 +12,11 @@ jobs: create-pull-request: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin @@ -26,7 +26,7 @@ jobs: ./gradlew postReleaseCleanup - name: Create Pull Request - uses: peter-evans/create-pull-request@v4 + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 with: token: ${{ secrets.GOOGLE_OSS_BOT_TOKEN }} committer: google-oss-bot @@ -41,6 +41,6 @@ jobs: title: '${{ inputs.name}} mergeback' body: | Auto-generated PR for cleaning up release ${{ inputs.name}} - + NO_RELEASE_CHANGE commit-message: 'Post release cleanup for ${{ inputs.name }}' diff --git a/.github/workflows/private-mirror-sync.yml b/.github/workflows/private-mirror-sync.yml index 324993eb791..dc17fb289cc 100644 --- a/.github/workflows/private-mirror-sync.yml +++ b/.github/workflows/private-mirror-sync.yml @@ -10,14 +10,14 @@ jobs: if: github.repository == 'FirebasePrivate/firebase-android-sdk' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: repository: firebase/firebase-android-sdk ref: main fetch-depth: 0 submodules: true - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 submodules: true diff --git a/.github/workflows/release-note-changes.yml b/.github/workflows/release-note-changes.yml index 06d42153ea4..95debd4469e 100644 --- a/.github/workflows/release-note-changes.yml +++ b/.github/workflows/release-note-changes.yml @@ -6,10 +6,10 @@ on: - 'main' jobs: - build: + release-notes-changed: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 @@ -18,22 +18,26 @@ jobs: - name: Get changed changelog files id: changed-files - uses: tj-actions/changed-files@v41.0.0 - with: - files_ignore: | - plugins/** - files: | - **/CHANGELOG.md + run: | + git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha}} | grep CHANGELOG.md > /tmp/changelog_file_list.txt + if [[ "$?" == "0" ]] + then + echo "any_changed=true" >> $GITHUB_OUTPUT + else + echo "any_changed=false" >> $GITHUB_OUTPUT + fi + echo "all_changed_files=$(cat /tmp/changelog_file_list.txt)" >> $GITHUB_OUTPUT + rm /tmp/changelog_file_list.txt - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin cache: gradle - name: Set up Python 3.10 - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 if: ${{ steps.changed-files.outputs.any_changed == 'true' }} with: python-version: '3.10' @@ -50,7 +54,7 @@ jobs: fireci changelog_comment -c "${{ steps.changed-files.outputs.all_changed_files }}" -o ./changelog_comment.md - name: Add PR Comment - uses: mshick/add-pr-comment@v2.8.1 + uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2 continue-on-error: true with: status: ${{ steps.generate-comment.outcome }} diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 22bd7f8e3c2..ed18d8c2a2c 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -46,7 +46,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false @@ -73,7 +73,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: Upload artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: SARIF file path: results.sarif diff --git a/.github/workflows/semver-check.yml b/.github/workflows/semver-check.yml index 2fc7eb38843..77b528b936b 100644 --- a/.github/workflows/semver-check.yml +++ b/.github/workflows/semver-check.yml @@ -10,9 +10,9 @@ jobs: semver-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin diff --git a/.github/workflows/sessions-e2e.yml b/.github/workflows/sessions-e2e.yml index 048cd92eee9..092a51fc094 100644 --- a/.github/workflows/sessions-e2e.yml +++ b/.github/workflows/sessions-e2e.yml @@ -18,10 +18,10 @@ jobs: steps: - name: Checkout firebase-sessions - uses: actions/checkout@v4.1.1 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: '11' distribution: 'temurin' @@ -31,10 +31,10 @@ jobs: run: | echo $SESSIONS_E2E_GOOGLE_SERVICES | base64 -d > google-services.json - - uses: google-github-actions/auth@v2 + - uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8 with: credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT }} - - uses: google-github-actions/setup-gcloud@v2 + - uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4 - name: Run sessions end-to-end tests env: FTL_RESULTS_BUCKET: fireescape diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index 07ab7dbeeb2..d39d6ab6562 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -7,20 +7,20 @@ jobs: if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin cache: gradle - - uses: google-github-actions/auth@v2 + - uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8 with: credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT }} - - uses: google-github-actions/setup-gcloud@v2 + - uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4 # TODO(yifany): make it a fireci plugin and remove the separately distributed jar file - name: Download smoke tests runner @@ -51,7 +51,7 @@ jobs: - name: Upload test artifacts if: always() - uses: actions/upload-artifact@v4.3.3 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: smoke-tests-artifacts path: | diff --git a/.github/workflows/update-cpp-sdk-on-release.yml b/.github/workflows/update-cpp-sdk-on-release.yml index 60ffbc47285..49e6b0e1392 100644 --- a/.github/workflows/update-cpp-sdk-on-release.yml +++ b/.github/workflows/update-cpp-sdk-on-release.yml @@ -23,7 +23,7 @@ jobs: outputs: released_version_changed: ${{ steps.check_version.outputs.released_version_changed }} steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: # Check out the actual head commit, not any merge commit. ref: ${{ github.sha }} @@ -51,12 +51,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup python - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 with: python-version: 3.7 - name: Check out firebase-cpp-sdk - uses: actions/checkout@v4.1.1 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: repository: firebase/firebase-cpp-sdk ref: main diff --git a/.github/workflows/validate-dependencies.yml b/.github/workflows/validate-dependencies.yml index c91ad8aee0c..b6fe70c5133 100644 --- a/.github/workflows/validate-dependencies.yml +++ b/.github/workflows/validate-dependencies.yml @@ -10,9 +10,9 @@ jobs: build-artifacts: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index f5f285e29a0..7824404d362 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -10,9 +10,9 @@ jobs: version-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up JDK 17 - uses: actions/setup-java@v4.1.0 + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: temurin diff --git a/build.gradle.kts b/build.gradle.kts index a15ce611215..a10ac0119ea 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,6 +26,7 @@ plugins { id("firebase-ci") id("smoke-tests") alias(libs.plugins.google.services) + alias(libs.plugins.kotlinx.serialization) apply false } extra["targetSdkVersion"] = 34 diff --git a/ci/fireci/fireciplugins/api_information.py b/ci/fireci/fireciplugins/api_information.py index d10b6866797..05e4966d47c 100644 --- a/ci/fireci/fireciplugins/api_information.py +++ b/ci/fireci/fireciplugins/api_information.py @@ -37,9 +37,9 @@ def api_information(auth_token, repo_name, issue_number): with open(os.path.join(dir_suffix, filename), 'r') as f: outputlines = f.readlines() for line in outputlines: - if 'error' in line: + if 'error:' in line: formatted_output_lines.append(line[line.find('error:'):]) - elif 'warning' in line: + elif 'warning:' in line: formatted_output_lines.append(line[line.find('warning:'):]) if formatted_output_lines: diff --git a/ci/fireci/pyproject.toml b/ci/fireci/pyproject.toml index 1ec2c8a9d98..8fd3b462353 100644 --- a/ci/fireci/pyproject.toml +++ b/ci/fireci/pyproject.toml @@ -1,3 +1,44 @@ [build-system] -requires = ["setuptools ~= 58.0"] +requires = ["setuptools ~= 70.0"] build-backend = "setuptools.build_meta" + +[project] +name = "fireci" +version = "0.1" +dependencies = [ + "protobuf==3.20.3", + "click==8.1.7", + "google-cloud-storage==2.18.2", + "mypy==1.6.0", + "numpy==1.24.4", + "pandas==1.5.3", + "PyGithub==1.58.2", + "pystache==0.6.0", + "requests==2.32.2", + "seaborn==0.12.2", + "PyYAML==6.0.1", + "termcolor==2.4.0", + "pytest" +] + +[project.scripts] +fireci = "fireci.main:cli" + +[tool.setuptools] +packages = ["fireci", "fireciplugins"] + +[tool.mypy] +strict_optional = false + +[[tool.mypy.overrides]] + module = [ + "google.cloud", + "matplotlib", + "matplotlib.pyplot", + "pandas", + "pystache", + "requests", + "seaborn", + "yaml" + ] + ignore_missing_imports = true diff --git a/ci/fireci/setup.cfg b/ci/fireci/setup.cfg deleted file mode 100644 index 7b49519871c..00000000000 --- a/ci/fireci/setup.cfg +++ /dev/null @@ -1,45 +0,0 @@ -[metadata] -name = fireci -version = 0.1 - -[options] -install_requires = - protobuf==3.20.3 - click==8.1.7 - google-cloud-storage==2.18.2 - mypy==1.6.0 - numpy==1.24.4 - pandas==1.5.3 - PyGithub==1.58.2 - pystache==0.6.0 - requests==2.31.0 - seaborn==0.12.2 - PyYAML==6.0.1 - termcolor==2.4.0 - -[options.extras_require] -test = - pytest - -[options.entry_points] -console_scripts = - fireci = fireci.main:cli - -[mypy] -strict_optional = False -[mypy-google.cloud] -ignore_missing_imports = True -[mypy-matplotlib] -ignore_missing_imports = True -[mypy-matplotlib.pyplot] -ignore_missing_imports = True -[mypy-pandas] -ignore_missing_imports = True -[mypy-pystache] -ignore_missing_imports = True -[mypy-requests] -ignore_missing_imports = True -[mypy-seaborn] -ignore_missing_imports = True -[mypy-yaml] -ignore_missing_imports = True diff --git a/ci/run.sh b/ci/run.sh index be6e0a35a68..3647cf8082d 100755 --- a/ci/run.sh +++ b/ci/run.sh @@ -15,7 +15,12 @@ # limitations under the License. set -e +set -x DIRECTORY=$(cd `dirname $0` && pwd) -pip3 install -e $DIRECTORY/fireci >> /dev/null +python3 -m ensurepip --upgrade +python3 -m pip install --upgrade setuptools +python3 -m pip install --upgrade pip +python3 -m pip install --upgrade wheel +python3 -m pip install -e $DIRECTORY/fireci >> /dev/null fireci $@ diff --git a/encoders/protoc-gen-firebase-encoders/src/main/kotlin/com/google/firebase/encoders/proto/codegen/Types.kt b/encoders/protoc-gen-firebase-encoders/src/main/kotlin/com/google/firebase/encoders/proto/codegen/Types.kt index 9a902916cf7..f829d95ed52 100644 --- a/encoders/protoc-gen-firebase-encoders/src/main/kotlin/com/google/firebase/encoders/proto/codegen/Types.kt +++ b/encoders/protoc-gen-firebase-encoders/src/main/kotlin/com/google/firebase/encoders/proto/codegen/Types.kt @@ -190,7 +190,7 @@ data class ProtoField( val lowerCamelCaseName: String get() { - return SNAKE_CASE_REGEX.replace(name) { it.value.replace("_", "").toUpperCase() } + return SNAKE_CASE_REGEX.replace(name) { it.value.replace("_", "").uppercase() } } val camelCaseName: String diff --git a/firebase-ai/CHANGELOG.md b/firebase-ai/CHANGELOG.md new file mode 100644 index 00000000000..dd818c29cec --- /dev/null +++ b/firebase-ai/CHANGELOG.md @@ -0,0 +1,28 @@ +# Unreleased + +* [fixed] Fixed `FirebaseAI.getInstance` StackOverflowException (#6971) +* [fixed] Fixed an issue that was causing the SDK to send empty `FunctionDeclaration` descriptions to the API. +* [changed] Introduced the `Voice` class, which accepts a voice name, and deprecated the `Voices` class. +* [changed] **Breaking Change**: Updated `SpeechConfig` to take in `Voice` class instead of `Voices` class. + * **Action Required:** Update all references of `SpeechConfig` initialization to use `Voice` class. +* [fixed] Fix incorrect model name in count token requests to the developer API backend + + +# 16.0.0 +* [feature] Initial release of the Firebase AI SDK (`firebase-ai`). This SDK *replaces* the previous + Vertex AI in Firebase SDK (`firebase-vertexai`) to accommodate the evolving set of supported + features and services. + * The new Firebase AI SDK provides **Preview** support for the Gemini Developer API, including its + free tier offering. + * Using the Firebase AI SDK with the Vertex AI Gemini API is still generally available (GA). + + If you're using the old `firebase-vertexai`, we recommend + [migrating to `firebase-ai`](/docs/ai-logic/migrate-to-latest-sdk) + because all new development and features will be in this new SDK. +* [feature] **Preview:** Added support for specifying response modalities in `GenerationConfig` + (only available in the new `firebase-ai` package). This includes support for image generation using + [specific Gemini models](/docs/vertex-ai/models). + + Note: This feature is in Public Preview, which means that it is not subject to any SLA or + deprecation policy and could change in backwards-incompatible ways. + diff --git a/firebase-ai/README.md b/firebase-ai/README.md new file mode 100644 index 00000000000..e09f65c6092 --- /dev/null +++ b/firebase-ai/README.md @@ -0,0 +1,32 @@ +# Firebase AI SDK + +For developer documentation, please visit https://firebase.google.com/docs/vertex-ai. +This README is for contributors building and running tests for the SDK. + +## Building + +All Gradle commands should be run from the root of this repository. + +`./gradlew :firebase-ai:publishToMavenLocal` + +## Running Tests + +> [!IMPORTANT] +> These unit tests require mock response files, which can be downloaded by running +`./firebase-ai/update_responses.sh` from the root of this repository. + +Unit tests: + +`./gradlew :firebase-ai:check` + +Integration tests, requiring a running and connected device (emulator or real): + +`./gradlew :firebase-ai:deviceCheck` + +## Code Formatting + +Format Kotlin code in this SDK in Android Studio using +the [spotless plugin]([https://plugins.jetbrains.com/plugin/14912-ktfmt](https://github.com/diffplug/spotless) +by running: + +`./gradlew firebase-ai:spotlessApply` diff --git a/firebase-ai/api.txt b/firebase-ai/api.txt new file mode 100644 index 00000000000..5645b466110 --- /dev/null +++ b/firebase-ai/api.txt @@ -0,0 +1,939 @@ +// Signature format: 3.0 +package com.google.firebase.ai { + + public final class Chat { + ctor public Chat(com.google.firebase.ai.GenerativeModel model, java.util.List history = java.util.ArrayList()); + method public java.util.List getHistory(); + method public suspend Object? sendMessage(android.graphics.Bitmap prompt, kotlin.coroutines.Continuation); + method public suspend Object? sendMessage(com.google.firebase.ai.type.Content prompt, kotlin.coroutines.Continuation); + method public suspend Object? sendMessage(String prompt, kotlin.coroutines.Continuation); + method public kotlinx.coroutines.flow.Flow sendMessageStream(android.graphics.Bitmap prompt); + method public kotlinx.coroutines.flow.Flow sendMessageStream(com.google.firebase.ai.type.Content prompt); + method public kotlinx.coroutines.flow.Flow sendMessageStream(String prompt); + property public final java.util.List history; + } + + public final class FirebaseAI { + method public com.google.firebase.ai.GenerativeModel generativeModel(String modelName); + method public com.google.firebase.ai.GenerativeModel generativeModel(String modelName, com.google.firebase.ai.type.GenerationConfig? generationConfig = null); + method public com.google.firebase.ai.GenerativeModel generativeModel(String modelName, com.google.firebase.ai.type.GenerationConfig? generationConfig = null, java.util.List? safetySettings = null); + method public com.google.firebase.ai.GenerativeModel generativeModel(String modelName, com.google.firebase.ai.type.GenerationConfig? generationConfig = null, java.util.List? safetySettings = null, java.util.List? tools = null); + method public com.google.firebase.ai.GenerativeModel generativeModel(String modelName, com.google.firebase.ai.type.GenerationConfig? generationConfig = null, java.util.List? safetySettings = null, java.util.List? tools = null, com.google.firebase.ai.type.ToolConfig? toolConfig = null); + method public com.google.firebase.ai.GenerativeModel generativeModel(String modelName, com.google.firebase.ai.type.GenerationConfig? generationConfig = null, java.util.List? safetySettings = null, java.util.List? tools = null, com.google.firebase.ai.type.ToolConfig? toolConfig = null, com.google.firebase.ai.type.Content? systemInstruction = null); + method public com.google.firebase.ai.GenerativeModel generativeModel(String modelName, com.google.firebase.ai.type.GenerationConfig? generationConfig = null, java.util.List? safetySettings = null, java.util.List? tools = null, com.google.firebase.ai.type.ToolConfig? toolConfig = null, com.google.firebase.ai.type.Content? systemInstruction = null, com.google.firebase.ai.type.RequestOptions requestOptions = com.google.firebase.ai.type.RequestOptions()); + method public static com.google.firebase.ai.FirebaseAI getInstance(); + method public static com.google.firebase.ai.FirebaseAI getInstance(com.google.firebase.ai.type.GenerativeBackend backend); + method public static com.google.firebase.ai.FirebaseAI getInstance(com.google.firebase.FirebaseApp app); + method public static com.google.firebase.ai.FirebaseAI getInstance(com.google.firebase.FirebaseApp app = Firebase.app, com.google.firebase.ai.type.GenerativeBackend backend); + method @com.google.firebase.ai.type.PublicPreviewAPI public com.google.firebase.ai.ImagenModel imagenModel(String modelName); + method @com.google.firebase.ai.type.PublicPreviewAPI public com.google.firebase.ai.ImagenModel imagenModel(String modelName, com.google.firebase.ai.type.ImagenGenerationConfig? generationConfig = null); + method @com.google.firebase.ai.type.PublicPreviewAPI public com.google.firebase.ai.ImagenModel imagenModel(String modelName, com.google.firebase.ai.type.ImagenGenerationConfig? generationConfig = null, com.google.firebase.ai.type.ImagenSafetySettings? safetySettings = null); + method @com.google.firebase.ai.type.PublicPreviewAPI public com.google.firebase.ai.ImagenModel imagenModel(String modelName, com.google.firebase.ai.type.ImagenGenerationConfig? generationConfig = null, com.google.firebase.ai.type.ImagenSafetySettings? safetySettings = null, com.google.firebase.ai.type.RequestOptions requestOptions = com.google.firebase.ai.type.RequestOptions()); + method @com.google.firebase.ai.type.PublicPreviewAPI public com.google.firebase.ai.LiveGenerativeModel liveModel(String modelName); + method @com.google.firebase.ai.type.PublicPreviewAPI public com.google.firebase.ai.LiveGenerativeModel liveModel(String modelName, com.google.firebase.ai.type.LiveGenerationConfig? generationConfig = null); + method @com.google.firebase.ai.type.PublicPreviewAPI public com.google.firebase.ai.LiveGenerativeModel liveModel(String modelName, com.google.firebase.ai.type.LiveGenerationConfig? generationConfig = null, java.util.List? tools = null); + method @com.google.firebase.ai.type.PublicPreviewAPI public com.google.firebase.ai.LiveGenerativeModel liveModel(String modelName, com.google.firebase.ai.type.LiveGenerationConfig? generationConfig = null, java.util.List? tools = null, com.google.firebase.ai.type.Content? systemInstruction = null); + method @com.google.firebase.ai.type.PublicPreviewAPI public com.google.firebase.ai.LiveGenerativeModel liveModel(String modelName, com.google.firebase.ai.type.LiveGenerationConfig? generationConfig = null, java.util.List? tools = null, com.google.firebase.ai.type.Content? systemInstruction = null, com.google.firebase.ai.type.RequestOptions requestOptions = com.google.firebase.ai.type.RequestOptions()); + property public static final com.google.firebase.ai.FirebaseAI instance; + field public static final com.google.firebase.ai.FirebaseAI.Companion Companion; + } + + public static final class FirebaseAI.Companion { + method public com.google.firebase.ai.FirebaseAI getInstance(); + method public com.google.firebase.ai.FirebaseAI getInstance(com.google.firebase.ai.type.GenerativeBackend backend); + method public com.google.firebase.ai.FirebaseAI getInstance(com.google.firebase.FirebaseApp app); + method public com.google.firebase.ai.FirebaseAI getInstance(com.google.firebase.FirebaseApp app = Firebase.app, com.google.firebase.ai.type.GenerativeBackend backend); + property public final com.google.firebase.ai.FirebaseAI instance; + } + + public final class FirebaseAIKt { + method public static com.google.firebase.ai.FirebaseAI ai(com.google.firebase.Firebase, com.google.firebase.FirebaseApp app = Firebase.app, com.google.firebase.ai.type.GenerativeBackend backend = GenerativeBackend.googleAI()); + method public static com.google.firebase.ai.FirebaseAI getAi(com.google.firebase.Firebase); + } + + public final class GenerativeModel { + method public suspend Object? countTokens(android.graphics.Bitmap prompt, kotlin.coroutines.Continuation); + method public suspend Object? countTokens(com.google.firebase.ai.type.Content[] prompt, kotlin.coroutines.Continuation); + method public suspend Object? countTokens(String prompt, kotlin.coroutines.Continuation); + method public suspend Object? generateContent(android.graphics.Bitmap prompt, kotlin.coroutines.Continuation); + method public suspend Object? generateContent(com.google.firebase.ai.type.Content[] prompt, kotlin.coroutines.Continuation); + method public suspend Object? generateContent(String prompt, kotlin.coroutines.Continuation); + method public kotlinx.coroutines.flow.Flow generateContentStream(android.graphics.Bitmap prompt); + method public kotlinx.coroutines.flow.Flow generateContentStream(com.google.firebase.ai.type.Content... prompt); + method public kotlinx.coroutines.flow.Flow generateContentStream(String prompt); + method public com.google.firebase.ai.Chat startChat(java.util.List history = emptyList()); + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class ImagenModel { + method public suspend Object? generateImages(String prompt, kotlin.coroutines.Continuation>); + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class LiveGenerativeModel { + method public suspend Object? connect(kotlin.coroutines.Continuation); + } + +} + +package com.google.firebase.ai.java { + + public abstract class ChatFutures { + method public static final com.google.firebase.ai.java.ChatFutures from(com.google.firebase.ai.Chat chat); + method public abstract com.google.firebase.ai.Chat getChat(); + method public abstract com.google.common.util.concurrent.ListenableFuture sendMessage(com.google.firebase.ai.type.Content prompt); + method public abstract org.reactivestreams.Publisher sendMessageStream(com.google.firebase.ai.type.Content prompt); + field public static final com.google.firebase.ai.java.ChatFutures.Companion Companion; + } + + public static final class ChatFutures.Companion { + method public com.google.firebase.ai.java.ChatFutures from(com.google.firebase.ai.Chat chat); + } + + public abstract class GenerativeModelFutures { + method public abstract com.google.common.util.concurrent.ListenableFuture countTokens(com.google.firebase.ai.type.Content... prompt); + method public static final com.google.firebase.ai.java.GenerativeModelFutures from(com.google.firebase.ai.GenerativeModel model); + method public abstract com.google.common.util.concurrent.ListenableFuture generateContent(com.google.firebase.ai.type.Content... prompt); + method public abstract org.reactivestreams.Publisher generateContentStream(com.google.firebase.ai.type.Content... prompt); + method public abstract com.google.firebase.ai.GenerativeModel getGenerativeModel(); + method public abstract com.google.firebase.ai.java.ChatFutures startChat(); + method public abstract com.google.firebase.ai.java.ChatFutures startChat(java.util.List history); + field public static final com.google.firebase.ai.java.GenerativeModelFutures.Companion Companion; + } + + public static final class GenerativeModelFutures.Companion { + method public com.google.firebase.ai.java.GenerativeModelFutures from(com.google.firebase.ai.GenerativeModel model); + } + + @com.google.firebase.ai.type.PublicPreviewAPI public abstract class ImagenModelFutures { + method public static final com.google.firebase.ai.java.ImagenModelFutures from(com.google.firebase.ai.ImagenModel model); + method public abstract com.google.common.util.concurrent.ListenableFuture> generateImages(String prompt); + method public abstract com.google.firebase.ai.ImagenModel getImageModel(); + field public static final com.google.firebase.ai.java.ImagenModelFutures.Companion Companion; + } + + public static final class ImagenModelFutures.Companion { + method public com.google.firebase.ai.java.ImagenModelFutures from(com.google.firebase.ai.ImagenModel model); + } + + @com.google.firebase.ai.type.PublicPreviewAPI public abstract class LiveModelFutures { + method public abstract com.google.common.util.concurrent.ListenableFuture connect(); + method public static final com.google.firebase.ai.java.LiveModelFutures from(com.google.firebase.ai.LiveGenerativeModel model); + field public static final com.google.firebase.ai.java.LiveModelFutures.Companion Companion; + } + + public static final class LiveModelFutures.Companion { + method public com.google.firebase.ai.java.LiveModelFutures from(com.google.firebase.ai.LiveGenerativeModel model); + } + + @com.google.firebase.ai.type.PublicPreviewAPI public abstract class LiveSessionFutures { + method public abstract com.google.common.util.concurrent.ListenableFuture close(); + method public static final com.google.firebase.ai.java.LiveSessionFutures from(com.google.firebase.ai.type.LiveSession session); + method public abstract org.reactivestreams.Publisher receive(); + method public abstract com.google.common.util.concurrent.ListenableFuture send(com.google.firebase.ai.type.Content content); + method public abstract com.google.common.util.concurrent.ListenableFuture send(String text); + method public abstract com.google.common.util.concurrent.ListenableFuture sendFunctionResponse(java.util.List functionList); + method public abstract com.google.common.util.concurrent.ListenableFuture sendMediaStream(java.util.List mediaChunks); + method @RequiresPermission(android.Manifest.permission.RECORD_AUDIO) public abstract com.google.common.util.concurrent.ListenableFuture startAudioConversation(); + method public abstract com.google.common.util.concurrent.ListenableFuture startAudioConversation(kotlin.jvm.functions.Function1? functionCallHandler); + method @RequiresPermission(android.Manifest.permission.RECORD_AUDIO) public abstract com.google.common.util.concurrent.ListenableFuture stopAudioConversation(); + method public abstract void stopReceiving(); + field public static final com.google.firebase.ai.java.LiveSessionFutures.Companion Companion; + } + + public static final class LiveSessionFutures.Companion { + method public com.google.firebase.ai.java.LiveSessionFutures from(com.google.firebase.ai.type.LiveSession session); + } + +} + +package com.google.firebase.ai.type { + + public final class AudioRecordInitializationFailedException extends com.google.firebase.ai.type.FirebaseAIException { + ctor public AudioRecordInitializationFailedException(String message); + } + + public final class BlockReason { + method public String getName(); + method public int getOrdinal(); + property public final String name; + property public final int ordinal; + field public static final com.google.firebase.ai.type.BlockReason BLOCKLIST; + field public static final com.google.firebase.ai.type.BlockReason.Companion Companion; + field public static final com.google.firebase.ai.type.BlockReason OTHER; + field public static final com.google.firebase.ai.type.BlockReason PROHIBITED_CONTENT; + field public static final com.google.firebase.ai.type.BlockReason SAFETY; + field public static final com.google.firebase.ai.type.BlockReason UNKNOWN; + } + + public static final class BlockReason.Companion { + } + + public final class Candidate { + method public com.google.firebase.ai.type.CitationMetadata? getCitationMetadata(); + method public com.google.firebase.ai.type.Content getContent(); + method public com.google.firebase.ai.type.FinishReason? getFinishReason(); + method public java.util.List getSafetyRatings(); + property public final com.google.firebase.ai.type.CitationMetadata? citationMetadata; + property public final com.google.firebase.ai.type.Content content; + property public final com.google.firebase.ai.type.FinishReason? finishReason; + property public final java.util.List safetyRatings; + } + + public final class Citation { + method public int getEndIndex(); + method public String? getLicense(); + method public java.util.Calendar? getPublicationDate(); + method public int getStartIndex(); + method public String? getTitle(); + method public String? getUri(); + property public final int endIndex; + property public final String? license; + property public final java.util.Calendar? publicationDate; + property public final int startIndex; + property public final String? title; + property public final String? uri; + } + + public final class CitationMetadata { + method public java.util.List getCitations(); + property public final java.util.List citations; + } + + public final class Content { + ctor public Content(String? role = "user", java.util.List parts); + ctor public Content(java.util.List parts); + method public com.google.firebase.ai.type.Content copy(String? role = role, java.util.List parts = parts); + method public java.util.List getParts(); + method public String? getRole(); + property public final java.util.List parts; + property public final String? role; + } + + public static final class Content.Builder { + ctor public Content.Builder(); + method public com.google.firebase.ai.type.Content.Builder addFileData(String uri, String mimeType); + method public com.google.firebase.ai.type.Content.Builder addImage(android.graphics.Bitmap image); + method public com.google.firebase.ai.type.Content.Builder addInlineData(byte[] bytes, String mimeType); + method public com.google.firebase.ai.type.Content.Builder addPart(T data); + method public com.google.firebase.ai.type.Content.Builder addText(String text); + method public com.google.firebase.ai.type.Content build(); + method public com.google.firebase.ai.type.Content.Builder setParts(java.util.List parts); + method public com.google.firebase.ai.type.Content.Builder setRole(String? role); + field public java.util.List parts; + field public String? role; + } + + public final class ContentBlockedException extends com.google.firebase.ai.type.FirebaseAIException { + } + + public final class ContentKt { + method public static com.google.firebase.ai.type.Content content(String? role = "user", kotlin.jvm.functions.Function1 init); + } + + public final class ContentModality { + method public int getOrdinal(); + property public final int ordinal; + field public static final com.google.firebase.ai.type.ContentModality AUDIO; + field public static final com.google.firebase.ai.type.ContentModality.Companion Companion; + field public static final com.google.firebase.ai.type.ContentModality DOCUMENT; + field public static final com.google.firebase.ai.type.ContentModality IMAGE; + field public static final com.google.firebase.ai.type.ContentModality TEXT; + field public static final com.google.firebase.ai.type.ContentModality UNSPECIFIED; + field public static final com.google.firebase.ai.type.ContentModality VIDEO; + } + + public static final class ContentModality.Companion { + } + + public final class CountTokensResponse { + ctor public CountTokensResponse(int totalTokens, Integer? totalBillableCharacters = null, java.util.List promptTokensDetails = emptyList()); + method public operator int component1(); + method public operator Integer? component2(); + method public operator java.util.List? component3(); + method public java.util.List getPromptTokensDetails(); + method public Integer? getTotalBillableCharacters(); + method public int getTotalTokens(); + property public final java.util.List promptTokensDetails; + property public final Integer? totalBillableCharacters; + property public final int totalTokens; + } + + public final class FileDataPart implements com.google.firebase.ai.type.Part { + ctor public FileDataPart(String uri, String mimeType); + method public String getMimeType(); + method public String getUri(); + property public final String mimeType; + property public final String uri; + } + + public final class FinishReason { + method public String getName(); + method public int getOrdinal(); + property public final String name; + property public final int ordinal; + field public static final com.google.firebase.ai.type.FinishReason BLOCKLIST; + field public static final com.google.firebase.ai.type.FinishReason.Companion Companion; + field public static final com.google.firebase.ai.type.FinishReason MALFORMED_FUNCTION_CALL; + field public static final com.google.firebase.ai.type.FinishReason MAX_TOKENS; + field public static final com.google.firebase.ai.type.FinishReason OTHER; + field public static final com.google.firebase.ai.type.FinishReason PROHIBITED_CONTENT; + field public static final com.google.firebase.ai.type.FinishReason RECITATION; + field public static final com.google.firebase.ai.type.FinishReason SAFETY; + field public static final com.google.firebase.ai.type.FinishReason SPII; + field public static final com.google.firebase.ai.type.FinishReason STOP; + field public static final com.google.firebase.ai.type.FinishReason UNKNOWN; + } + + public static final class FinishReason.Companion { + } + + public abstract class FirebaseAIException extends java.lang.RuntimeException { + } + + public final class FunctionCallPart implements com.google.firebase.ai.type.Part { + ctor public FunctionCallPart(String name, java.util.Map args); + ctor public FunctionCallPart(String name, java.util.Map args, String? id = null); + method public java.util.Map getArgs(); + method public String? getId(); + method public String getName(); + property public final java.util.Map args; + property public final String? id; + property public final String name; + } + + public final class FunctionCallingConfig { + method public static com.google.firebase.ai.type.FunctionCallingConfig any(); + method public static com.google.firebase.ai.type.FunctionCallingConfig any(java.util.List? allowedFunctionNames = null); + method public static com.google.firebase.ai.type.FunctionCallingConfig auto(); + method public static com.google.firebase.ai.type.FunctionCallingConfig none(); + field public static final com.google.firebase.ai.type.FunctionCallingConfig.Companion Companion; + } + + public static final class FunctionCallingConfig.Companion { + method public com.google.firebase.ai.type.FunctionCallingConfig any(); + method public com.google.firebase.ai.type.FunctionCallingConfig any(java.util.List? allowedFunctionNames = null); + method public com.google.firebase.ai.type.FunctionCallingConfig auto(); + method public com.google.firebase.ai.type.FunctionCallingConfig none(); + } + + public final class FunctionDeclaration { + ctor public FunctionDeclaration(String name, String description, java.util.Map parameters, java.util.List optionalParameters = emptyList()); + } + + public final class FunctionResponsePart implements com.google.firebase.ai.type.Part { + ctor public FunctionResponsePart(String name, kotlinx.serialization.json.JsonObject response); + ctor public FunctionResponsePart(String name, kotlinx.serialization.json.JsonObject response, String? id = null); + method public String? getId(); + method public String getName(); + method public kotlinx.serialization.json.JsonObject getResponse(); + property public final String? id; + property public final String name; + property public final kotlinx.serialization.json.JsonObject response; + } + + public final class GenerateContentResponse { + ctor public GenerateContentResponse(java.util.List candidates, com.google.firebase.ai.type.PromptFeedback? promptFeedback, com.google.firebase.ai.type.UsageMetadata? usageMetadata); + method public java.util.List getCandidates(); + method public java.util.List getFunctionCalls(); + method public java.util.List getInlineDataParts(); + method public com.google.firebase.ai.type.PromptFeedback? getPromptFeedback(); + method public String? getText(); + method public com.google.firebase.ai.type.UsageMetadata? getUsageMetadata(); + property public final java.util.List candidates; + property public final java.util.List functionCalls; + property public final java.util.List inlineDataParts; + property public final com.google.firebase.ai.type.PromptFeedback? promptFeedback; + property public final String? text; + property public final com.google.firebase.ai.type.UsageMetadata? usageMetadata; + } + + public final class GenerationConfig { + field public static final com.google.firebase.ai.type.GenerationConfig.Companion Companion; + } + + public static final class GenerationConfig.Builder { + ctor public GenerationConfig.Builder(); + method public com.google.firebase.ai.type.GenerationConfig build(); + method public com.google.firebase.ai.type.GenerationConfig.Builder setCandidateCount(Integer? candidateCount); + method public com.google.firebase.ai.type.GenerationConfig.Builder setFrequencyPenalty(Float? frequencyPenalty); + method public com.google.firebase.ai.type.GenerationConfig.Builder setMaxOutputTokens(Integer? maxOutputTokens); + method public com.google.firebase.ai.type.GenerationConfig.Builder setPresencePenalty(Float? presencePenalty); + method public com.google.firebase.ai.type.GenerationConfig.Builder setResponseMimeType(String? responseMimeType); + method public com.google.firebase.ai.type.GenerationConfig.Builder setResponseModalities(java.util.List? responseModalities); + method public com.google.firebase.ai.type.GenerationConfig.Builder setResponseSchema(com.google.firebase.ai.type.Schema? responseSchema); + method public com.google.firebase.ai.type.GenerationConfig.Builder setStopSequences(java.util.List? stopSequences); + method public com.google.firebase.ai.type.GenerationConfig.Builder setTemperature(Float? temperature); + method public com.google.firebase.ai.type.GenerationConfig.Builder setTopK(Integer? topK); + method public com.google.firebase.ai.type.GenerationConfig.Builder setTopP(Float? topP); + field public Integer? candidateCount; + field public Float? frequencyPenalty; + field public Integer? maxOutputTokens; + field public Float? presencePenalty; + field public String? responseMimeType; + field public java.util.List? responseModalities; + field public com.google.firebase.ai.type.Schema? responseSchema; + field public java.util.List? stopSequences; + field public Float? temperature; + field public Integer? topK; + field public Float? topP; + } + + public static final class GenerationConfig.Companion { + method public com.google.firebase.ai.type.GenerationConfig.Builder builder(); + } + + public final class GenerationConfigKt { + method public static com.google.firebase.ai.type.GenerationConfig generationConfig(kotlin.jvm.functions.Function1 init); + } + + public final class GenerativeBackend { + method public static com.google.firebase.ai.type.GenerativeBackend googleAI(); + method public static com.google.firebase.ai.type.GenerativeBackend vertexAI(); + method public static com.google.firebase.ai.type.GenerativeBackend vertexAI(String location = "us-central1"); + field public static final com.google.firebase.ai.type.GenerativeBackend.Companion Companion; + } + + public static final class GenerativeBackend.Companion { + method public com.google.firebase.ai.type.GenerativeBackend googleAI(); + method public com.google.firebase.ai.type.GenerativeBackend vertexAI(); + method public com.google.firebase.ai.type.GenerativeBackend vertexAI(String location = "us-central1"); + } + + public final class HarmBlockMethod { + method public int getOrdinal(); + property public final int ordinal; + field public static final com.google.firebase.ai.type.HarmBlockMethod.Companion Companion; + field public static final com.google.firebase.ai.type.HarmBlockMethod PROBABILITY; + field public static final com.google.firebase.ai.type.HarmBlockMethod SEVERITY; + } + + public static final class HarmBlockMethod.Companion { + } + + public final class HarmBlockThreshold { + method public int getOrdinal(); + property public final int ordinal; + field public static final com.google.firebase.ai.type.HarmBlockThreshold.Companion Companion; + field public static final com.google.firebase.ai.type.HarmBlockThreshold LOW_AND_ABOVE; + field public static final com.google.firebase.ai.type.HarmBlockThreshold MEDIUM_AND_ABOVE; + field public static final com.google.firebase.ai.type.HarmBlockThreshold NONE; + field public static final com.google.firebase.ai.type.HarmBlockThreshold OFF; + field public static final com.google.firebase.ai.type.HarmBlockThreshold ONLY_HIGH; + } + + public static final class HarmBlockThreshold.Companion { + } + + public final class HarmCategory { + method public int getOrdinal(); + property public final int ordinal; + field public static final com.google.firebase.ai.type.HarmCategory CIVIC_INTEGRITY; + field public static final com.google.firebase.ai.type.HarmCategory.Companion Companion; + field public static final com.google.firebase.ai.type.HarmCategory DANGEROUS_CONTENT; + field public static final com.google.firebase.ai.type.HarmCategory HARASSMENT; + field public static final com.google.firebase.ai.type.HarmCategory HATE_SPEECH; + field public static final com.google.firebase.ai.type.HarmCategory SEXUALLY_EXPLICIT; + field public static final com.google.firebase.ai.type.HarmCategory UNKNOWN; + } + + public static final class HarmCategory.Companion { + } + + public final class HarmProbability { + method public int getOrdinal(); + property public final int ordinal; + field public static final com.google.firebase.ai.type.HarmProbability.Companion Companion; + field public static final com.google.firebase.ai.type.HarmProbability HIGH; + field public static final com.google.firebase.ai.type.HarmProbability LOW; + field public static final com.google.firebase.ai.type.HarmProbability MEDIUM; + field public static final com.google.firebase.ai.type.HarmProbability NEGLIGIBLE; + field public static final com.google.firebase.ai.type.HarmProbability UNKNOWN; + } + + public static final class HarmProbability.Companion { + } + + public final class HarmSeverity { + method public int getOrdinal(); + property public final int ordinal; + field public static final com.google.firebase.ai.type.HarmSeverity.Companion Companion; + field public static final com.google.firebase.ai.type.HarmSeverity HIGH; + field public static final com.google.firebase.ai.type.HarmSeverity LOW; + field public static final com.google.firebase.ai.type.HarmSeverity MEDIUM; + field public static final com.google.firebase.ai.type.HarmSeverity NEGLIGIBLE; + field public static final com.google.firebase.ai.type.HarmSeverity UNKNOWN; + } + + public static final class HarmSeverity.Companion { + } + + public final class ImagePart implements com.google.firebase.ai.type.Part { + ctor public ImagePart(android.graphics.Bitmap image); + method public android.graphics.Bitmap getImage(); + property public final android.graphics.Bitmap image; + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class ImagenAspectRatio { + field public static final com.google.firebase.ai.type.ImagenAspectRatio.Companion Companion; + field public static final com.google.firebase.ai.type.ImagenAspectRatio LANDSCAPE_16x9; + field public static final com.google.firebase.ai.type.ImagenAspectRatio LANDSCAPE_4x3; + field public static final com.google.firebase.ai.type.ImagenAspectRatio PORTRAIT_3x4; + field public static final com.google.firebase.ai.type.ImagenAspectRatio PORTRAIT_9x16; + field public static final com.google.firebase.ai.type.ImagenAspectRatio SQUARE_1x1; + } + + public static final class ImagenAspectRatio.Companion { + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class ImagenGenerationConfig { + ctor public ImagenGenerationConfig(String? negativePrompt = null, Integer? numberOfImages = 1, com.google.firebase.ai.type.ImagenAspectRatio? aspectRatio = null, com.google.firebase.ai.type.ImagenImageFormat? imageFormat = null, Boolean? addWatermark = null); + method public Boolean? getAddWatermark(); + method public com.google.firebase.ai.type.ImagenAspectRatio? getAspectRatio(); + method public com.google.firebase.ai.type.ImagenImageFormat? getImageFormat(); + method public String? getNegativePrompt(); + method public Integer? getNumberOfImages(); + property public final Boolean? addWatermark; + property public final com.google.firebase.ai.type.ImagenAspectRatio? aspectRatio; + property public final com.google.firebase.ai.type.ImagenImageFormat? imageFormat; + property public final String? negativePrompt; + property public final Integer? numberOfImages; + field public static final com.google.firebase.ai.type.ImagenGenerationConfig.Companion Companion; + } + + public static final class ImagenGenerationConfig.Builder { + ctor public ImagenGenerationConfig.Builder(); + method public com.google.firebase.ai.type.ImagenGenerationConfig build(); + method public com.google.firebase.ai.type.ImagenGenerationConfig.Builder setAddWatermark(boolean addWatermark); + method public com.google.firebase.ai.type.ImagenGenerationConfig.Builder setAspectRatio(com.google.firebase.ai.type.ImagenAspectRatio aspectRatio); + method public com.google.firebase.ai.type.ImagenGenerationConfig.Builder setImageFormat(com.google.firebase.ai.type.ImagenImageFormat imageFormat); + method public com.google.firebase.ai.type.ImagenGenerationConfig.Builder setNegativePrompt(String negativePrompt); + method public com.google.firebase.ai.type.ImagenGenerationConfig.Builder setNumberOfImages(int numberOfImages); + field public Boolean? addWatermark; + field public com.google.firebase.ai.type.ImagenAspectRatio? aspectRatio; + field public com.google.firebase.ai.type.ImagenImageFormat? imageFormat; + field public String? negativePrompt; + field public Integer? numberOfImages; + } + + public static final class ImagenGenerationConfig.Companion { + method public com.google.firebase.ai.type.ImagenGenerationConfig.Builder builder(); + } + + public final class ImagenGenerationConfigKt { + method @com.google.firebase.ai.type.PublicPreviewAPI public static com.google.firebase.ai.type.ImagenGenerationConfig imagenGenerationConfig(kotlin.jvm.functions.Function1 init); + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class ImagenGenerationResponse { + method public String? getFilteredReason(); + method public java.util.List getImages(); + property public final String? filteredReason; + property public final java.util.List images; + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class ImagenImageFormat { + method public Integer? getCompressionQuality(); + method public String getMimeType(); + method public static com.google.firebase.ai.type.ImagenImageFormat jpeg(Integer? compressionQuality = null); + method public static com.google.firebase.ai.type.ImagenImageFormat png(); + property public final Integer? compressionQuality; + property public final String mimeType; + field public static final com.google.firebase.ai.type.ImagenImageFormat.Companion Companion; + } + + public static final class ImagenImageFormat.Companion { + method public com.google.firebase.ai.type.ImagenImageFormat jpeg(Integer? compressionQuality = null); + method public com.google.firebase.ai.type.ImagenImageFormat png(); + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class ImagenInlineImage { + method public android.graphics.Bitmap asBitmap(); + method public byte[] getData(); + method public String getMimeType(); + property public final byte[] data; + property public final String mimeType; + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class ImagenPersonFilterLevel { + field public static final com.google.firebase.ai.type.ImagenPersonFilterLevel ALLOW_ADULT; + field public static final com.google.firebase.ai.type.ImagenPersonFilterLevel ALLOW_ALL; + field public static final com.google.firebase.ai.type.ImagenPersonFilterLevel BLOCK_ALL; + field public static final com.google.firebase.ai.type.ImagenPersonFilterLevel.Companion Companion; + } + + public static final class ImagenPersonFilterLevel.Companion { + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class ImagenSafetyFilterLevel { + field public static final com.google.firebase.ai.type.ImagenSafetyFilterLevel BLOCK_LOW_AND_ABOVE; + field public static final com.google.firebase.ai.type.ImagenSafetyFilterLevel BLOCK_MEDIUM_AND_ABOVE; + field public static final com.google.firebase.ai.type.ImagenSafetyFilterLevel BLOCK_NONE; + field public static final com.google.firebase.ai.type.ImagenSafetyFilterLevel BLOCK_ONLY_HIGH; + field public static final com.google.firebase.ai.type.ImagenSafetyFilterLevel.Companion Companion; + } + + public static final class ImagenSafetyFilterLevel.Companion { + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class ImagenSafetySettings { + ctor public ImagenSafetySettings(com.google.firebase.ai.type.ImagenSafetyFilterLevel safetyFilterLevel, com.google.firebase.ai.type.ImagenPersonFilterLevel personFilterLevel); + } + + public final class InlineDataPart implements com.google.firebase.ai.type.Part { + ctor public InlineDataPart(byte[] inlineData, String mimeType); + method public byte[] getInlineData(); + method public String getMimeType(); + property public final byte[] inlineData; + property public final String mimeType; + } + + public final class InvalidAPIKeyException extends com.google.firebase.ai.type.FirebaseAIException { + } + + public final class InvalidLocationException extends com.google.firebase.ai.type.FirebaseAIException { + } + + public final class InvalidStateException extends com.google.firebase.ai.type.FirebaseAIException { + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class LiveGenerationConfig { + field public static final com.google.firebase.ai.type.LiveGenerationConfig.Companion Companion; + } + + public static final class LiveGenerationConfig.Builder { + ctor public LiveGenerationConfig.Builder(); + method public com.google.firebase.ai.type.LiveGenerationConfig build(); + method public com.google.firebase.ai.type.LiveGenerationConfig.Builder setCandidateCount(Integer? candidateCount); + method public com.google.firebase.ai.type.LiveGenerationConfig.Builder setFrequencyPenalty(Float? frequencyPenalty); + method public com.google.firebase.ai.type.LiveGenerationConfig.Builder setMaxOutputTokens(Integer? maxOutputTokens); + method public com.google.firebase.ai.type.LiveGenerationConfig.Builder setPresencePenalty(Float? presencePenalty); + method public com.google.firebase.ai.type.LiveGenerationConfig.Builder setResponseModality(com.google.firebase.ai.type.ResponseModality? responseModality); + method public com.google.firebase.ai.type.LiveGenerationConfig.Builder setSpeechConfig(com.google.firebase.ai.type.SpeechConfig? speechConfig); + method public com.google.firebase.ai.type.LiveGenerationConfig.Builder setTemperature(Float? temperature); + method public com.google.firebase.ai.type.LiveGenerationConfig.Builder setTopK(Integer? topK); + method public com.google.firebase.ai.type.LiveGenerationConfig.Builder setTopP(Float? topP); + field public Integer? candidateCount; + field public Float? frequencyPenalty; + field public Integer? maxOutputTokens; + field public Float? presencePenalty; + field public com.google.firebase.ai.type.ResponseModality? responseModality; + field public com.google.firebase.ai.type.SpeechConfig? speechConfig; + field public Float? temperature; + field public Integer? topK; + field public Float? topP; + } + + public static final class LiveGenerationConfig.Companion { + method public com.google.firebase.ai.type.LiveGenerationConfig.Builder builder(); + } + + public final class LiveGenerationConfigKt { + method public static com.google.firebase.ai.type.LiveGenerationConfig liveGenerationConfig(kotlin.jvm.functions.Function1 init); + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class LiveServerContent implements com.google.firebase.ai.type.LiveServerMessage { + ctor public LiveServerContent(com.google.firebase.ai.type.Content? content, boolean interrupted, boolean turnComplete, boolean generationComplete); + method public com.google.firebase.ai.type.Content? getContent(); + method public boolean getGenerationComplete(); + method public boolean getInterrupted(); + method public boolean getTurnComplete(); + property public final com.google.firebase.ai.type.Content? content; + property public final boolean generationComplete; + property public final boolean interrupted; + property public final boolean turnComplete; + } + + @com.google.firebase.ai.type.PublicPreviewAPI public interface LiveServerMessage { + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class LiveServerSetupComplete implements com.google.firebase.ai.type.LiveServerMessage { + ctor public LiveServerSetupComplete(); + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class LiveServerToolCall implements com.google.firebase.ai.type.LiveServerMessage { + ctor public LiveServerToolCall(java.util.List functionCalls); + method public java.util.List getFunctionCalls(); + property public final java.util.List functionCalls; + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class LiveServerToolCallCancellation implements com.google.firebase.ai.type.LiveServerMessage { + ctor public LiveServerToolCallCancellation(java.util.List functionIds); + method public java.util.List getFunctionIds(); + property public final java.util.List functionIds; + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class LiveSession { + method public suspend Object? close(kotlin.coroutines.Continuation); + method public kotlinx.coroutines.flow.Flow receive(); + method public suspend Object? send(com.google.firebase.ai.type.Content content, kotlin.coroutines.Continuation); + method public suspend Object? send(String text, kotlin.coroutines.Continuation); + method public suspend Object? sendFunctionResponse(java.util.List functionList, kotlin.coroutines.Continuation); + method public suspend Object? sendMediaStream(java.util.List mediaChunks, kotlin.coroutines.Continuation); + method @RequiresPermission(android.Manifest.permission.RECORD_AUDIO) public suspend Object? startAudioConversation(kotlin.jvm.functions.Function1? functionCallHandler = null, kotlin.coroutines.Continuation); + method public void stopAudioConversation(); + method public void stopReceiving(); + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class MediaData { + ctor public MediaData(byte[] data, String mimeType); + method public byte[] getData(); + method public String getMimeType(); + property public final byte[] data; + property public final String mimeType; + } + + public final class ModalityTokenCount { + method public operator com.google.firebase.ai.type.ContentModality component1(); + method public operator int component2(); + method public com.google.firebase.ai.type.ContentModality getModality(); + method public int getTokenCount(); + property public final com.google.firebase.ai.type.ContentModality modality; + property public final int tokenCount; + } + + public interface Part { + } + + public final class PartKt { + method public static com.google.firebase.ai.type.FileDataPart? asFileDataOrNull(com.google.firebase.ai.type.Part); + method public static android.graphics.Bitmap? asImageOrNull(com.google.firebase.ai.type.Part); + method public static com.google.firebase.ai.type.InlineDataPart? asInlineDataPartOrNull(com.google.firebase.ai.type.Part); + method public static String? asTextOrNull(com.google.firebase.ai.type.Part); + } + + public final class PromptBlockedException extends com.google.firebase.ai.type.FirebaseAIException { + method public com.google.firebase.ai.type.GenerateContentResponse? getResponse(); + property public final com.google.firebase.ai.type.GenerateContentResponse? response; + } + + public final class PromptFeedback { + ctor public PromptFeedback(com.google.firebase.ai.type.BlockReason? blockReason, java.util.List safetyRatings, String? blockReasonMessage); + method public com.google.firebase.ai.type.BlockReason? getBlockReason(); + method public String? getBlockReasonMessage(); + method public java.util.List getSafetyRatings(); + property public final com.google.firebase.ai.type.BlockReason? blockReason; + property public final String? blockReasonMessage; + property public final java.util.List safetyRatings; + } + + @kotlin.RequiresOptIn(level=kotlin.RequiresOptIn.Level.ERROR, message="This API is part of an experimental public preview and may change in " + "backwards-incompatible ways without notice.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface PublicPreviewAPI { + } + + public final class QuotaExceededException extends com.google.firebase.ai.type.FirebaseAIException { + } + + public final class RequestOptions { + ctor public RequestOptions(); + ctor public RequestOptions(long timeoutInMillis = 180.seconds.inWholeMilliseconds); + } + + public final class RequestTimeoutException extends com.google.firebase.ai.type.FirebaseAIException { + } + + public final class ResponseModality { + method public int getOrdinal(); + property public final int ordinal; + field public static final com.google.firebase.ai.type.ResponseModality AUDIO; + field public static final com.google.firebase.ai.type.ResponseModality.Companion Companion; + field public static final com.google.firebase.ai.type.ResponseModality IMAGE; + field public static final com.google.firebase.ai.type.ResponseModality TEXT; + } + + public static final class ResponseModality.Companion { + } + + public final class ResponseStoppedException extends com.google.firebase.ai.type.FirebaseAIException { + method public com.google.firebase.ai.type.GenerateContentResponse getResponse(); + property public final com.google.firebase.ai.type.GenerateContentResponse response; + } + + public final class SafetyRating { + method public Boolean? getBlocked(); + method public com.google.firebase.ai.type.HarmCategory getCategory(); + method public com.google.firebase.ai.type.HarmProbability getProbability(); + method public float getProbabilityScore(); + method public com.google.firebase.ai.type.HarmSeverity? getSeverity(); + method public Float? getSeverityScore(); + property public final Boolean? blocked; + property public final com.google.firebase.ai.type.HarmCategory category; + property public final com.google.firebase.ai.type.HarmProbability probability; + property public final float probabilityScore; + property public final com.google.firebase.ai.type.HarmSeverity? severity; + property public final Float? severityScore; + } + + public final class SafetySetting { + ctor public SafetySetting(com.google.firebase.ai.type.HarmCategory harmCategory, com.google.firebase.ai.type.HarmBlockThreshold threshold, com.google.firebase.ai.type.HarmBlockMethod? method = null); + } + + public final class Schema { + method public static com.google.firebase.ai.type.Schema array(com.google.firebase.ai.type.Schema items); + method public static com.google.firebase.ai.type.Schema array(com.google.firebase.ai.type.Schema items, String? description = null); + method public static com.google.firebase.ai.type.Schema array(com.google.firebase.ai.type.Schema items, String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.Schema boolean(); + method public static com.google.firebase.ai.type.Schema boolean(String? description = null); + method public static com.google.firebase.ai.type.Schema boolean(String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.Schema enumeration(java.util.List values); + method public static com.google.firebase.ai.type.Schema enumeration(java.util.List values, String? description = null); + method public static com.google.firebase.ai.type.Schema enumeration(java.util.List values, String? description = null, boolean nullable = false); + method public String? getDescription(); + method public java.util.List? getEnum(); + method public String? getFormat(); + method public com.google.firebase.ai.type.Schema? getItems(); + method public Boolean? getNullable(); + method public java.util.Map? getProperties(); + method public java.util.List? getRequired(); + method public String getType(); + method public static com.google.firebase.ai.type.Schema numDouble(); + method public static com.google.firebase.ai.type.Schema numDouble(String? description = null); + method public static com.google.firebase.ai.type.Schema numDouble(String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.Schema numFloat(); + method public static com.google.firebase.ai.type.Schema numFloat(String? description = null); + method public static com.google.firebase.ai.type.Schema numFloat(String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.Schema numInt(); + method public static com.google.firebase.ai.type.Schema numInt(String? description = null); + method public static com.google.firebase.ai.type.Schema numInt(String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.Schema numLong(); + method public static com.google.firebase.ai.type.Schema numLong(String? description = null); + method public static com.google.firebase.ai.type.Schema numLong(String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.Schema obj(java.util.Map properties); + method public static com.google.firebase.ai.type.Schema obj(java.util.Map properties, java.util.List optionalProperties = emptyList()); + method public static com.google.firebase.ai.type.Schema obj(java.util.Map properties, java.util.List optionalProperties = emptyList(), String? description = null); + method public static com.google.firebase.ai.type.Schema obj(java.util.Map properties, java.util.List optionalProperties = emptyList(), String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.Schema str(); + method public static com.google.firebase.ai.type.Schema str(String? description = null); + method public static com.google.firebase.ai.type.Schema str(String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.Schema str(String? description = null, boolean nullable = false, com.google.firebase.ai.type.StringFormat? format = null); + property public final String? description; + property public final java.util.List? enum; + property public final String? format; + property public final com.google.firebase.ai.type.Schema? items; + property public final Boolean? nullable; + property public final java.util.Map? properties; + property public final java.util.List? required; + property public final String type; + field public static final com.google.firebase.ai.type.Schema.Companion Companion; + } + + public static final class Schema.Companion { + method public com.google.firebase.ai.type.Schema array(com.google.firebase.ai.type.Schema items); + method public com.google.firebase.ai.type.Schema array(com.google.firebase.ai.type.Schema items, String? description = null); + method public com.google.firebase.ai.type.Schema array(com.google.firebase.ai.type.Schema items, String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.Schema boolean(); + method public com.google.firebase.ai.type.Schema boolean(String? description = null); + method public com.google.firebase.ai.type.Schema boolean(String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.Schema enumeration(java.util.List values); + method public com.google.firebase.ai.type.Schema enumeration(java.util.List values, String? description = null); + method public com.google.firebase.ai.type.Schema enumeration(java.util.List values, String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.Schema numDouble(); + method public com.google.firebase.ai.type.Schema numDouble(String? description = null); + method public com.google.firebase.ai.type.Schema numDouble(String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.Schema numFloat(); + method public com.google.firebase.ai.type.Schema numFloat(String? description = null); + method public com.google.firebase.ai.type.Schema numFloat(String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.Schema numInt(); + method public com.google.firebase.ai.type.Schema numInt(String? description = null); + method public com.google.firebase.ai.type.Schema numInt(String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.Schema numLong(); + method public com.google.firebase.ai.type.Schema numLong(String? description = null); + method public com.google.firebase.ai.type.Schema numLong(String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.Schema obj(java.util.Map properties); + method public com.google.firebase.ai.type.Schema obj(java.util.Map properties, java.util.List optionalProperties = emptyList()); + method public com.google.firebase.ai.type.Schema obj(java.util.Map properties, java.util.List optionalProperties = emptyList(), String? description = null); + method public com.google.firebase.ai.type.Schema obj(java.util.Map properties, java.util.List optionalProperties = emptyList(), String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.Schema str(); + method public com.google.firebase.ai.type.Schema str(String? description = null); + method public com.google.firebase.ai.type.Schema str(String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.Schema str(String? description = null, boolean nullable = false, com.google.firebase.ai.type.StringFormat? format = null); + } + + public final class SerializationException extends com.google.firebase.ai.type.FirebaseAIException { + } + + public final class ServerException extends com.google.firebase.ai.type.FirebaseAIException { + } + + public final class ServiceConnectionHandshakeFailedException extends com.google.firebase.ai.type.FirebaseAIException { + ctor public ServiceConnectionHandshakeFailedException(String message, Throwable? cause = null); + } + + public final class ServiceDisabledException extends com.google.firebase.ai.type.FirebaseAIException { + } + + public final class SessionAlreadyReceivingException extends com.google.firebase.ai.type.FirebaseAIException { + ctor public SessionAlreadyReceivingException(); + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class SpeechConfig { + ctor public SpeechConfig(com.google.firebase.ai.type.Voice voice); + method public com.google.firebase.ai.type.Voice getVoice(); + property public final com.google.firebase.ai.type.Voice voice; + } + + public abstract class StringFormat { + } + + public static final class StringFormat.Custom extends com.google.firebase.ai.type.StringFormat { + ctor public StringFormat.Custom(String value); + } + + public final class TextPart implements com.google.firebase.ai.type.Part { + ctor public TextPart(String text); + method public String getText(); + property public final String text; + } + + public final class Tool { + method public static com.google.firebase.ai.type.Tool functionDeclarations(java.util.List functionDeclarations); + field public static final com.google.firebase.ai.type.Tool.Companion Companion; + } + + public static final class Tool.Companion { + method public com.google.firebase.ai.type.Tool functionDeclarations(java.util.List functionDeclarations); + } + + public final class ToolConfig { + ctor public ToolConfig(com.google.firebase.ai.type.FunctionCallingConfig? functionCallingConfig); + } + + public final class UnknownException extends com.google.firebase.ai.type.FirebaseAIException { + } + + public final class UnsupportedUserLocationException extends com.google.firebase.ai.type.FirebaseAIException { + } + + public final class UsageMetadata { + ctor public UsageMetadata(int promptTokenCount, Integer? candidatesTokenCount, int totalTokenCount, java.util.List promptTokensDetails, java.util.List candidatesTokensDetails); + method public Integer? getCandidatesTokenCount(); + method public java.util.List getCandidatesTokensDetails(); + method public int getPromptTokenCount(); + method public java.util.List getPromptTokensDetails(); + method public int getTotalTokenCount(); + property public final Integer? candidatesTokenCount; + property public final java.util.List candidatesTokensDetails; + property public final int promptTokenCount; + property public final java.util.List promptTokensDetails; + property public final int totalTokenCount; + } + + @com.google.firebase.ai.type.PublicPreviewAPI public final class Voice { + ctor public Voice(String voiceName); + method public String getVoiceName(); + property public final String voiceName; + } + + @Deprecated @com.google.firebase.ai.type.PublicPreviewAPI public final class Voices { + method @Deprecated public int getOrdinal(); + property @Deprecated public final int ordinal; + field @Deprecated public static final com.google.firebase.ai.type.Voices AOEDE; + field @Deprecated public static final com.google.firebase.ai.type.Voices CHARON; + field @Deprecated public static final com.google.firebase.ai.type.Voices.Companion Companion; + field @Deprecated public static final com.google.firebase.ai.type.Voices FENRIR; + field @Deprecated public static final com.google.firebase.ai.type.Voices KORE; + field @Deprecated public static final com.google.firebase.ai.type.Voices PUCK; + field @Deprecated public static final com.google.firebase.ai.type.Voices UNSPECIFIED; + } + + @Deprecated public static final class Voices.Companion { + } + +} + diff --git a/firebase-ai/consumer-rules.pro b/firebase-ai/consumer-rules.pro new file mode 100644 index 00000000000..b5225e0c05e --- /dev/null +++ b/firebase-ai/consumer-rules.pro @@ -0,0 +1,24 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keep class com.google.firebase.ai.type.** { *; } +-keep class com.google.firebase.ai.common.** { *; } diff --git a/firebase-ai/firebase-ai.gradle.kts b/firebase-ai/firebase-ai.gradle.kts new file mode 100644 index 00000000000..de29f44b0a1 --- /dev/null +++ b/firebase-ai/firebase-ai.gradle.kts @@ -0,0 +1,129 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("UnstableApiUsage") + +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("firebase-library") + id("kotlin-android") + alias(libs.plugins.kotlinx.serialization) +} + +firebaseLibrary { + testLab.enabled = false + publishJavadoc = true + releaseNotes { + name.set("{{firebase_ai}}") + versionName.set("ai") + hasKTX.set(false) + } +} + +android { + val targetSdkVersion: Int by rootProject + + namespace = "com.google.firebase.ai" + compileSdk = 34 + defaultConfig { + minSdk = 21 + consumerProguardFiles("consumer-rules.pro") + multiDexEnabled = true + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { jvmTarget = "1.8" } + testOptions { + targetSdk = targetSdkVersion + unitTests { + isIncludeAndroidResources = true + isReturnDefaultValues = true + } + } + lint { + targetSdk = targetSdkVersion + baseline = file("lint-baseline.xml") + } + sourceSets { getByName("test").java.srcDirs("src/testUtil") } +} + +// Enable Kotlin "Explicit API Mode". This causes the Kotlin compiler to fail if any +// classes, methods, or properties have implicit `public` visibility. This check helps +// avoid accidentally leaking elements into the public API, requiring that any public +// element be explicitly declared as `public`. +// https://github.com/Kotlin/KEEP/blob/master/proposals/explicit-api-mode.md +// https://chao2zhang.medium.com/explicit-api-mode-for-kotlin-on-android-b8264fdd76d1 +tasks.withType().all { + if (!name.contains("test", ignoreCase = true)) { + if (!kotlinOptions.freeCompilerArgs.contains("-Xexplicit-api=strict")) { + kotlinOptions.freeCompilerArgs += "-Xexplicit-api=strict" + } + } +} + +dependencies { + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.websockets) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.logging) + + api("com.google.firebase:firebase-common:21.0.0") + implementation("com.google.firebase:firebase-components:18.0.0") + implementation("com.google.firebase:firebase-annotations:16.2.0") + implementation("com.google.firebase:firebase-appcheck-interop:17.1.0") + implementation(libs.androidx.annotation) + implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.core.ktx) + implementation(libs.slf4j.nop) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.reactive) + implementation(libs.reactive.streams) + implementation("com.google.guava:listenablefuture:1.0") + implementation("androidx.concurrent:concurrent-futures:1.2.0") + implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0") + implementation("com.google.firebase:firebase-auth-interop:18.0.0") + + testImplementation(libs.kotest.assertions.core) + testImplementation(libs.kotest.assertions) + testImplementation(libs.kotest.assertions.json) + testImplementation(libs.ktor.client.okhttp) + testImplementation(libs.ktor.client.mock) + testImplementation(libs.org.json) + testImplementation(libs.androidx.test.junit) + testImplementation(libs.androidx.test.runner) + testImplementation(libs.junit) + testImplementation(libs.kotlin.coroutines.test) + testImplementation(libs.robolectric) + testImplementation(libs.truth) + testImplementation(libs.mockito.core) + + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.truth) +} diff --git a/firebase-ai/gradle.properties b/firebase-ai/gradle.properties new file mode 100644 index 00000000000..1c7c87996dd --- /dev/null +++ b/firebase-ai/gradle.properties @@ -0,0 +1,16 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +version=16.1.0 +latestReleasedVersion=16.0.0 diff --git a/firebase-ai/lint-baseline.xml b/firebase-ai/lint-baseline.xml new file mode 100644 index 00000000000..3848b6c0e9f --- /dev/null +++ b/firebase-ai/lint-baseline.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/firebase-ai/proguard-rules.pro b/firebase-ai/proguard-rules.pro new file mode 100644 index 00000000000..f1b424510da --- /dev/null +++ b/firebase-ai/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/firebase-ai/src/main/AndroidManifest.xml b/firebase-ai/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..8b668fbb5a4 --- /dev/null +++ b/firebase-ai/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt new file mode 100644 index 00000000000..13599fb1c9a --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt @@ -0,0 +1,224 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai + +import android.graphics.Bitmap +import com.google.firebase.ai.type.Content +import com.google.firebase.ai.type.GenerateContentResponse +import com.google.firebase.ai.type.ImagePart +import com.google.firebase.ai.type.InlineDataPart +import com.google.firebase.ai.type.InvalidStateException +import com.google.firebase.ai.type.TextPart +import com.google.firebase.ai.type.content +import java.util.LinkedList +import java.util.concurrent.Semaphore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach + +/** + * Representation of a multi-turn interaction with a model. + * + * Captures and stores the history of communication in memory, and provides it as context with each + * new message. + * + * **Note:** This object is not thread-safe, and calling [sendMessage] multiple times without + * waiting for a response will throw an [InvalidStateException]. + * + * @param model The model to use for the interaction. + * @property history The previous content from the chat that has been successfully sent and received + * from the model. This will be provided to the model for each message sent (as context for the + * discussion). + */ +public class Chat( + private val model: GenerativeModel, + public val history: MutableList = ArrayList() +) { + private var lock = Semaphore(1) + + /** + * Sends a message using the provided [prompt]; automatically providing the existing [history] as + * context. + * + * If successful, the message and response will be added to the [history]. If unsuccessful, + * [history] will remain unchanged. + * + * @param prompt The input that, together with the history, will be given to the model as the + * prompt. + * @throws InvalidStateException if [prompt] is not coming from the 'user' role. + * @throws InvalidStateException if the [Chat] instance has an active request. + */ + public suspend fun sendMessage(prompt: Content): GenerateContentResponse { + prompt.assertComesFromUser() + attemptLock() + try { + val response = model.generateContent(*history.toTypedArray(), prompt) + history.add(prompt) + history.add(response.candidates.first().content) + return response + } finally { + lock.release() + } + } + + /** + * Sends a message using the provided [text prompt][prompt]; automatically providing the existing + * [history] as context. + * + * If successful, the message and response will be added to the [history]. If unsuccessful, + * [history] will remain unchanged. + * + * @param prompt The input that, together with the history, will be given to the model as the + * prompt. + * @throws InvalidStateException if [prompt] is not coming from the 'user' role. + * @throws InvalidStateException if the [Chat] instance has an active request. + */ + public suspend fun sendMessage(prompt: String): GenerateContentResponse { + val content = content { text(prompt) } + return sendMessage(content) + } + + /** + * Sends a message using the existing history of this chat as context and the provided image + * prompt. + * + * If successful, the message and response will be added to the history. If unsuccessful, history + * will remain unchanged. + * + * @param prompt The input that, together with the history, will be given to the model as the + * prompt. + * @throws InvalidStateException if [prompt] is not coming from the 'user' role. + * @throws InvalidStateException if the [Chat] instance has an active request. + */ + public suspend fun sendMessage(prompt: Bitmap): GenerateContentResponse { + val content = content { image(prompt) } + return sendMessage(content) + } + + /** + * Sends a message using the existing history of this chat as context and the provided [Content] + * prompt. + * + * The response from the model is returned as a stream. + * + * If successful, the message and response will be added to the history. If unsuccessful, history + * will remain unchanged. + * + * @param prompt The input that, together with the history, will be given to the model as the + * prompt. + * @throws InvalidStateException if [prompt] is not coming from the 'user' role. + * @throws InvalidStateException if the [Chat] instance has an active request. + */ + public fun sendMessageStream(prompt: Content): Flow { + prompt.assertComesFromUser() + attemptLock() + + val flow = model.generateContentStream(*history.toTypedArray(), prompt) + val bitmaps = LinkedList() + val inlineDataParts = LinkedList() + val text = StringBuilder() + + /** + * TODO: revisit when images and inline data are returned. This will cause issues with how + * things are structured in the response. eg; a text/image/text response will be (incorrectly) + * represented as image/text + */ + return flow + .onEach { + for (part in it.candidates.first().content.parts) { + when (part) { + is TextPart -> text.append(part.text) + is ImagePart -> bitmaps.add(part.image) + is InlineDataPart -> inlineDataParts.add(part) + } + } + } + .onCompletion { + lock.release() + if (it == null) { + val content = + content("model") { + for (bitmap in bitmaps) { + image(bitmap) + } + for (inlineDataPart in inlineDataParts) { + inlineData(inlineDataPart.inlineData, inlineDataPart.mimeType) + } + if (text.isNotBlank()) { + text(text.toString()) + } + } + + history.add(prompt) + history.add(content) + } + } + } + + /** + * Sends a message using the existing history of this chat as context and the provided text + * prompt. + * + * The response from the model is returned as a stream. + * + * If successful, the message and response will be added to the history. If unsuccessful, history + * will remain unchanged. + * + * @param prompt The input(s) that, together with the history, will be given to the model as the + * prompt. + * @throws InvalidStateException if [prompt] is not coming from the 'user' role. + * @throws InvalidStateException if the [Chat] instance has an active request. + */ + public fun sendMessageStream(prompt: String): Flow { + val content = content { text(prompt) } + return sendMessageStream(content) + } + + /** + * Sends a message using the existing history of this chat as context and the provided image + * prompt. + * + * The response from the model is returned as a stream. + * + * If successful, the message and response will be added to the history. If unsuccessful, history + * will remain unchanged. + * + * @param prompt The input that, together with the history, will be given to the model as the + * prompt. + * @throws InvalidStateException if [prompt] is not coming from the 'user' role. + * @throws InvalidStateException if the [Chat] instance has an active request. + */ + public fun sendMessageStream(prompt: Bitmap): Flow { + val content = content { image(prompt) } + return sendMessageStream(content) + } + + private fun Content.assertComesFromUser() { + if (role !in listOf("user", "function")) { + throw InvalidStateException("Chat prompts should come from the 'user' or 'function' role.") + } + } + + private fun attemptLock() { + if (!lock.tryAcquire()) { + throw InvalidStateException( + "This chat instance currently has an ongoing request, please wait for it to complete " + + "before sending more messages" + ) + } + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/FirebaseAI.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/FirebaseAI.kt new file mode 100644 index 00000000000..86eb8057b1d --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/FirebaseAI.kt @@ -0,0 +1,247 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai + +import android.util.Log +import com.google.firebase.Firebase +import com.google.firebase.FirebaseApp +import com.google.firebase.ai.type.Content +import com.google.firebase.ai.type.GenerationConfig +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.GenerativeBackendEnum +import com.google.firebase.ai.type.ImagenGenerationConfig +import com.google.firebase.ai.type.ImagenSafetySettings +import com.google.firebase.ai.type.InvalidStateException +import com.google.firebase.ai.type.LiveGenerationConfig +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.RequestOptions +import com.google.firebase.ai.type.SafetySetting +import com.google.firebase.ai.type.Tool +import com.google.firebase.ai.type.ToolConfig +import com.google.firebase.annotations.concurrent.Blocking +import com.google.firebase.app +import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider +import com.google.firebase.auth.internal.InternalAuthProvider +import com.google.firebase.inject.Provider +import kotlin.coroutines.CoroutineContext + +/** Entry point for all _Firebase AI_ functionality. */ +public class FirebaseAI +internal constructor( + private val firebaseApp: FirebaseApp, + private val backend: GenerativeBackend, + @Blocking private val blockingDispatcher: CoroutineContext, + private val appCheckProvider: Provider, + private val internalAuthProvider: Provider, +) { + + /** + * Instantiates a new [GenerativeModel] given the provided parameters. + * + * @param modelName The name of the model to use, for example `"gemini-2.0-flash-exp"`. + * @param generationConfig The configuration parameters to use for content generation. + * @param safetySettings The safety bounds the model will abide to during content generation. + * @param tools A list of [Tool]s the model may use to generate content. + * @param toolConfig The [ToolConfig] that defines how the model handles the tools provided. + * @param systemInstruction [Content] instructions that direct the model to behave a certain way. + * Currently only text content is supported. + * @param requestOptions Configuration options for sending requests to the backend. + * @return The initialized [GenerativeModel] instance. + */ + @JvmOverloads + public fun generativeModel( + modelName: String, + generationConfig: GenerationConfig? = null, + safetySettings: List? = null, + tools: List? = null, + toolConfig: ToolConfig? = null, + systemInstruction: Content? = null, + requestOptions: RequestOptions = RequestOptions(), + ): GenerativeModel { + val modelUri = + when (backend.backend) { + GenerativeBackendEnum.VERTEX_AI -> + "projects/${firebaseApp.options.projectId}/locations/${backend.location}/publishers/google/models/${modelName}" + GenerativeBackendEnum.GOOGLE_AI -> + "projects/${firebaseApp.options.projectId}/models/${modelName}" + } + if (!modelName.startsWith(GEMINI_MODEL_NAME_PREFIX)) { + Log.w( + TAG, + """Unsupported Gemini model "${modelName}"; see + https://firebase.google.com/docs/vertex-ai/models for a list supported Gemini model names. + """ + .trimIndent(), + ) + } + return GenerativeModel( + modelUri, + firebaseApp.options.apiKey, + firebaseApp, + generationConfig, + safetySettings, + tools, + toolConfig, + systemInstruction, + requestOptions, + backend, + appCheckProvider.get(), + internalAuthProvider.get(), + ) + } + + /** + * Instantiates a new [LiveGenerationConfig] given the provided parameters. + * + * @param modelName The name of the model to use, for example `"gemini-2.0-flash-exp"`. + * @param generationConfig The configuration parameters to use for content generation. + * @param tools A list of [Tool]s the model may use to generate content. + * @param systemInstruction [Content] instructions that direct the model to behave a certain way. + * Currently only text content is supported. + * @param requestOptions Configuration options for sending requests to the backend. + * @return The initialized [LiveGenerativeModel] instance. + */ + @JvmOverloads + @PublicPreviewAPI + public fun liveModel( + modelName: String, + generationConfig: LiveGenerationConfig? = null, + tools: List? = null, + systemInstruction: Content? = null, + requestOptions: RequestOptions = RequestOptions(), + ): LiveGenerativeModel { + if (!modelName.startsWith(GEMINI_MODEL_NAME_PREFIX)) { + Log.w( + TAG, + """Unsupported Gemini model "$modelName"; see + https://firebase.google.com/docs/vertex-ai/models for a list supported Gemini model names. + """ + .trimIndent(), + ) + } + return LiveGenerativeModel( + when (backend.backend) { + GenerativeBackendEnum.VERTEX_AI -> + "projects/${firebaseApp.options.projectId}/locations/${backend.location}/publishers/google/models/${modelName}" + GenerativeBackendEnum.GOOGLE_AI -> + throw InvalidStateException("Live Model is not yet available on the Google AI backend") + }, + firebaseApp.options.apiKey, + firebaseApp, + blockingDispatcher, + generationConfig, + tools, + systemInstruction, + backend.location, + requestOptions, + appCheckProvider.get(), + internalAuthProvider.get(), + ) + } + + /** + * Instantiates a new [ImagenModel] given the provided parameters. + * + * @param modelName The name of the model to use, for example `"imagen-3.0-generate-001"`. + * @param generationConfig The configuration parameters to use for image generation. + * @param safetySettings The safety bounds the model will abide by during image generation. + * @param requestOptions Configuration options for sending requests to the backend. + * @return The initialized [ImagenModel] instance. + */ + @JvmOverloads + @PublicPreviewAPI + public fun imagenModel( + modelName: String, + generationConfig: ImagenGenerationConfig? = null, + safetySettings: ImagenSafetySettings? = null, + requestOptions: RequestOptions = RequestOptions(), + ): ImagenModel { + val modelUri = + when (backend.backend) { + GenerativeBackendEnum.VERTEX_AI -> + "projects/${firebaseApp.options.projectId}/locations/${backend.location}/publishers/google/models/${modelName}" + GenerativeBackendEnum.GOOGLE_AI -> + "projects/${firebaseApp.options.projectId}/models/${modelName}" + } + if (!modelName.startsWith(IMAGEN_MODEL_NAME_PREFIX)) { + Log.w( + TAG, + """Unsupported Imagen model "${modelName}"; see + https://firebase.google.com/docs/vertex-ai/models for a list supported Imagen model names. + """ + .trimIndent(), + ) + } + return ImagenModel( + modelUri, + firebaseApp.options.apiKey, + firebaseApp, + generationConfig, + safetySettings, + requestOptions, + appCheckProvider.get(), + internalAuthProvider.get(), + ) + } + + public companion object { + /** The [FirebaseAI] instance for the default [FirebaseApp] using the Google AI Backend. */ + @JvmStatic + public val instance: FirebaseAI + get() = getInstance(backend = GenerativeBackend.googleAI()) + + /** + * Returns the [FirebaseAI] instance for the provided [FirebaseApp] and [backend]. + * + * @param backend the backend reference to make generative AI requests to. + */ + @JvmStatic + @JvmOverloads + public fun getInstance( + app: FirebaseApp = Firebase.app, + backend: GenerativeBackend + ): FirebaseAI { + val multiResourceComponent = app[FirebaseAIMultiResourceComponent::class.java] + return multiResourceComponent.get(backend) + } + + /** The [FirebaseAI] instance for the provided [FirebaseApp] using the Google AI Backend. */ + @JvmStatic + public fun getInstance(app: FirebaseApp): FirebaseAI = + getInstance(app, GenerativeBackend.googleAI()) + + private const val GEMINI_MODEL_NAME_PREFIX = "gemini-" + + private const val IMAGEN_MODEL_NAME_PREFIX = "imagen-" + + private val TAG = FirebaseAI::class.java.simpleName + } +} + +/** The [FirebaseAI] instance for the default [FirebaseApp] using the Google AI Backend. */ +public val Firebase.ai: FirebaseAI + get() = FirebaseAI.instance + +/** + * Returns the [FirebaseAI] instance for the provided [FirebaseApp] and [backend]. + * + * @param backend the backend reference to make generative AI requests to. + */ +public fun Firebase.ai( + app: FirebaseApp = Firebase.app, + backend: GenerativeBackend = GenerativeBackend.googleAI() +): FirebaseAI = FirebaseAI.getInstance(app, backend) diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/FirebaseAIMultiResourceComponent.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/FirebaseAIMultiResourceComponent.kt new file mode 100644 index 00000000000..c0667b1685e --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/FirebaseAIMultiResourceComponent.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai + +import androidx.annotation.GuardedBy +import com.google.firebase.FirebaseApp +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.annotations.concurrent.Blocking +import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider +import com.google.firebase.auth.internal.InternalAuthProvider +import com.google.firebase.inject.Provider +import kotlin.coroutines.CoroutineContext + +/** + * Multi-resource container for Firebase AI. + * + * @hide + */ +internal class FirebaseAIMultiResourceComponent( + private val app: FirebaseApp, + @Blocking val blockingDispatcher: CoroutineContext, + private val appCheckProvider: Provider, + private val internalAuthProvider: Provider, +) { + + @GuardedBy("this") private val instances: MutableMap = mutableMapOf() + + fun get(backend: GenerativeBackend): FirebaseAI = + synchronized(this) { + instances[backend.location] + ?: FirebaseAI( + app, + backend, + blockingDispatcher, + appCheckProvider, + internalAuthProvider, + ) + .also { instances[backend.location] = it } + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/FirebaseAIRegistrar.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/FirebaseAIRegistrar.kt new file mode 100644 index 00000000000..a8b6b2cb1a3 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/FirebaseAIRegistrar.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai + +import androidx.annotation.Keep +import com.google.firebase.FirebaseApp +import com.google.firebase.annotations.concurrent.Blocking +import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider +import com.google.firebase.auth.internal.InternalAuthProvider +import com.google.firebase.components.Component +import com.google.firebase.components.ComponentRegistrar +import com.google.firebase.components.Dependency +import com.google.firebase.components.Qualified +import com.google.firebase.components.Qualified.unqualified +import com.google.firebase.platforminfo.LibraryVersionComponent +import kotlinx.coroutines.CoroutineDispatcher + +/** + * [ComponentRegistrar] for setting up [FirebaseAI] and its internal dependencies. + * + * @hide + */ +@Keep +internal class FirebaseAIRegistrar : ComponentRegistrar { + override fun getComponents() = + listOf( + Component.builder(FirebaseAIMultiResourceComponent::class.java) + .name(LIBRARY_NAME) + .add(Dependency.required(firebaseApp)) + .add(Dependency.required(blockingDispatcher)) + .add(Dependency.optionalProvider(appCheckInterop)) + .add(Dependency.optionalProvider(internalAuthProvider)) + .factory { container -> + FirebaseAIMultiResourceComponent( + container[firebaseApp], + container.get(blockingDispatcher), + container.getProvider(appCheckInterop), + container.getProvider(internalAuthProvider) + ) + } + .build(), + LibraryVersionComponent.create(LIBRARY_NAME, BuildConfig.VERSION_NAME), + ) + + private companion object { + private const val LIBRARY_NAME = "fire-ai" + + private val firebaseApp = unqualified(FirebaseApp::class.java) + private val appCheckInterop = unqualified(InteropAppCheckTokenProvider::class.java) + private val internalAuthProvider = unqualified(InternalAuthProvider::class.java) + private val blockingDispatcher = + Qualified.qualified(Blocking::class.java, CoroutineDispatcher::class.java) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt new file mode 100644 index 00000000000..1b36998f970 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt @@ -0,0 +1,255 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai + +import android.graphics.Bitmap +import com.google.firebase.FirebaseApp +import com.google.firebase.ai.common.APIController +import com.google.firebase.ai.common.AppCheckHeaderProvider +import com.google.firebase.ai.common.CountTokensRequest +import com.google.firebase.ai.common.GenerateContentRequest +import com.google.firebase.ai.type.Content +import com.google.firebase.ai.type.CountTokensResponse +import com.google.firebase.ai.type.FinishReason +import com.google.firebase.ai.type.FirebaseAIException +import com.google.firebase.ai.type.GenerateContentResponse +import com.google.firebase.ai.type.GenerationConfig +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.GenerativeBackendEnum +import com.google.firebase.ai.type.InvalidStateException +import com.google.firebase.ai.type.PromptBlockedException +import com.google.firebase.ai.type.RequestOptions +import com.google.firebase.ai.type.ResponseStoppedException +import com.google.firebase.ai.type.SafetySetting +import com.google.firebase.ai.type.SerializationException +import com.google.firebase.ai.type.Tool +import com.google.firebase.ai.type.ToolConfig +import com.google.firebase.ai.type.content +import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider +import com.google.firebase.auth.internal.InternalAuthProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.serialization.ExperimentalSerializationApi + +/** + * Represents a multimodal model (like Gemini), capable of generating content based on various input + * types. + */ +public class GenerativeModel +internal constructor( + private val modelName: String, + private val generationConfig: GenerationConfig? = null, + private val safetySettings: List? = null, + private val tools: List? = null, + private val toolConfig: ToolConfig? = null, + private val systemInstruction: Content? = null, + private val generativeBackend: GenerativeBackend = GenerativeBackend.googleAI(), + private val controller: APIController, +) { + internal constructor( + modelName: String, + apiKey: String, + firebaseApp: FirebaseApp, + generationConfig: GenerationConfig? = null, + safetySettings: List? = null, + tools: List? = null, + toolConfig: ToolConfig? = null, + systemInstruction: Content? = null, + requestOptions: RequestOptions = RequestOptions(), + generativeBackend: GenerativeBackend, + appCheckTokenProvider: InteropAppCheckTokenProvider? = null, + internalAuthProvider: InternalAuthProvider? = null, + ) : this( + modelName, + generationConfig, + safetySettings, + tools, + toolConfig, + systemInstruction, + generativeBackend, + APIController( + apiKey, + modelName, + requestOptions, + "gl-kotlin/${KotlinVersion.CURRENT}-ai fire/${BuildConfig.VERSION_NAME}", + firebaseApp, + AppCheckHeaderProvider(TAG, appCheckTokenProvider, internalAuthProvider), + ), + ) + + /** + * Generates new content from the input [Content] given to the model as a prompt. + * + * @param prompt The input(s) given to the model as a prompt. + * @return The content generated by the model. + * @throws [FirebaseAIException] if the request failed. + * @see [FirebaseAIException] for types of errors. + */ + public suspend fun generateContent(vararg prompt: Content): GenerateContentResponse = + try { + controller.generateContent(constructRequest(*prompt)).toPublic().validate() + } catch (e: Throwable) { + throw FirebaseAIException.from(e) + } + + /** + * Generates new content as a stream from the input [Content] given to the model as a prompt. + * + * @param prompt The input(s) given to the model as a prompt. + * @return A [Flow] which will emit responses as they are returned by the model. + * @throws [FirebaseAIException] if the request failed. + * @see [FirebaseAIException] for types of errors. + */ + public fun generateContentStream(vararg prompt: Content): Flow = + controller + .generateContentStream(constructRequest(*prompt)) + .catch { throw FirebaseAIException.from(it) } + .map { it.toPublic().validate() } + + /** + * Generates new content from the text input given to the model as a prompt. + * + * @param prompt The text to be send to the model as a prompt. + * @return The content generated by the model. + * @throws [FirebaseAIException] if the request failed. + * @see [FirebaseAIException] for types of errors. + */ + public suspend fun generateContent(prompt: String): GenerateContentResponse = + generateContent(content { text(prompt) }) + + /** + * Generates new content as a stream from the text input given to the model as a prompt. + * + * @param prompt The text to be send to the model as a prompt. + * @return A [Flow] which will emit responses as they are returned by the model. + * @throws [FirebaseAIException] if the request failed. + * @see [FirebaseAIException] for types of errors. + */ + public fun generateContentStream(prompt: String): Flow = + generateContentStream(content { text(prompt) }) + + /** + * Generates new content from the image input given to the model as a prompt. + * + * @param prompt The image to be converted into a single piece of [Content] to send to the model. + * @return A [GenerateContentResponse] after some delay. + * @throws [FirebaseAIException] if the request failed. + * @see [FirebaseAIException] for types of errors. + */ + public suspend fun generateContent(prompt: Bitmap): GenerateContentResponse = + generateContent(content { image(prompt) }) + + /** + * Generates new content as a stream from the image input given to the model as a prompt. + * + * @param prompt The image to be converted into a single piece of [Content] to send to the model. + * @return A [Flow] which will emit responses as they are returned by the model. + * @throws [FirebaseAIException] if the request failed. + * @see [FirebaseAIException] for types of errors. + */ + public fun generateContentStream(prompt: Bitmap): Flow = + generateContentStream(content { image(prompt) }) + + /** Creates a [Chat] instance using this model with the optionally provided history. */ + public fun startChat(history: List = emptyList()): Chat = + Chat(this, history.toMutableList()) + + /** + * Counts the number of tokens in a prompt using the model's tokenizer. + * + * @param prompt The input(s) given to the model as a prompt. + * @return The [CountTokensResponse] of running the model's tokenizer on the input. + * @throws [FirebaseAIException] if the request failed. + * @see [FirebaseAIException] for types of errors. + */ + public suspend fun countTokens(vararg prompt: Content): CountTokensResponse { + try { + return controller.countTokens(constructCountTokensRequest(*prompt)).toPublic() + } catch (e: Throwable) { + throw FirebaseAIException.from(e) + } + } + + /** + * Counts the number of tokens in a text prompt using the model's tokenizer. + * + * @param prompt The text given to the model as a prompt. + * @return The [CountTokensResponse] of running the model's tokenizer on the input. + * @throws [FirebaseAIException] if the request failed. + * @see [FirebaseAIException] for types of errors. + */ + public suspend fun countTokens(prompt: String): CountTokensResponse { + return countTokens(content { text(prompt) }) + } + + /** + * Counts the number of tokens in an image prompt using the model's tokenizer. + * + * @param prompt The image given to the model as a prompt. + * @return The [CountTokensResponse] of running the model's tokenizer on the input. + * @throws [FirebaseAIException] if the request failed. + * @see [FirebaseAIException] for types of errors. + */ + public suspend fun countTokens(prompt: Bitmap): CountTokensResponse { + return countTokens(content { image(prompt) }) + } + + @OptIn(ExperimentalSerializationApi::class) + private fun constructRequest(vararg prompt: Content) = + GenerateContentRequest( + modelName, + prompt.map { it.toInternal() }, + safetySettings + ?.also { safetySettingList -> + if ( + generativeBackend.backend == GenerativeBackendEnum.GOOGLE_AI && + safetySettingList.any { it.method != null } + ) { + throw InvalidStateException( + "HarmBlockMethod is unsupported by the Google Developer API" + ) + } + } + ?.map { it.toInternal() }, + generationConfig?.toInternal(), + tools?.map { it.toInternal() }, + toolConfig?.toInternal(), + systemInstruction?.copy(role = "system")?.toInternal(), + ) + + private fun constructCountTokensRequest(vararg prompt: Content) = + when (generativeBackend.backend) { + GenerativeBackendEnum.GOOGLE_AI -> CountTokensRequest.forGoogleAI(constructRequest(*prompt)) + GenerativeBackendEnum.VERTEX_AI -> CountTokensRequest.forVertexAI(constructRequest(*prompt)) + } + + private fun GenerateContentResponse.validate() = apply { + if (candidates.isEmpty() && promptFeedback == null) { + throw SerializationException("Error deserializing response, found no valid fields") + } + promptFeedback?.blockReason?.let { throw PromptBlockedException(this) } + candidates + .mapNotNull { it.finishReason } + .firstOrNull { it != FinishReason.STOP } + ?.let { throw ResponseStoppedException(this) } + } + + private companion object { + private val TAG = GenerativeModel::class.java.simpleName + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/ImagenModel.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/ImagenModel.kt new file mode 100644 index 00000000000..4d88d09b1e1 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/ImagenModel.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai + +import com.google.firebase.FirebaseApp +import com.google.firebase.ai.common.APIController +import com.google.firebase.ai.common.AppCheckHeaderProvider +import com.google.firebase.ai.common.ContentBlockedException +import com.google.firebase.ai.common.GenerateImageRequest +import com.google.firebase.ai.type.FirebaseAIException +import com.google.firebase.ai.type.ImagenGenerationConfig +import com.google.firebase.ai.type.ImagenGenerationResponse +import com.google.firebase.ai.type.ImagenInlineImage +import com.google.firebase.ai.type.ImagenSafetySettings +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.RequestOptions +import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider +import com.google.firebase.auth.internal.InternalAuthProvider + +/** + * Represents a generative model (like Imagen), capable of generating images based on various input + * types. + */ +@PublicPreviewAPI +public class ImagenModel +internal constructor( + private val modelName: String, + private val generationConfig: ImagenGenerationConfig? = null, + private val safetySettings: ImagenSafetySettings? = null, + private val controller: APIController, +) { + @JvmOverloads + internal constructor( + modelName: String, + apiKey: String, + firebaseApp: FirebaseApp, + generationConfig: ImagenGenerationConfig? = null, + safetySettings: ImagenSafetySettings? = null, + requestOptions: RequestOptions = RequestOptions(), + appCheckTokenProvider: InteropAppCheckTokenProvider? = null, + internalAuthProvider: InternalAuthProvider? = null, + ) : this( + modelName, + generationConfig, + safetySettings, + APIController( + apiKey, + modelName, + requestOptions, + "gl-kotlin/${KotlinVersion.CURRENT}-ai fire/${BuildConfig.VERSION_NAME}", + firebaseApp, + AppCheckHeaderProvider(TAG, appCheckTokenProvider, internalAuthProvider), + ), + ) + + /** + * Generates an image, returning the result directly to the caller. + * + * @param prompt The input(s) given to the model as a prompt. + */ + public suspend fun generateImages(prompt: String): ImagenGenerationResponse = + try { + controller + .generateImage(constructRequest(prompt, null, generationConfig)) + .validate() + .toPublicInline() + } catch (e: Throwable) { + throw FirebaseAIException.from(e) + } + + private fun constructRequest( + prompt: String, + gcsUri: String?, + config: ImagenGenerationConfig?, + ): GenerateImageRequest { + return GenerateImageRequest( + listOf(GenerateImageRequest.ImagenPrompt(prompt)), + GenerateImageRequest.ImagenParameters( + sampleCount = config?.numberOfImages ?: 1, + includeRaiReason = true, + addWatermark = generationConfig?.addWatermark, + personGeneration = safetySettings?.personFilterLevel?.internalVal, + negativePrompt = config?.negativePrompt, + safetySetting = safetySettings?.safetyFilterLevel?.internalVal, + storageUri = gcsUri, + aspectRatio = config?.aspectRatio?.internalVal, + imageOutputOptions = generationConfig?.imageFormat?.toInternal(), + ), + ) + } + + internal companion object { + private val TAG = ImagenModel::class.java.simpleName + internal const val DEFAULT_FILTERED_ERROR = + "Unable to show generated images. All images were filtered out because they violated Vertex AI's usage guidelines. You will not be charged for blocked images. Try rephrasing the prompt. If you think this was an error, send feedback." + } +} + +@OptIn(PublicPreviewAPI::class) +private fun ImagenGenerationResponse.Internal.validate(): ImagenGenerationResponse.Internal { + if (predictions.none { it.mimeType != null }) { + throw ContentBlockedException( + message = predictions.first { it.raiFilteredReason != null }.raiFilteredReason + ?: ImagenModel.DEFAULT_FILTERED_ERROR + ) + } + return this +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/LiveGenerativeModel.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/LiveGenerativeModel.kt new file mode 100644 index 00000000000..fe4cae0d187 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/LiveGenerativeModel.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai + +import com.google.firebase.FirebaseApp +import com.google.firebase.ai.common.APIController +import com.google.firebase.ai.common.AppCheckHeaderProvider +import com.google.firebase.ai.common.JSON +import com.google.firebase.ai.type.Content +import com.google.firebase.ai.type.LiveClientSetupMessage +import com.google.firebase.ai.type.LiveGenerationConfig +import com.google.firebase.ai.type.LiveSession +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.RequestOptions +import com.google.firebase.ai.type.ServiceConnectionHandshakeFailedException +import com.google.firebase.ai.type.Tool +import com.google.firebase.annotations.concurrent.Blocking +import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider +import com.google.firebase.auth.internal.InternalAuthProvider +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readBytes +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject + +/** + * Represents a multimodal model (like Gemini) capable of real-time content generation based on + * various input types, supporting bidirectional streaming. + */ +@PublicPreviewAPI +public class LiveGenerativeModel +internal constructor( + private val modelName: String, + @Blocking private val blockingDispatcher: CoroutineContext, + private val config: LiveGenerationConfig? = null, + private val tools: List? = null, + private val systemInstruction: Content? = null, + private val location: String, + private val controller: APIController +) { + internal constructor( + modelName: String, + apiKey: String, + firebaseApp: FirebaseApp, + blockingDispatcher: CoroutineContext, + config: LiveGenerationConfig? = null, + tools: List? = null, + systemInstruction: Content? = null, + location: String = "us-central1", + requestOptions: RequestOptions = RequestOptions(), + appCheckTokenProvider: InteropAppCheckTokenProvider? = null, + internalAuthProvider: InternalAuthProvider? = null, + ) : this( + modelName, + blockingDispatcher, + config, + tools, + systemInstruction, + location, + APIController( + apiKey, + modelName, + requestOptions, + "gl-kotlin/${KotlinVersion.CURRENT}-ai fire/${BuildConfig.VERSION_NAME}", + firebaseApp, + AppCheckHeaderProvider(TAG, appCheckTokenProvider, internalAuthProvider), + ), + ) + + /** + * Start a [LiveSession] with the server for bidirectional streaming. + * + * @return A [LiveSession] that you can use to stream messages to and from the server. + * @throws [ServiceConnectionHandshakeFailedException] If the client was not able to establish a + * connection with the server. + */ + @OptIn(ExperimentalSerializationApi::class) + public suspend fun connect(): LiveSession { + val clientMessage = + LiveClientSetupMessage( + modelName, + config?.toInternal(), + tools?.map { it.toInternal() }, + systemInstruction?.toInternal() + ) + .toInternal() + val data: String = Json.encodeToString(clientMessage) + try { + val webSession = controller.getWebSocketSession(location) + webSession.send(Frame.Text(data)) + val receivedJsonStr = webSession.incoming.receive().readBytes().toString(Charsets.UTF_8) + val receivedJson = JSON.parseToJsonElement(receivedJsonStr) + + return if (receivedJson is JsonObject && "setupComplete" in receivedJson) { + LiveSession(session = webSession, blockingDispatcher = blockingDispatcher) + } else { + webSession.close() + throw ServiceConnectionHandshakeFailedException("Unable to connect to the server") + } + } catch (e: ClosedReceiveChannelException) { + throw ServiceConnectionHandshakeFailedException("Channel was closed by the server", e) + } + } + + private companion object { + private val TAG = LiveGenerativeModel::class.java.simpleName + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/APIController.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/APIController.kt new file mode 100644 index 00000000000..34a4b96b7dd --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/APIController.kt @@ -0,0 +1,364 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.common + +import android.util.Log +import com.google.firebase.Firebase +import com.google.firebase.FirebaseApp +import com.google.firebase.ai.common.util.decodeToFlow +import com.google.firebase.ai.common.util.fullModelName +import com.google.firebase.ai.type.CountTokensResponse +import com.google.firebase.ai.type.FinishReason +import com.google.firebase.ai.type.GRpcErrorResponse +import com.google.firebase.ai.type.GenerateContentResponse +import com.google.firebase.ai.type.ImagenGenerationResponse +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.RequestOptions +import com.google.firebase.ai.type.Response +import com.google.firebase.options +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.websocket.ClientWebSocketSession +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.preparePost +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsChannel +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.withCharset +import io.ktor.serialization.kotlinx.json.json +import io.ktor.utils.io.charsets.Charset +import kotlin.math.max +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json + +@OptIn(ExperimentalSerializationApi::class) +internal val JSON = Json { + ignoreUnknownKeys = true + prettyPrint = false + isLenient = true + explicitNulls = false +} + +/** + * Backend class for interfacing with the Gemini API. + * + * This class handles making HTTP requests to the API and streaming the responses back. + * + * @param httpEngine The HTTP client engine to be used for making requests. Defaults to CIO engine. + * Exposed primarily for DI in tests. + * @property key The API key used for authentication. + * @property model The model to use for generation. + * @property apiClient The value to pass in the `x-goog-api-client` header. + * @property headerProvider A provider that generates extra headers to include in all HTTP requests. + */ +@OptIn(PublicPreviewAPI::class) +internal class APIController +internal constructor( + private val key: String, + model: String, + private val requestOptions: RequestOptions, + httpEngine: HttpClientEngine, + private val apiClient: String, + private val firebaseApp: FirebaseApp, + private val appVersion: Int = 0, + private val googleAppId: String, + private val headerProvider: HeaderProvider?, +) { + + constructor( + key: String, + model: String, + requestOptions: RequestOptions, + apiClient: String, + firebaseApp: FirebaseApp, + headerProvider: HeaderProvider? = null, + ) : this( + key, + model, + requestOptions, + OkHttp.create(), + apiClient, + firebaseApp, + getVersionNumber(firebaseApp), + firebaseApp.options.applicationId, + headerProvider + ) + + private val model = fullModelName(model) + + private val client = + HttpClient(httpEngine) { + install(HttpTimeout) { + requestTimeoutMillis = requestOptions.timeout.inWholeMilliseconds + socketTimeoutMillis = + max(180.seconds.inWholeMilliseconds, requestOptions.timeout.inWholeMilliseconds) + } + install(WebSockets) + install(ContentNegotiation) { json(JSON) } + } + + suspend fun generateContent(request: GenerateContentRequest): GenerateContentResponse.Internal = + try { + client + .post("${requestOptions.endpoint}/${requestOptions.apiVersion}/$model:generateContent") { + applyCommonConfiguration(request) + applyHeaderProvider() + } + .also { validateResponse(it) } + .body() + .validate() + } catch (e: Throwable) { + throw FirebaseCommonAIException.from(e) + } + + suspend fun generateImage(request: GenerateImageRequest): ImagenGenerationResponse.Internal = + try { + client + .post("${requestOptions.endpoint}/${requestOptions.apiVersion}/$model:predict") { + applyCommonConfiguration(request) + applyHeaderProvider() + } + .also { validateResponse(it) } + .body() + } catch (e: Throwable) { + throw FirebaseCommonAIException.from(e) + } + + private fun getBidiEndpoint(location: String): String = + "wss://firebasevertexai.googleapis.com/ws/google.firebase.vertexai.v1beta.LlmBidiService/BidiGenerateContent/locations/$location?key=$key" + + suspend fun getWebSocketSession(location: String): ClientWebSocketSession = + client.webSocketSession(getBidiEndpoint(location)) { applyCommonHeaders() } + + fun generateContentStream( + request: GenerateContentRequest + ): Flow = + client + .postStream( + "${requestOptions.endpoint}/${requestOptions.apiVersion}/$model:streamGenerateContent?alt=sse" + ) { + applyCommonConfiguration(request) + } + .map { it.validate() } + .catch { throw FirebaseCommonAIException.from(it) } + + suspend fun countTokens(request: CountTokensRequest): CountTokensResponse.Internal = + try { + client + .post("${requestOptions.endpoint}/${requestOptions.apiVersion}/$model:countTokens") { + applyCommonConfiguration(request) + applyHeaderProvider() + } + .also { validateResponse(it) } + .body() + } catch (e: Throwable) { + throw FirebaseCommonAIException.from(e) + } + + private fun HttpRequestBuilder.applyCommonHeaders() { + contentType(ContentType.Application.Json) + header("x-goog-api-key", key) + header("x-goog-api-client", apiClient) + if (firebaseApp.isDataCollectionDefaultEnabled) { + header("X-Firebase-AppId", googleAppId) + header("X-Firebase-AppVersion", appVersion) + } + } + private fun HttpRequestBuilder.applyCommonConfiguration(request: Request) { + when (request) { + is GenerateContentRequest -> setBody(request) + is CountTokensRequest -> setBody(request) + is GenerateImageRequest -> setBody(request) + } + applyCommonHeaders() + } + + private suspend fun HttpRequestBuilder.applyHeaderProvider() { + if (headerProvider != null) { + try { + withTimeout(headerProvider.timeout) { + for ((tag, value) in headerProvider.generateHeaders()) { + header(tag, value) + } + } + } catch (e: TimeoutCancellationException) { + Log.w(TAG, "HeaderProvided timed out without generating headers, ignoring") + } + } + } + + /** + * Makes a POST request to the specified [url] and returns a [Flow] of deserialized response + * objects of type [R]. The response is expected to be a stream of JSON objects that are parsed in + * real-time as they are received from the server. + * + * This function is intended for internal use within the client that handles streaming responses. + * + * Example usage: + * ``` + * val client: HttpClient = HttpClient(CIO) + * val request: Request = GenerateContentRequest(...) + * val url: String = "http://example.com/stream" + * + * val responses: GenerateContentResponse = client.postStream(url) { + * setBody(request) + * contentType(ContentType.Application.Json) + * } + * responses.collect { + * println("Got a response: $it") + * } + * ``` + * + * @param R The type of the response object. + * @param url The URL to which the POST request will be made. + * @param config An optional [HttpRequestBuilder] callback for request configuration. + * @return A [Flow] of response objects of type [R]. + */ + private inline fun HttpClient.postStream( + url: String, + crossinline config: HttpRequestBuilder.() -> Unit = {}, + ): Flow = channelFlow { + launch(CoroutineName("postStream")) { + preparePost(url) { + applyHeaderProvider() + config() + } + .execute { + validateResponse(it) + + val channel = it.bodyAsChannel() + val flow = JSON.decodeToFlow(channel) + + flow.collect { send(it) } + } + } + } + + companion object { + private val TAG = APIController::class.java.simpleName + + private fun getVersionNumber(app: FirebaseApp): Int { + try { + val context = app.applicationContext + return context.packageManager.getPackageInfo(context.packageName, 0).versionCode + } catch (e: Exception) { + Log.d(TAG, "Error while getting app version: ${e.message}") + return 0 + } + } + } +} + +internal interface HeaderProvider { + val timeout: Duration + + suspend fun generateHeaders(): Map +} + +private suspend fun validateResponse(response: HttpResponse) { + if (response.status == HttpStatusCode.OK) return + + val htmlContentType = ContentType.Text.Html.withCharset(Charset.forName("utf-8")) + if (response.status == HttpStatusCode.NotFound && response.contentType() == htmlContentType) + throw ServerException( + """URL not found. Please verify the location used to create the `FirebaseAI` object + | See https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations#available-regions + | for the list of available locations. Raw response: ${response.bodyAsText()}""" + .trimMargin() + ) + val text = response.bodyAsText() + val error = + try { + JSON.decodeFromString(text).error + } catch (e: Throwable) { + throw ServerException("Unexpected Response:\n$text $e") + } + val message = error.message + if (message.contains("API key not valid")) { + throw InvalidAPIKeyException(message) + } + // TODO (b/325117891): Use a better method than string matching. + if (message == "User location is not supported for the API use.") { + throw UnsupportedUserLocationException() + } + if (message.contains("quota")) { + throw QuotaExceededException(message) + } + if (message.contains("The prompt could not be submitted")) { + throw PromptBlockedException(message) + } + getServiceDisabledErrorDetailsOrNull(error)?.let { + val errorMessage = + if (it.metadata?.get("service") == "firebasevertexai.googleapis.com") { + """ + The Firebase AI SDK requires the Vertex AI in Firebase API + (`firebasevertexai.googleapis.com`) to be enabled in your Firebase project. Enable this API + by visiting the Firebase Console at + https://console.firebase.google.com/project/${Firebase.options.projectId}/genai + and clicking "Get started". If you enabled this API recently, wait a few minutes for the + action to propagate to our systems and then retry. + """ + .trimIndent() + } else { + error.message + } + + throw ServiceDisabledException(errorMessage) + } + throw ServerException(message) +} + +private fun getServiceDisabledErrorDetailsOrNull( + error: GRpcErrorResponse.GRpcError +): GRpcErrorResponse.GRpcError.GRpcErrorDetails? { + return error.details?.firstOrNull { + it.reason == "SERVICE_DISABLED" && it.domain == "googleapis.com" + } +} + +private fun GenerateContentResponse.Internal.validate() = apply { + if ((candidates?.isEmpty() != false) && promptFeedback == null) { + throw SerializationException("Error deserializing response, found no valid fields") + } + promptFeedback?.blockReason?.let { throw PromptBlockedException(this) } + candidates + ?.mapNotNull { it.finishReason } + ?.firstOrNull { it != FinishReason.Internal.STOP } + ?.let { throw ResponseStoppedException(this) } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/AppCheckHeaderProvider.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/AppCheckHeaderProvider.kt new file mode 100644 index 00000000000..d5a5ec32305 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/AppCheckHeaderProvider.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.common + +import android.util.Log +import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider +import com.google.firebase.auth.internal.InternalAuthProvider +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.tasks.await + +internal class AppCheckHeaderProvider( + private val logTag: String, + private val appCheckTokenProvider: InteropAppCheckTokenProvider? = null, + private val internalAuthProvider: InternalAuthProvider? = null, +) : HeaderProvider { + override val timeout: Duration + get() = 10.seconds + + override suspend fun generateHeaders(): Map { + val headers = mutableMapOf() + if (appCheckTokenProvider == null) { + Log.w(logTag, "AppCheck not registered, skipping") + } else { + val token = appCheckTokenProvider.getToken(false).await() + + if (token.error != null) { + Log.w(logTag, "Error obtaining AppCheck token", token.error) + } + // The Firebase App Check backend can differentiate between apps without App Check, and + // wrongly configured apps by verifying the value of the token, so it always needs to be + // included. + headers["X-Firebase-AppCheck"] = token.token + } + + if (internalAuthProvider == null) { + Log.w(logTag, "Auth not registered, skipping") + } else { + try { + val token = internalAuthProvider.getAccessToken(false).await() + + headers["Authorization"] = "Firebase ${token.token!!}" + } catch (e: Exception) { + Log.w(logTag, "Error getting Auth token ", e) + } + } + + return headers + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/Exceptions.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/Exceptions.kt new file mode 100644 index 00000000000..6e2ff67ca4d --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/Exceptions.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.common + +import com.google.firebase.ai.type.GenerateContentResponse +import io.ktor.serialization.JsonConvertException +import kotlinx.coroutines.TimeoutCancellationException + +/** Parent class for any errors that occur. */ +internal sealed class FirebaseCommonAIException(message: String, cause: Throwable? = null) : + RuntimeException(message, cause) { + companion object { + + /** + * Converts a [Throwable] to a [FirebaseCommonAIException]. + * + * Will populate default messages as expected, and propagate the provided [cause] through the + * resulting exception. + */ + fun from(cause: Throwable): FirebaseCommonAIException = + when (cause) { + is FirebaseCommonAIException -> cause + is JsonConvertException, + is kotlinx.serialization.SerializationException -> + SerializationException( + "Something went wrong while trying to deserialize a response from the server.", + cause, + ) + is TimeoutCancellationException -> + RequestTimeoutException("The request failed to complete in the allotted time.") + else -> UnknownException("Something unexpected happened.", cause) + } + } +} + +/** Something went wrong while trying to deserialize a response from the server. */ +internal class SerializationException(message: String, cause: Throwable? = null) : + FirebaseCommonAIException(message, cause) + +/** The server responded with a non 200 response code. */ +internal class ServerException(message: String, cause: Throwable? = null) : + FirebaseCommonAIException(message, cause) + +/** The server responded that the API Key is no valid. */ +internal class InvalidAPIKeyException(message: String, cause: Throwable? = null) : + FirebaseCommonAIException(message, cause) + +/** + * A request was blocked for some reason. + * + * See the [response's][response] `promptFeedback.blockReason` for more information. + * + * @property response the full server response for the request. + */ +internal class PromptBlockedException +internal constructor( + val response: GenerateContentResponse.Internal?, + cause: Throwable? = null, + message: String? = null, +) : + FirebaseCommonAIException( + "Prompt was blocked: ${response?.promptFeedback?.blockReason?.name?: message}", + cause, + ) { + internal constructor(message: String, cause: Throwable? = null) : this(null, cause, message) +} + +/** + * The user's location (region) is not supported by the API. + * + * See the Google documentation for a + * [list of regions](https://ai.google.dev/available_regions#available_regions) (countries and + * territories) where the API is available. + */ +internal class UnsupportedUserLocationException(cause: Throwable? = null) : + FirebaseCommonAIException("User location is not supported for the API use.", cause) + +/** + * Some form of state occurred that shouldn't have. + * + * Usually indicative of consumer error. + */ +internal class InvalidStateException(message: String, cause: Throwable? = null) : + FirebaseCommonAIException(message, cause) + +/** + * A request was stopped during generation for some reason. + * + * @property response the full server response for the request + */ +internal class ResponseStoppedException( + val response: GenerateContentResponse.Internal, + cause: Throwable? = null +) : + FirebaseCommonAIException( + "Content generation stopped. Reason: ${response.candidates?.first()?.finishReason?.name}", + cause, + ) + +/** + * A request took too long to complete. + * + * Usually occurs due to a user specified [timeout][RequestOptions.timeout]. + */ +internal class RequestTimeoutException(message: String, cause: Throwable? = null) : + FirebaseCommonAIException(message, cause) + +/** The quota for this API key is depleted, retry this request at a later time. */ +internal class QuotaExceededException(message: String, cause: Throwable? = null) : + FirebaseCommonAIException(message, cause) + +/** The service is not enabled for this project. Visit the Firebase Console to enable it. */ +internal class ServiceDisabledException(message: String, cause: Throwable? = null) : + FirebaseCommonAIException(message, cause) + +/** Catch all case for exceptions not explicitly expected. */ +internal class UnknownException(message: String, cause: Throwable? = null) : + FirebaseCommonAIException(message, cause) + +internal class ContentBlockedException(message: String, cause: Throwable? = null) : + FirebaseCommonAIException(message, cause) + +internal fun makeMissingCaseException( + source: String, + ordinal: Int +): com.google.firebase.ai.type.SerializationException { + return com.google.firebase.ai.type.SerializationException( + """ + |Missing case for a $source: $ordinal + |This error indicates that one of the `toInternal` conversions needs updating. + |If you're a developer seeing this exception, please file an issue on our GitHub repo: + |https://github.com/firebase/firebase-android-sdk + """ + .trimMargin() + ) +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/Request.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/Request.kt new file mode 100644 index 00000000000..ebc3db7f282 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/Request.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalSerializationApi::class) + +package com.google.firebase.ai.common + +import com.google.firebase.ai.common.util.fullModelName +import com.google.firebase.ai.common.util.trimmedModelName +import com.google.firebase.ai.type.Content +import com.google.firebase.ai.type.GenerationConfig +import com.google.firebase.ai.type.ImagenImageFormat +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.SafetySetting +import com.google.firebase.ai.type.Tool +import com.google.firebase.ai.type.ToolConfig +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +internal interface Request + +@Serializable +internal data class GenerateContentRequest( + val model: String? = null, + val contents: List, + @SerialName("safety_settings") val safetySettings: List? = null, + @SerialName("generation_config") val generationConfig: GenerationConfig.Internal? = null, + val tools: List? = null, + @SerialName("tool_config") var toolConfig: ToolConfig.Internal? = null, + @SerialName("system_instruction") val systemInstruction: Content.Internal? = null, +) : Request + +@Serializable +internal data class CountTokensRequest( + val generateContentRequest: GenerateContentRequest? = null, + val model: String? = null, + val contents: List? = null, + val tools: List? = null, + @SerialName("system_instruction") val systemInstruction: Content.Internal? = null, + val generationConfig: GenerationConfig.Internal? = null +) : Request { + companion object { + + fun forGoogleAI(generateContentRequest: GenerateContentRequest) = + CountTokensRequest( + generateContentRequest = + generateContentRequest.model?.let { + generateContentRequest.copy(model = fullModelName(trimmedModelName(it))) + } + ?: generateContentRequest + ) + + fun forVertexAI(generateContentRequest: GenerateContentRequest) = + CountTokensRequest( + model = generateContentRequest.model?.let { fullModelName(it) }, + contents = generateContentRequest.contents, + tools = generateContentRequest.tools, + systemInstruction = generateContentRequest.systemInstruction, + generationConfig = generateContentRequest.generationConfig, + ) + } +} + +@Serializable +internal data class GenerateImageRequest( + val instances: List, + val parameters: ImagenParameters, +) : Request { + @Serializable internal data class ImagenPrompt(val prompt: String) + + @OptIn(PublicPreviewAPI::class) + @Serializable + internal data class ImagenParameters( + val sampleCount: Int, + val includeRaiReason: Boolean, + val storageUri: String?, + val negativePrompt: String?, + val aspectRatio: String?, + val safetySetting: String?, + val personGeneration: String?, + val addWatermark: Boolean?, + val imageOutputOptions: ImagenImageFormat.Internal?, + ) +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/android.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/android.kt new file mode 100644 index 00000000000..4d7a1e46097 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/android.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.common.util + +import android.media.AudioRecord +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.yield + +/** + * The minimum buffer size for this instance. + * + * The same as calling [AudioRecord.getMinBufferSize], except the params are pre-populated. + */ +internal val AudioRecord.minBufferSize: Int + get() = AudioRecord.getMinBufferSize(sampleRate, channelConfiguration, audioFormat) + +/** + * Reads from this [AudioRecord] and returns the data in a flow. + * + * Will yield when this instance is not recording. + */ +internal fun AudioRecord.readAsFlow() = flow { + val buffer = ByteArray(minBufferSize) + + while (true) { + if (recordingState != AudioRecord.RECORDSTATE_RECORDING) { + yield() + continue + } + + val bytesRead = read(buffer, 0, buffer.size) + if (bytesRead > 0) { + emit(buffer.copyOf(bytesRead)) + } + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/kotlin.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/kotlin.kt new file mode 100644 index 00000000000..f9b3add3cc4 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/kotlin.kt @@ -0,0 +1,103 @@ +/* + * 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. + */ + +package com.google.firebase.ai.common.util + +import java.io.ByteArrayOutputStream +import java.lang.reflect.Field +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.fold + +/** + * Removes the last character from the [StringBuilder]. + * + * If the StringBuilder is empty, calling this function will throw an [IndexOutOfBoundsException]. + * + * @return The [StringBuilder] used to make the call, for optional chaining. + * @throws IndexOutOfBoundsException if the StringBuilder is empty. + */ +internal fun StringBuilder.removeLast(): StringBuilder = + if (isEmpty()) throw IndexOutOfBoundsException("StringBuilder is empty.") + else deleteCharAt(length - 1) + +/** + * A variant of [getAnnotation][Field.getAnnotation] that provides implicit Kotlin support. + * + * Syntax sugar for: + * ``` + * getAnnotation(T::class.java) + * ``` + */ +internal inline fun Field.getAnnotation() = getAnnotation(T::class.java) + +/** + * Collects bytes from this flow and doesn't emit them back until [minSize] is reached. + * + * For example: + * ``` + * val byteArr = flowOf(byteArrayOf(1), byteArrayOf(2, 3, 4), byteArrayOf(5, 6, 7, 8)) + * val expectedResult = listOf(byteArrayOf(1, 2, 3, 4), byteArrayOf( 5, 6, 7, 8)) + * + * byteArr.accumulateUntil(4).toList() shouldContainExactly expectedResult + * ``` + * + * @param minSize The minimum about of bytes the array should have before being sent down-stream + * @param emitLeftOvers If the flow completes and there are bytes left over that don't meet the + * [minSize], send them anyways. + */ +internal fun Flow.accumulateUntil( + minSize: Int, + emitLeftOvers: Boolean = false +): Flow = flow { + val remaining = + fold(ByteArrayOutputStream()) { buffer, it -> + buffer.apply { + write(it, 0, it.size) + if (size() >= minSize) { + emit(toByteArray()) + reset() + } + } + } + + if (emitLeftOvers && remaining.size() > 0) { + emit(remaining.toByteArray()) + } +} + +/** + * Create a [Job] that is a child of the [currentCoroutineContext], if any. + * + * This is useful when you want a coroutine scope to be canceled when its parent scope is canceled, + * and you don't have full control over the parent scope, but you don't want the cancellation of the + * child to impact the parent. + * + * If the parent coroutine context does not have a job, an empty one will be created. + */ +internal suspend inline fun childJob() = Job(currentCoroutineContext()[Job] ?: Job()) + +/** + * A constant value pointing to a cancelled [CoroutineScope]. + * + * Useful when you want to initialize a mutable [CoroutineScope] in a canceled state. + */ +internal val CancelledCoroutineScope = CoroutineScope(EmptyCoroutineContext).apply { cancel() } diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/ktor.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/ktor.kt new file mode 100644 index 00000000000..1084ed56323 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/ktor.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("DEPRECATION") // a replacement for our purposes has not been published yet + +package com.google.firebase.ai.common.util + +import io.ktor.utils.io.ByteChannel +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.close +import io.ktor.utils.io.readUTF8Line +import io.ktor.utils.io.writeFully +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + +/** + * Suspends and processes each line read from the [ByteReadChannel] until the channel is closed for + * read. + * + * This extension function facilitates processing the stream of lines in a manner that takes into + * account EOF/empty strings- and avoids calling [block] as such. + * + * Example usage: + * ``` + * val channel: ByteReadChannel = ByteReadChannel("Hello, World!") + * channel.onEachLine { + * println("Received line: $it") + * } + * ``` + * + * @param block A suspending function to process each line. + */ +internal suspend fun ByteReadChannel.onEachLine(block: suspend (String) -> Unit) { + while (!isClosedForRead) { + awaitContent() + val line = readUTF8Line()?.takeUnless { it.isEmpty() } ?: continue + block(line) + } +} + +/** + * Decodes a stream of JSON elements from the given [ByteReadChannel] into a [Flow] of objects of + * type [T]. + * + * This function takes in a stream of events, each with a set of named parts. Parts are separated by + * an HTTP \r\n newline, events are separated by a double HTTP \r\n\r\n newline. This function + * assumes every event will only contain a named "data" part with a JSON object. Each data JSON is + * decoded into an instance of [T] and emitted as it is read from the channel. + * + * Example usage: + * ``` + * val json = Json { ignoreUnknownKeys = true } // Create a Json instance with any configurations + * val channel: ByteReadChannel = ByteReadChannel("data: {\"name\":\"Alice\"}\r\n\r\ndata: {\"name\":\"Bob\"}]") + * + * json.decodeToFlow(channel).collect { person -> + * println(person.name) + * } + * ``` + * + * @param T The type of objects to decode from the JSON stream. + * @param channel The [ByteReadChannel] from which the JSON stream will be read. + * @return A [Flow] of objects of type [T]. + * @throws SerializationException in case of any decoding-specific error + * @throws IllegalArgumentException if the decoded input is not a valid instance of [T] + */ +internal inline fun Json.decodeToFlow(channel: ByteReadChannel): Flow = channelFlow { + channel.onEachLine { + val data = it.removePrefix("data:") + send(decodeFromString(data)) + } +} + +/** + * Writes the provided [bytes] to the channel and closes it. + * + * Just a wrapper around [writeFully] that closes the channel after writing is complete. + * + * @param bytes the data to send through the channel + */ +internal suspend fun ByteChannel.send(bytes: ByteArray) { + writeFully(bytes) + close() +} + +/** String separator used in SSE communication to signal the end of a message. */ +internal const val SSE_SEPARATOR = "\r\n\r\n" diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/serialization.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/serialization.kt new file mode 100644 index 00000000000..91490da4126 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/serialization.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.common.util + +import android.util.Log +import com.google.firebase.ai.common.SerializationException +import kotlin.reflect.KClass +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Serializer for enums that defaults to the first ordinal on unknown types. + * + * Convention is that the first enum be named `UNKNOWN`, but any name is valid. + * + * When an unknown enum value is found, the enum itself will be logged to stderr with a message + * about opening an issue on GitHub regarding the new enum value. + */ +internal class FirstOrdinalSerializer>(private val enumClass: KClass) : + KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("FirstOrdinalSerializer") { + for (enumValue in enumClass.enumValues()) { + element(enumValue.toString()) + } + } + + override fun deserialize(decoder: Decoder): T { + val name = decoder.decodeString() + val values = enumClass.enumValues() + + return values.firstOrNull { it.serialName == name } + ?: values.first().also { printWarning(name) } + } + + private fun printWarning(name: String) { + Log.e( + "FirstOrdinalSerializer", + """ + |Unknown enum value found: $name" + |This usually means the backend was updated, and the SDK needs to be updated to match it. + |Check if there's a new version for the SDK, otherwise please open an issue on our + |GitHub to bring it to our attention: + |https://github.com/google/google-ai-android + """ + .trimMargin(), + ) + } + + override fun serialize(encoder: Encoder, value: T) { + encoder.encodeString(value.serialName) + } +} + +/** + * Provides the name to be used in serialization for this enum value. + * + * By default an enum is serialized to its [name][Enum.name], and can be overwritten by providing a + * [SerialName] annotation. + */ +internal val > T.serialName: String + get() = declaringJavaClass.getField(name).getAnnotation()?.value ?: name + +/** + * Variant of [kotlin.enumValues] that provides support for [KClass] instances of enums. + * + * @throws SerializationException if the class is not a valid enum. Beyond runtime emily magic, this + * shouldn't really be possible. + */ +internal fun > KClass.enumValues(): Array = + java.enumConstants ?: throw SerializationException("$simpleName is not a valid enum type.") diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/util.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/util.kt new file mode 100644 index 00000000000..1fc71f6c4ff --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/util/util.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.common.util + +/** + * Ensures the model name provided has a `models/` prefix + * + * Models must be prepended with the `models/` prefix when communicating with the backend. + */ +internal fun fullModelName(name: String): String = + name.takeIf { it.contains("/") } ?: "models/$name" + +internal fun trimmedModelName(name: String): String = name.split("/").last() diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/ChatFutures.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/ChatFutures.kt new file mode 100644 index 00000000000..2973cf78624 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/ChatFutures.kt @@ -0,0 +1,83 @@ +/* + * 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. + */ + +package com.google.firebase.ai.java + +import androidx.concurrent.futures.SuspendToFutureAdapter +import com.google.common.util.concurrent.ListenableFuture +import com.google.firebase.ai.Chat +import com.google.firebase.ai.type.Content +import com.google.firebase.ai.type.GenerateContentResponse +import com.google.firebase.ai.type.InvalidStateException +import kotlinx.coroutines.reactive.asPublisher +import org.reactivestreams.Publisher + +/** + * Wrapper class providing Java compatible methods for [Chat]. + * + * @see [Chat] + */ +public abstract class ChatFutures internal constructor() { + + /** + * Sends a message using the existing history of this chat as context and the provided [Content] + * prompt. + * + * If successful, the message and response will be added to the history. If unsuccessful, history + * will remain unchanged. + * + * @param prompt The input(s) that, together with the history, will be given to the model as the + * prompt. + * @throws InvalidStateException if [prompt] is not coming from the 'user' role + * @throws InvalidStateException if the [Chat] instance has an active request + */ + public abstract fun sendMessage(prompt: Content): ListenableFuture + + /** + * Sends a message using the existing history of this chat as context and the provided [Content] + * prompt. + * + * The response from the model is returned as a stream. + * + * If successful, the message and response will be added to the history. If unsuccessful, history + * will remain unchanged. + * + * @param prompt The input(s) that, together with the history, will be given to the model as the + * prompt. + * @throws InvalidStateException if [prompt] is not coming from the 'user' role + * @throws InvalidStateException if the [Chat] instance has an active request + */ + public abstract fun sendMessageStream(prompt: Content): Publisher + + /** Returns the [Chat] object wrapped by this object. */ + public abstract fun getChat(): Chat + + private class FuturesImpl(private val chat: Chat) : ChatFutures() { + override fun sendMessage(prompt: Content): ListenableFuture = + SuspendToFutureAdapter.launchFuture { chat.sendMessage(prompt) } + + override fun sendMessageStream(prompt: Content): Publisher = + chat.sendMessageStream(prompt).asPublisher() + + override fun getChat(): Chat = chat + } + + public companion object { + + /** @return a [ChatFutures] created around the provided [Chat] */ + @JvmStatic public fun from(chat: Chat): ChatFutures = FuturesImpl(chat) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/GenerativeModelFutures.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/GenerativeModelFutures.kt new file mode 100644 index 00000000000..57a531c1cd8 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/GenerativeModelFutures.kt @@ -0,0 +1,108 @@ +/* + * 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. + */ + +package com.google.firebase.ai.java + +import androidx.concurrent.futures.SuspendToFutureAdapter +import com.google.common.util.concurrent.ListenableFuture +import com.google.firebase.ai.GenerativeModel +import com.google.firebase.ai.java.ChatFutures.Companion.from +import com.google.firebase.ai.type.Content +import com.google.firebase.ai.type.CountTokensResponse +import com.google.firebase.ai.type.FirebaseAIException +import com.google.firebase.ai.type.GenerateContentResponse +import kotlinx.coroutines.reactive.asPublisher +import org.reactivestreams.Publisher + +/** + * Wrapper class providing Java compatible methods for [GenerativeModel]. + * + * @see [GenerativeModel] + */ +public abstract class GenerativeModelFutures internal constructor() { + + /** + * Generates new content from the input [Content] given to the model as a prompt. + * + * @param prompt The input(s) given to the model as a prompt. + * @return The content generated by the model. + * @throws [FirebaseAIException] if the request failed. + */ + public abstract fun generateContent( + vararg prompt: Content + ): ListenableFuture + + /** + * Generates new content as a stream from the input [Content] given to the model as a prompt. + * + * @param prompt The input(s) given to the model as a prompt. + * @return A [Publisher] which will emit responses as they are returned by the model. + * @throws [FirebaseAIException] if the request failed. + */ + public abstract fun generateContentStream( + vararg prompt: Content + ): Publisher + + /** + * Counts the number of tokens in a prompt using the model's tokenizer. + * + * @param prompt The input(s) given to the model as a prompt. + * @return The [CountTokensResponse] of running the model's tokenizer on the input. + * @throws [FirebaseAIException] if the request failed. + */ + public abstract fun countTokens(vararg prompt: Content): ListenableFuture + + /** + * Creates a [ChatFutures] instance which internally tracks the ongoing conversation with the + * model. + */ + public abstract fun startChat(): ChatFutures + + /** + * Creates a [ChatFutures] instance, initialized using the optionally provided [history]. + * + * @param history A list of previous interactions with the model to use as a starting point + */ + public abstract fun startChat(history: List): ChatFutures + + /** Returns the [GenerativeModel] object wrapped by this object. */ + public abstract fun getGenerativeModel(): GenerativeModel + + private class FuturesImpl(private val model: GenerativeModel) : GenerativeModelFutures() { + override fun generateContent( + vararg prompt: Content + ): ListenableFuture = + SuspendToFutureAdapter.launchFuture { model.generateContent(*prompt) } + + override fun generateContentStream(vararg prompt: Content): Publisher = + model.generateContentStream(*prompt).asPublisher() + + override fun countTokens(vararg prompt: Content): ListenableFuture = + SuspendToFutureAdapter.launchFuture { model.countTokens(*prompt) } + + override fun startChat(): ChatFutures = startChat(emptyList()) + + override fun startChat(history: List): ChatFutures = from(model.startChat(history)) + + override fun getGenerativeModel(): GenerativeModel = model + } + + public companion object { + + /** @return a [GenerativeModelFutures] created around the provided [GenerativeModel] */ + @JvmStatic public fun from(model: GenerativeModel): GenerativeModelFutures = FuturesImpl(model) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/ImagenModelFutures.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/ImagenModelFutures.kt new file mode 100644 index 00000000000..99d42d32732 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/ImagenModelFutures.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.java + +import androidx.concurrent.futures.SuspendToFutureAdapter +import com.google.common.util.concurrent.ListenableFuture +import com.google.firebase.ai.ImagenModel +import com.google.firebase.ai.type.ImagenGenerationResponse +import com.google.firebase.ai.type.ImagenInlineImage +import com.google.firebase.ai.type.PublicPreviewAPI + +/** + * Wrapper class providing Java compatible methods for [ImagenModel]. + * + * @see [ImagenModel] + */ +@PublicPreviewAPI +public abstract class ImagenModelFutures internal constructor() { + /** + * Generates an image, returning the result directly to the caller. + * + * @param prompt The main text prompt from which the image is generated. + */ + public abstract fun generateImages( + prompt: String, + ): ListenableFuture> + + /** Returns the [ImagenModel] object wrapped by this object. */ + public abstract fun getImageModel(): ImagenModel + + private class FuturesImpl(private val model: ImagenModel) : ImagenModelFutures() { + override fun generateImages( + prompt: String, + ): ListenableFuture> = + SuspendToFutureAdapter.launchFuture { model.generateImages(prompt) } + + override fun getImageModel(): ImagenModel = model + } + + public companion object { + + /** @return a [ImagenModelFutures] created around the provided [ImagenModel] */ + @JvmStatic public fun from(model: ImagenModel): ImagenModelFutures = FuturesImpl(model) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/LiveModelFutures.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/LiveModelFutures.kt new file mode 100644 index 00000000000..9eb222007a0 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/LiveModelFutures.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law 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. + */ + +package com.google.firebase.ai.java + +import androidx.concurrent.futures.SuspendToFutureAdapter +import com.google.common.util.concurrent.ListenableFuture +import com.google.firebase.ai.LiveGenerativeModel +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.ServiceConnectionHandshakeFailedException + +/** + * Wrapper class providing Java compatible methods for [LiveGenerativeModel]. + * + * @see [LiveGenerativeModel] + */ +@PublicPreviewAPI +public abstract class LiveModelFutures internal constructor() { + + /** + * Start a [LiveSessionFutures] with the server for bidirectional streaming. + * @return A [LiveSessionFutures] that you can use to stream messages to and from the server. + * @throws [ServiceConnectionHandshakeFailedException] If the client was not able to establish a + * connection with the server. + */ + public abstract fun connect(): ListenableFuture + + private class FuturesImpl(private val model: LiveGenerativeModel) : LiveModelFutures() { + override fun connect(): ListenableFuture { + return SuspendToFutureAdapter.launchFuture { LiveSessionFutures.from(model.connect()) } + } + } + + public companion object { + + /** @return a [LiveModelFutures] created around the provided [LiveGenerativeModel] */ + @JvmStatic public fun from(model: LiveGenerativeModel): LiveModelFutures = FuturesImpl(model) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/LiveSessionFutures.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/LiveSessionFutures.kt new file mode 100644 index 00000000000..1efa2dfedfc --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/java/LiveSessionFutures.kt @@ -0,0 +1,183 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.java + +import android.Manifest.permission.RECORD_AUDIO +import androidx.annotation.RequiresPermission +import androidx.concurrent.futures.SuspendToFutureAdapter +import com.google.common.util.concurrent.ListenableFuture +import com.google.firebase.ai.type.Content +import com.google.firebase.ai.type.FunctionCallPart +import com.google.firebase.ai.type.FunctionResponsePart +import com.google.firebase.ai.type.LiveServerMessage +import com.google.firebase.ai.type.LiveSession +import com.google.firebase.ai.type.MediaData +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.SessionAlreadyReceivingException +import io.ktor.websocket.close +import kotlinx.coroutines.reactive.asPublisher +import org.reactivestreams.Publisher + +/** + * Wrapper class providing Java compatible methods for [LiveSession]. + * + * @see [LiveSession] + */ +@PublicPreviewAPI +public abstract class LiveSessionFutures internal constructor() { + + /** + * Starts an audio conversation with the model, which can only be stopped using + * [stopAudioConversation] or [close]. + * + * @param functionCallHandler A callback function that is invoked whenever the model receives a + * function call. + */ + public abstract fun startAudioConversation( + functionCallHandler: ((FunctionCallPart) -> FunctionResponsePart)? + ): ListenableFuture + + /** + * Starts an audio conversation with the model, which can only be stopped using + * [stopAudioConversation]. + */ + @RequiresPermission(RECORD_AUDIO) + public abstract fun startAudioConversation(): ListenableFuture + + /** + * Stops the audio conversation with the Gemini Server. + * + * This only needs to be called after a previous call to [startAudioConversation]. + * + * If there is no audio conversation currently active, this function does nothing. + */ + @RequiresPermission(RECORD_AUDIO) + public abstract fun stopAudioConversation(): ListenableFuture + + /** + * Stops receiving from the model. + * + * If this function is called during an ongoing audio conversation, the model's response will not + * be received, and no audio will be played; the live session object will no longer receive data + * from the server. + * + * To resume receiving data, you must either handle it directly using [receive], or indirectly by + * using [startAudioConversation]. + * + * @see close + */ + // TODO(b/410059569): Remove when fixed + public abstract fun stopReceiving() + + /** + * Sends function calling responses to the model. + * + * @param functionList The list of [FunctionResponsePart] instances indicating the function + * response from the client. + */ + public abstract fun sendFunctionResponse( + functionList: List + ): ListenableFuture + + /** + * Streams client data to the model. + * + * Calling this after [startAudioConversation] will play the response audio immediately. + * + * @param mediaChunks The list of [MediaData] instances representing the media data to be sent. + */ + public abstract fun sendMediaStream(mediaChunks: List): ListenableFuture + + /** + * Sends [data][Content] to the model. + * + * Calling this after [startAudioConversation] will play the response audio immediately. + * + * @param content Client [Content] to be sent to the model. + */ + public abstract fun send(content: Content): ListenableFuture + + /** + * Sends text to the model. + * + * Calling this after [startAudioConversation] will play the response audio immediately. + * + * @param text Text to be sent to the model. + */ + public abstract fun send(text: String): ListenableFuture + + /** + * Closes the client session. + * + * Once a [LiveSession] is closed, it can not be reopened; you'll need to start a new + * [LiveSession]. + * + * @see stopReceiving + */ + public abstract fun close(): ListenableFuture + + /** + * Receives responses from the model for both streaming and standard requests. + * + * Call [close] to stop receiving responses from the model. + * + * @return A [Publisher] which will emit [LiveServerMessage] from the model. + * + * @throws [SessionAlreadyReceivingException] when the session is already receiving. + * @see stopReceiving + */ + public abstract fun receive(): Publisher + + private class FuturesImpl(private val session: LiveSession) : LiveSessionFutures() { + + override fun receive(): Publisher = session.receive().asPublisher() + + override fun close(): ListenableFuture = + SuspendToFutureAdapter.launchFuture { session.close() } + + override fun send(text: String) = SuspendToFutureAdapter.launchFuture { session.send(text) } + + override fun send(content: Content) = + SuspendToFutureAdapter.launchFuture { session.send(content) } + + override fun sendFunctionResponse(functionList: List) = + SuspendToFutureAdapter.launchFuture { session.sendFunctionResponse(functionList) } + + override fun sendMediaStream(mediaChunks: List) = + SuspendToFutureAdapter.launchFuture { session.sendMediaStream(mediaChunks) } + + @RequiresPermission(RECORD_AUDIO) + override fun startAudioConversation( + functionCallHandler: ((FunctionCallPart) -> FunctionResponsePart)? + ) = SuspendToFutureAdapter.launchFuture { session.startAudioConversation(functionCallHandler) } + + @RequiresPermission(RECORD_AUDIO) + override fun startAudioConversation() = + SuspendToFutureAdapter.launchFuture { session.startAudioConversation() } + + override fun stopAudioConversation() = + SuspendToFutureAdapter.launchFuture { session.stopAudioConversation() } + + override fun stopReceiving() = session.stopReceiving() + } + + public companion object { + + /** @return a [LiveSessionFutures] created around the provided [LiveSession] */ + @JvmStatic public fun from(session: LiveSession): LiveSessionFutures = FuturesImpl(session) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AudioHelper.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AudioHelper.kt new file mode 100644 index 00000000000..4db66ae6c3e --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AudioHelper.kt @@ -0,0 +1,213 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import android.Manifest +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioRecord +import android.media.AudioTrack +import android.media.MediaRecorder +import android.media.audiofx.AcousticEchoCanceler +import android.util.Log +import androidx.annotation.RequiresPermission +import com.google.firebase.ai.common.util.readAsFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +/** + * Helper class for recording audio and playing back a separate audio track at the same time. + * + * @see AudioHelper.build + * @see LiveSession.startAudioConversation + */ +@PublicPreviewAPI +internal class AudioHelper( + /** Record for recording the System microphone. */ + private val recorder: AudioRecord, + /** Track for playing back what the model says. */ + private val playbackTrack: AudioTrack, +) { + private var released: Boolean = false + + /** + * Release the system resources on the recorder and playback track. + * + * Once an [AudioHelper] has been "released", it can _not_ be used again. + * + * This method can safely be called multiple times, as it won't do anything if this instance has + * already been released. + */ + fun release() { + if (released) return + released = true + + recorder.release() + playbackTrack.release() + } + + /** + * Play the provided audio data on the playback track. + * + * Does nothing if this [AudioHelper] has been [released][release]. + * + * @throws IllegalStateException If the playback track was not properly initialized. + * @throws IllegalArgumentException If the playback data is invalid. + * @throws RuntimeException If we fail to play the audio data for some unknown reason. + */ + fun playAudio(data: ByteArray) { + if (released) return + if (data.isEmpty()) return + + if (playbackTrack.playState == AudioTrack.PLAYSTATE_STOPPED) playbackTrack.play() + + val result = playbackTrack.write(data, 0, data.size) + if (result > 0) return + if (result == 0) { + Log.w( + TAG, + "Failed to write any audio bytes to the playback track. The audio track may have been stopped or paused." + ) + return + } + + // ERROR_INVALID_OPERATION and ERROR_BAD_VALUE should never occur + when (result) { + AudioTrack.ERROR_INVALID_OPERATION -> + throw IllegalStateException("The playback track was not properly initialized.") + AudioTrack.ERROR_BAD_VALUE -> + throw IllegalArgumentException("Playback data is somehow invalid.") + AudioTrack.ERROR_DEAD_OBJECT -> { + Log.w(TAG, "Attempted to playback some audio, but the track has been released.") + release() // to ensure `released` is set and `record` is released too + } + AudioTrack.ERROR -> + throw RuntimeException("Failed to play the audio data for some unknown reason.") + } + } + + /** + * Pause the recording of the microphone, if it's recording. + * + * Does nothing if this [AudioHelper] has been [released][release]. + * + * @see resumeRecording + * + * @throws IllegalStateException If the playback track was not properly initialized. + */ + fun pauseRecording() { + if (released || recorder.recordingState == AudioRecord.RECORDSTATE_STOPPED) return + + try { + recorder.stop() + } catch (e: IllegalStateException) { + release() + throw IllegalStateException("The playback track was not properly initialized.") + } + } + + /** + * Resumes the recording of the microphone, if it's not already running. + * + * Does nothing if this [AudioHelper] has been [released][release]. + * + * @see pauseRecording + */ + fun resumeRecording() { + if (released || recorder.recordingState == AudioRecord.RECORDSTATE_RECORDING) return + + recorder.startRecording() + } + + /** + * Start perpetually recording the system microphone, and return the bytes read in a flow. + * + * Returns an empty flow if this [AudioHelper] has been [released][release]. + */ + fun listenToRecording(): Flow { + if (released) return emptyFlow() + + resumeRecording() + + return recorder.readAsFlow() + } + + companion object { + private val TAG = AudioHelper::class.simpleName + + /** + * Creates an instance of [AudioHelper] with the track and record initialized. + * + * A separate build method is necessary so that we can properly propagate the required manifest + * permission, and throw exceptions when needed. + * + * It also makes it easier to read, since the long initialization is separate from the + * constructor. + */ + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + fun build(): AudioHelper { + val playbackTrack = + AudioTrack( + AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION).build(), + AudioFormat.Builder() + .setSampleRate(24000) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .build(), + AudioTrack.getMinBufferSize( + 24000, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT + ), + AudioTrack.MODE_STREAM, + AudioManager.AUDIO_SESSION_ID_GENERATE + ) + + val bufferSize = + AudioRecord.getMinBufferSize( + 16000, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + + if (bufferSize <= 0) + throw AudioRecordInitializationFailedException( + "Audio Record buffer size is invalid ($bufferSize)" + ) + + val recorder = + AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, + 16000, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize + ) + if (recorder.state != AudioRecord.STATE_INITIALIZED) + throw AudioRecordInitializationFailedException( + "Audio Record initialization has failed. State: ${recorder.state}" + ) + + if (AcousticEchoCanceler.isAvailable()) { + AcousticEchoCanceler.create(recorder.audioSessionId)?.enabled = true + } + + return AudioHelper(recorder, playbackTrack) + } + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Candidate.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Candidate.kt new file mode 100644 index 00000000000..d5fc51f21c0 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Candidate.kt @@ -0,0 +1,319 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalSerializationApi::class) + +package com.google.firebase.ai.type + +import com.google.firebase.ai.common.util.FirstOrdinalSerializer +import java.util.Calendar +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +/** + * A `Candidate` represents a single response generated by the model for a given request. + * + * @property content The actual content generated by the model. + * @property safetyRatings A list of [SafetyRating]s describing the generated content. + * @property citationMetadata Metadata about the sources used to generate this content. + * @property finishReason The reason the model stopped generating content, if it exist. + */ +public class Candidate +internal constructor( + public val content: Content, + public val safetyRatings: List, + public val citationMetadata: CitationMetadata?, + public val finishReason: FinishReason? +) { + + @Serializable + internal data class Internal( + val content: Content.Internal? = null, + val finishReason: FinishReason.Internal? = null, + val safetyRatings: List? = null, + val citationMetadata: CitationMetadata.Internal? = null, + val groundingMetadata: GroundingMetadata? = null, + ) { + internal fun toPublic(): Candidate { + val safetyRatings = safetyRatings?.mapNotNull { it.toPublic() }.orEmpty() + val citations = citationMetadata?.toPublic() + val finishReason = finishReason?.toPublic() + + return Candidate( + this.content?.toPublic() ?: content("model") {}, + safetyRatings, + citations, + finishReason + ) + } + + @Serializable + internal data class GroundingMetadata( + @SerialName("web_search_queries") val webSearchQueries: List?, + @SerialName("search_entry_point") val searchEntryPoint: SearchEntryPoint?, + @SerialName("retrieval_queries") val retrievalQueries: List?, + @SerialName("grounding_attribution") val groundingAttribution: List?, + ) { + + @Serializable + internal data class SearchEntryPoint( + @SerialName("rendered_content") val renderedContent: String?, + @SerialName("sdk_blob") val sdkBlob: String?, + ) + + @Serializable + internal data class GroundingAttribution( + val segment: Segment, + @SerialName("confidence_score") val confidenceScore: Float?, + ) { + + @Serializable + internal data class Segment( + @SerialName("start_index") val startIndex: Int, + @SerialName("end_index") val endIndex: Int, + ) + } + } + } +} + +/** + * An assessment of the potential harm of some generated content. + * + * The rating will be restricted to a particular [category]. + * + * @property category The category of harm being assessed (e.g., Hate speech). + * @property probability The likelihood of the content causing harm. + * @property probabilityScore A numerical score representing the probability of harm, between 0 and + * 1. + * @property blocked Indicates whether the content was blocked due to safety concerns. + * @property severity The severity of the potential harm. + * @property severityScore A numerical score representing the severity of harm. + */ +public class SafetyRating +internal constructor( + public val category: HarmCategory, + public val probability: HarmProbability, + public val probabilityScore: Float = 0f, + public val blocked: Boolean? = null, + public val severity: HarmSeverity? = null, + public val severityScore: Float? = null +) { + + @Serializable + internal data class Internal + @JvmOverloads + constructor( + val category: HarmCategory.Internal? = null, + val probability: HarmProbability.Internal? = null, + val blocked: Boolean? = null, // TODO(): any reason not to default to false? + val probabilityScore: Float? = null, + val severity: HarmSeverity.Internal? = null, + val severityScore: Float? = null, + ) { + + internal fun toPublic() = + // Due to a bug in the backend, it's possible that we receive + // an invalid `SafetyRating` value, without either category or + // probability. We return null in those cases to enable + // filtering by the higher level types. + if (category == null || probability == null) { + null + } else { + SafetyRating( + category = category.toPublic(), + probability = probability.toPublic(), + probabilityScore = probabilityScore ?: 0f, + blocked = blocked, + severity = severity?.toPublic(), + severityScore = severityScore + ) + } + } +} + +/** + * A collection of source attributions for a piece of content. + * + * @property citations A list of individual cited sources and the parts of the content to which they + * apply. + */ +public class CitationMetadata internal constructor(public val citations: List) { + + @Serializable + internal data class Internal + @OptIn(ExperimentalSerializationApi::class) + internal constructor(@JsonNames("citations") val citationSources: List) { + + internal fun toPublic() = CitationMetadata(citationSources.map { it.toPublic() }) + } +} + +/** + * Represents a citation of content from an external source within the model's output. + * + * When the language model generates text that includes content from another source, it should + * provide a citation to properly attribute the original source. This class encapsulates the + * metadata associated with that citation. + * + * @property title The title of the cited source, if available. + * @property startIndex The (inclusive) starting index within the model output where the cited + * content begins. + * @property endIndex The (exclusive) ending index within the model output where the cited content + * ends. + * @property uri The URI of the cited source, if available. + * @property license The license under which the cited content is distributed under, if available. + * @property publicationDate The date of publication of the cited source, if available. + */ +public class Citation +internal constructor( + public val title: String? = null, + public val startIndex: Int = 0, + public val endIndex: Int, + public val uri: String? = null, + public val license: String? = null, + public val publicationDate: Calendar? = null +) { + + @Serializable + internal data class Internal( + val title: String? = null, + val startIndex: Int = 0, + val endIndex: Int, + val uri: String? = null, + val license: String? = null, + val publicationDate: Date? = null, + ) { + + internal fun toPublic(): Citation { + val publicationDateAsCalendar = + publicationDate?.let { + val calendar = Calendar.getInstance() + // Internal `Date.year` uses 0 to represent not specified. We use 1 as default. + val year = if (it.year == null || it.year < 1) 1 else it.year + // Internal `Date.month` uses 0 to represent not specified, or is 1-12 as months. The + // month as + // expected by [Calendar] is 0-based, so we subtract 1 or use 0 as default. + val month = if (it.month == null || it.month < 1) 0 else it.month - 1 + // Internal `Date.day` uses 0 to represent not specified. We use 1 as default. + val day = if (it.day == null || it.day < 1) 1 else it.day + calendar.set(year, month, day) + calendar + } + return Citation( + title = title, + startIndex = startIndex, + endIndex = endIndex, + uri = uri, + license = license, + publicationDate = publicationDateAsCalendar + ) + } + + @Serializable + internal data class Date( + /** Year of the date. Must be between 1 and 9999, or 0 for no year. */ + val year: Int? = null, + /** 1-based index for month. Must be from 1 to 12, or 0 to specify a year without a month. */ + val month: Int? = null, + /** + * Day of a month. Must be from 1 to 31 and valid for the year and month, or 0 to specify a + * year by itself or a year and month where the day isn't significant. + */ + val day: Int? = null, + ) + } +} + +/** + * Represents the reason why the model stopped generating content. + * + * @property name The name of the finish reason. + * @property ordinal The ordinal value of the finish reason. + */ +public class FinishReason private constructor(public val name: String, public val ordinal: Int) { + + @Serializable(Internal.Serializer::class) + internal enum class Internal { + UNKNOWN, + @SerialName("FINISH_REASON_UNSPECIFIED") UNSPECIFIED, + STOP, + MAX_TOKENS, + SAFETY, + RECITATION, + OTHER, + BLOCKLIST, + PROHIBITED_CONTENT, + SPII, + MALFORMED_FUNCTION_CALL; + + internal object Serializer : KSerializer by FirstOrdinalSerializer(Internal::class) + + internal fun toPublic() = + when (this) { + MAX_TOKENS -> FinishReason.MAX_TOKENS + RECITATION -> FinishReason.RECITATION + SAFETY -> FinishReason.SAFETY + STOP -> FinishReason.STOP + OTHER -> FinishReason.OTHER + BLOCKLIST -> FinishReason.BLOCKLIST + PROHIBITED_CONTENT -> FinishReason.PROHIBITED_CONTENT + SPII -> FinishReason.SPII + MALFORMED_FUNCTION_CALL -> FinishReason.MALFORMED_FUNCTION_CALL + else -> FinishReason.UNKNOWN + } + } + public companion object { + /** A new and not yet supported value. */ + @JvmField public val UNKNOWN: FinishReason = FinishReason("UNKNOWN", 0) + + /** Model finished successfully and stopped. */ + @JvmField public val STOP: FinishReason = FinishReason("STOP", 1) + + /** Model hit the token limit. */ + @JvmField public val MAX_TOKENS: FinishReason = FinishReason("MAX_TOKENS", 2) + + /** [SafetySetting] prevented the model from outputting content. */ + @JvmField public val SAFETY: FinishReason = FinishReason("SAFETY", 3) + + /** + * The token generation was stopped because the response was flagged for unauthorized citations. + */ + @JvmField public val RECITATION: FinishReason = FinishReason("RECITATION", 4) + + /** Model stopped for another reason. */ + @JvmField public val OTHER: FinishReason = FinishReason("OTHER", 5) + + /** Token generation stopped because the content contains forbidden terms. */ + @JvmField public val BLOCKLIST: FinishReason = FinishReason("BLOCKLIST", 6) + + /** Token generation stopped for potentially containing prohibited content. */ + @JvmField public val PROHIBITED_CONTENT: FinishReason = FinishReason("PROHIBITED_CONTENT", 7) + + /** + * Token generation stopped because the content potentially contains Sensitive Personally + * Identifiable Information (SPII). + */ + @JvmField public val SPII: FinishReason = FinishReason("SPII", 8) + + /** The function call generated by the model is invalid. */ + @JvmField + public val MALFORMED_FUNCTION_CALL: FinishReason = FinishReason("MALFORMED_FUNCTION_CALL", 9) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Content.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Content.kt new file mode 100644 index 00000000000..4e9f1a860db --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Content.kt @@ -0,0 +1,123 @@ +/* + * 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. + */ + +package com.google.firebase.ai.type + +import android.graphics.Bitmap +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable + +/** + * Represents content sent to and received from the model. + * + * `Content` is composed of a one or more heterogeneous parts that can be represent data in + * different formats, like text or images. + * + * @param role The producer of the content. Must be either `"user"` or `"model"`. By default, it's + * `"user"`. + * @param parts An ordered list of [Part] that constitute this content. + */ +public class Content +@JvmOverloads +constructor(public val role: String? = "user", public val parts: List) { + + /** Returns a copy of this object, with the provided parameters overwriting the originals. */ + public fun copy(role: String? = this.role, parts: List = this.parts): Content { + return Content(role, parts) + } + + /** Builder class to facilitate constructing complex [Content] objects. */ + public class Builder { + + /** The producer of the content. Must be either 'user' or 'model'. By default, it's "user". */ + @JvmField public var role: String? = "user" + + /** + * The mutable list of [Part]s comprising the [Content]. + * + * Prefer using the provided helper methods over modifying this list directly. + */ + @JvmField public var parts: MutableList = arrayListOf() + + public fun setRole(role: String?): Content.Builder = apply { this.role = role } + public fun setParts(parts: MutableList): Content.Builder = apply { this.parts = parts } + + /** Adds a new [Part] to [parts]. */ + @JvmName("addPart") + public fun part(data: T): Content.Builder = apply { parts.add(data) } + + /** Adds a new [TextPart] with the provided [text] to [parts]. */ + @JvmName("addText") public fun text(text: String): Content.Builder = part(TextPart(text)) + + /** + * Adds a new [InlineDataPart] with the provided [bytes], which should be interpreted by the + * model based on the [mimeType], to [parts]. + */ + @JvmName("addInlineData") + public fun inlineData(bytes: ByteArray, mimeType: String): Content.Builder = + part(InlineDataPart(bytes, mimeType)) + + /** Adds a new [ImagePart] with the provided [image] to [parts]. */ + @JvmName("addImage") public fun image(image: Bitmap): Content.Builder = part(ImagePart(image)) + + /** Adds a new [FileDataPart] with the provided [uri] and [mimeType] to [parts]. */ + @JvmName("addFileData") + public fun fileData(uri: String, mimeType: String): Content.Builder = + part(FileDataPart(uri, mimeType)) + + /** Returns a new [Content] using the defined [role] and [parts]. */ + public fun build(): Content = Content(role, parts) + } + + @OptIn(ExperimentalSerializationApi::class) + internal fun toInternal() = Internal(this.role ?: "user", this.parts.map { it.toInternal() }) + + @ExperimentalSerializationApi + @Serializable + internal data class Internal( + @EncodeDefault val role: String? = "user", + val parts: List + ) { + internal fun toPublic(): Content { + val returnedParts = + parts.map { it.toPublic() }.filterNot { it is TextPart && it.text.isEmpty() } + // If all returned parts were text and empty, we coalesce them into a single one-character + // string + // part so the backend doesn't fail if we send this back as part of a multi-turn interaction. + return Content(role, returnedParts.ifEmpty { listOf(TextPart(" ")) }) + } + } +} + +/** + * Function to build a new [Content] instances in a DSL-like manner. + * + * Contains a collection of text, image, and binary parts. + * + * Example usage: + * ``` + * content("user") { + * text("Example string") + * ) + * ``` + */ +public fun content(role: String? = "user", init: Content.Builder.() -> Unit): Content { + val builder = Content.Builder() + builder.role = role + builder.init() + return builder.build() +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ContentModality.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ContentModality.kt new file mode 100644 index 00000000000..bfdf8831a43 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ContentModality.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import com.google.firebase.ai.common.util.FirstOrdinalSerializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Content part modality. */ +public class ContentModality private constructor(public val ordinal: Int) { + + @Serializable(Internal.Serializer::class) + internal enum class Internal { + @SerialName("MODALITY_UNSPECIFIED") UNSPECIFIED, + TEXT, + IMAGE, + VIDEO, + AUDIO, + DOCUMENT; + + internal object Serializer : KSerializer by FirstOrdinalSerializer(Internal::class) + + internal fun toPublic() = + when (this) { + TEXT -> ContentModality.TEXT + IMAGE -> ContentModality.IMAGE + VIDEO -> ContentModality.VIDEO + AUDIO -> ContentModality.AUDIO + DOCUMENT -> ContentModality.DOCUMENT + else -> ContentModality.UNSPECIFIED + } + } + + internal fun toInternal() = + when (this) { + TEXT -> "TEXT" + IMAGE -> "IMAGE" + VIDEO -> "VIDEO" + AUDIO -> "AUDIO" + DOCUMENT -> "DOCUMENT" + else -> "UNSPECIFIED" + } + public companion object { + /** Unspecified modality. */ + @JvmField public val UNSPECIFIED: ContentModality = ContentModality(0) + + /** Plain text. */ + @JvmField public val TEXT: ContentModality = ContentModality(1) + + /** Image. */ + @JvmField public val IMAGE: ContentModality = ContentModality(2) + + /** Video. */ + @JvmField public val VIDEO: ContentModality = ContentModality(3) + + /** Audio. */ + @JvmField public val AUDIO: ContentModality = ContentModality(4) + + /** Document, e.g. PDF. */ + @JvmField public val DOCUMENT: ContentModality = ContentModality(5) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/CountTokensResponse.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/CountTokensResponse.kt new file mode 100644 index 00000000000..955f7bf941a --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/CountTokensResponse.kt @@ -0,0 +1,62 @@ +/* + * 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. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.Serializable + +/** + * The model's response to a count tokens request. + * + * **Important:** The counters in this class do not include billable image, video or other non-text + * input. See [Vertex AI pricing](https://cloud.google.com/vertex-ai/generative-ai/pricing) for + * details. + * + * @property totalTokens The total number of tokens in the input given to the model as a prompt. + * @property totalBillableCharacters The total number of billable characters in the text input given + * to the model as a prompt. **Important:** this property does not include billable image, video or + * other non-text input. See + * [Vertex AI pricing](https://cloud.google.com/vertex-ai/generative-ai/pricing) for details. + * @property promptTokensDetails The breakdown, by modality, of how many tokens are consumed by the + * prompt. + */ +public class CountTokensResponse( + public val totalTokens: Int, + public val totalBillableCharacters: Int? = null, + public val promptTokensDetails: List = emptyList(), +) { + public operator fun component1(): Int = totalTokens + + public operator fun component2(): Int? = totalBillableCharacters + + public operator fun component3(): List? = promptTokensDetails + + @Serializable + internal data class Internal( + val totalTokens: Int, + val totalBillableCharacters: Int? = null, + val promptTokensDetails: List? = null + ) : Response { + + internal fun toPublic(): CountTokensResponse { + return CountTokensResponse( + totalTokens, + totalBillableCharacters ?: 0, + promptTokensDetails?.map { it.toPublic() } ?: emptyList() + ) + } + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Exceptions.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Exceptions.kt new file mode 100644 index 00000000000..57a27f241a0 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Exceptions.kt @@ -0,0 +1,224 @@ +/* + * 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. + */ + +package com.google.firebase.ai.type + +import com.google.firebase.ai.FirebaseAI +import com.google.firebase.ai.common.FirebaseCommonAIException +import kotlinx.coroutines.TimeoutCancellationException + +/** Parent class for any errors that occur from the [FirebaseAI] SDK. */ +public abstract class FirebaseAIException +internal constructor(message: String, cause: Throwable? = null) : RuntimeException(message, cause) { + + internal companion object { + + /** + * Converts a [Throwable] to a [FirebaseAIException]. + * + * Will populate default messages as expected, and propagate the provided [cause] through the + * resulting exception. + */ + internal fun from(cause: Throwable): FirebaseAIException = + when (cause) { + is FirebaseAIException -> cause + is FirebaseCommonAIException -> + when (cause) { + is com.google.firebase.ai.common.SerializationException -> + SerializationException(cause.message ?: "", cause.cause) + is com.google.firebase.ai.common.ServerException -> + ServerException(cause.message ?: "", cause.cause) + is com.google.firebase.ai.common.InvalidAPIKeyException -> + InvalidAPIKeyException(cause.message ?: "") + is com.google.firebase.ai.common.PromptBlockedException -> + PromptBlockedException(cause.response?.toPublic(), cause.cause) + is com.google.firebase.ai.common.UnsupportedUserLocationException -> + UnsupportedUserLocationException(cause.cause) + is com.google.firebase.ai.common.InvalidStateException -> + InvalidStateException(cause.message ?: "", cause) + is com.google.firebase.ai.common.ResponseStoppedException -> + ResponseStoppedException(cause.response.toPublic(), cause.cause) + is com.google.firebase.ai.common.RequestTimeoutException -> + RequestTimeoutException(cause.message ?: "", cause.cause) + is com.google.firebase.ai.common.ServiceDisabledException -> + ServiceDisabledException(cause.message ?: "", cause.cause) + is com.google.firebase.ai.common.UnknownException -> + UnknownException(cause.message ?: "", cause.cause) + is com.google.firebase.ai.common.ContentBlockedException -> + ContentBlockedException(cause.message ?: "", cause.cause) + is com.google.firebase.ai.common.QuotaExceededException -> + QuotaExceededException(cause.message ?: "", cause.cause) + else -> UnknownException(cause.message ?: "", cause) + } + is TimeoutCancellationException -> + RequestTimeoutException("The request failed to complete in the allotted time.") + else -> UnknownException("Something unexpected happened.", cause) + } + + /** + * Catch any exception thrown in the [callback] block and rethrow it as a [FirebaseAIException]. + * + * Will return whatever the [callback] returns as well. + * + * @see catch + */ + internal suspend fun catchAsync(callback: suspend () -> T): T { + try { + return callback() + } catch (e: Exception) { + throw from(e) + } + } + + /** + * Catch any exception thrown in the [callback] block and rethrow it as a [FirebaseAIException]. + * + * Will return whatever the [callback] returns as well. + * + * @see catchAsync + */ + internal fun catch(callback: () -> T): T { + try { + return callback() + } catch (e: Exception) { + throw from(e) + } + } + } +} + +/** Something went wrong while trying to deserialize a response from the server. */ +public class SerializationException +internal constructor(message: String, cause: Throwable? = null) : + FirebaseAIException(message, cause) + +/** The server responded with a non 200 response code. */ +public class ServerException internal constructor(message: String, cause: Throwable? = null) : + FirebaseAIException(message, cause) + +/** The provided API Key is not valid. */ +public class InvalidAPIKeyException +internal constructor(message: String, cause: Throwable? = null) : + FirebaseAIException(message, cause) + +/** + * A request was blocked. + * + * See the [response's][response] `promptFeedback.blockReason` for more information. + * + * @property response The full server response. + */ +public class PromptBlockedException +internal constructor( + public val response: GenerateContentResponse?, + cause: Throwable? = null, + message: String? = null, +) : + FirebaseAIException( + "Prompt was blocked: ${response?.promptFeedback?.blockReason?.name?: message}", + cause, + ) { + internal constructor(message: String, cause: Throwable? = null) : this(null, cause, message) +} + +public class ContentBlockedException +internal constructor(message: String, cause: Throwable? = null) : + FirebaseAIException(message, cause) + +/** + * The user's location (region) is not supported by the API. + * + * See the documentation for a + * [list of regions](https://firebase.google.com/docs/vertex-ai/locations?platform=android#available-locations) + * (countries and territories) where the API is available. + */ +// TODO(rlazo): Add secondary constructor to pass through the message? +public class UnsupportedUserLocationException internal constructor(cause: Throwable? = null) : + FirebaseAIException("User location is not supported for the API use.", cause) + +/** + * Some form of state occurred that shouldn't have. + * + * Usually indicative of consumer error. + */ +public class InvalidStateException internal constructor(message: String, cause: Throwable? = null) : + FirebaseAIException(message, cause) + +/** + * A request was stopped during generation for some reason. + * + * @property response The full server response. + */ +public class ResponseStoppedException +internal constructor(public val response: GenerateContentResponse, cause: Throwable? = null) : + FirebaseAIException( + "Content generation stopped. Reason: ${response.candidates.first().finishReason?.name}", + cause, + ) + +/** + * A request took too long to complete. + * + * Usually occurs due to a user specified [timeout][RequestOptions.timeout]. + */ +public class RequestTimeoutException +internal constructor(message: String, cause: Throwable? = null) : + FirebaseAIException(message, cause) + +/** + * The specified Vertex AI location is invalid. + * + * For a list of valid locations, see + * [Vertex AI locations.](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations#available-regions) + */ +public class InvalidLocationException +internal constructor(location: String, cause: Throwable? = null) : + FirebaseAIException("Invalid location \"${location}\"", cause) + +/** + * The service is not enabled for this Firebase project. Learn how to enable the required services + * in the + * [Firebase documentation.](https://firebase.google.com/docs/vertex-ai/faq-and-troubleshooting#required-apis) + */ +public class ServiceDisabledException +internal constructor(message: String, cause: Throwable? = null) : + FirebaseAIException(message, cause) + +/** + * The request has hit a quota limit. Learn more about quotas in the + * [Firebase documentation.](https://firebase.google.com/docs/vertex-ai/quotas) + */ +public class QuotaExceededException +internal constructor(message: String, cause: Throwable? = null) : + FirebaseAIException(message, cause) + +/** Streaming session already receiving. */ +public class SessionAlreadyReceivingException : + FirebaseAIException( + "This session is already receiving. Please call stopReceiving() before calling this again." + ) + +/** Audio record initialization failures for audio streaming */ +public class AudioRecordInitializationFailedException(message: String) : + FirebaseAIException(message) + +/** Handshake failed with the server */ +public class ServiceConnectionHandshakeFailedException(message: String, cause: Throwable? = null) : + FirebaseAIException(message, cause) + +/** Catch all case for exceptions not explicitly expected. */ +public class UnknownException internal constructor(message: String, cause: Throwable? = null) : + FirebaseAIException(message, cause) diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/FunctionCallingConfig.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/FunctionCallingConfig.kt new file mode 100644 index 00000000000..f854de85fd8 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/FunctionCallingConfig.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * The configuration that specifies the function calling behavior. + * + * See the static methods in the `companion object` for the list of available behaviors. + */ +public class FunctionCallingConfig +internal constructor( + internal val mode: Mode, + internal val allowedFunctionNames: List? = null +) { + + /** Configuration for dictating when the model should call the attached function. */ + internal enum class Mode { + /** + * The default behavior for function calling. The model calls functions to answer queries at its + * discretion + */ + AUTO, + + /** The model always predicts a provided function call to answer every query. */ + ANY, + + /** + * The model will never predict a function call to answer a query. This can also be achieved by + * not passing any tools to the model. + */ + NONE, + } + + @Serializable + internal data class Internal( + val mode: Mode, + @SerialName("allowed_function_names") val allowedFunctionNames: List? = null + ) { + @Serializable + enum class Mode { + @SerialName("MODE_UNSPECIFIED") UNSPECIFIED, + AUTO, + ANY, + NONE, + } + } + + public companion object { + /** + * The default behavior for function calling. The model calls functions to answer queries at its + * discretion. + */ + @JvmStatic public fun auto(): FunctionCallingConfig = FunctionCallingConfig(Mode.AUTO) + + /** The model always predicts a provided function call to answer every query. */ + @JvmStatic + @JvmOverloads + public fun any(allowedFunctionNames: List? = null): FunctionCallingConfig = + FunctionCallingConfig(Mode.ANY, allowedFunctionNames) + + /** + * The model will never predict a function call to answer a query. This can also be achieved by + * not passing any tools to the model. + */ + @JvmStatic public fun none(): FunctionCallingConfig = FunctionCallingConfig(Mode.NONE) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/FunctionDeclaration.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/FunctionDeclaration.kt new file mode 100644 index 00000000000..2b73d5ccfb1 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/FunctionDeclaration.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.Serializable + +/** + * Defines a function that the model can use as a tool. + * + * When generating responses, the model might need external information or require the application + * to perform an action. `FunctionDeclaration` provides the necessary information for the model to + * create a [FunctionCallPart], which instructs the client to execute the corresponding function. + * The client then sends the result back to the model as a [FunctionResponsePart]. + * + * For example + * + * ``` + * val getExchangeRate = FunctionDeclaration( + * name = "getExchangeRate", + * description = "Get the exchange rate for currencies between countries.", + * parameters = mapOf( + * "currencyFrom" to Schema.str("The currency to convert from."), + * "currencyTo" to Schema.str("The currency to convert to.") + * ) + * ) + * ``` + * + * See the + * [Use the Gemini API for function calling](https://firebase.google.com/docs/vertex-ai/function-calling?platform=android) + * guide for more information on function calling. + * + * @param name The name of the function. + * @param description The description of what the function does and its output. To improve the + * effectiveness of the model, the description should be clear and detailed. + * @param parameters The map of parameters names to their [Schema] the function accepts as + * arguments. + * @param optionalParameters The list of parameter names that the model can omit when invoking this + * function. + * @see Schema + */ +public class FunctionDeclaration( + internal val name: String, + internal val description: String, + internal val parameters: Map, + internal val optionalParameters: List = emptyList(), +) { + internal val schema: Schema = + Schema.obj(properties = parameters, optionalProperties = optionalParameters, nullable = false) + + internal fun toInternal() = Internal(name, description, schema.toInternal()) + + @Serializable + internal data class Internal( + val name: String, + val description: String, + val parameters: Schema.Internal + ) +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerateContentResponse.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerateContentResponse.kt new file mode 100644 index 00000000000..be2b50f3be4 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerateContentResponse.kt @@ -0,0 +1,73 @@ +/* + * 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. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.Serializable + +/** + * A response from the model. + * + * @property candidates The list of [Candidate] responses generated by the model. + * @property promptFeedback Feedback about the prompt send to the model to generate this response. + * When streaming, it's only populated in the first response. + * @property usageMetadata Information about the number of tokens in the prompt and in the response. + */ +public class GenerateContentResponse( + public val candidates: List, + public val promptFeedback: PromptFeedback?, + public val usageMetadata: UsageMetadata?, +) { + /** + * Convenience field representing all the text parts in the response as a single string, if they + * exists. + */ + public val text: String? by lazy { + candidates.first().content.parts.filterIsInstance().joinToString(" ") { it.text } + } + + /** Convenience field to list all the [FunctionCallPart]s in the response, if they exist. */ + public val functionCalls: List by lazy { + candidates.first().content.parts.filterIsInstance() + } + + /** + * Convenience field representing all the [InlineDataPart]s in the first candidate, if they exist. + * + * This also includes any [ImagePart], but they will be represented as [InlineDataPart] instead. + */ + public val inlineDataParts: List by lazy { + candidates.first().content.parts.let { parts -> + parts.filterIsInstance().map { it.toInlineDataPart() } + + parts.filterIsInstance() + } + } + + @Serializable + internal data class Internal( + val candidates: List? = null, + val promptFeedback: PromptFeedback.Internal? = null, + val usageMetadata: UsageMetadata.Internal? = null, + ) : Response { + internal fun toPublic(): GenerateContentResponse { + return GenerateContentResponse( + candidates?.map { it.toPublic() }.orEmpty(), + promptFeedback?.toPublic(), + usageMetadata?.toPublic() + ) + } + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerationConfig.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerationConfig.kt new file mode 100644 index 00000000000..1c2d2680bb1 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerationConfig.kt @@ -0,0 +1,247 @@ +/* + * 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. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Configuration parameters to use for content generation. + * + * @property temperature A parameter controlling the degree of randomness in token selection. A + * temperature of 0 means that the highest probability tokens are always selected. In this case, + * responses for a given prompt are mostly deterministic, but a small amount of variation is still + * possible. + * + * @property topK The `topK` parameter changes how the model selects tokens for output. A `topK` of + * 1 means the selected token is the most probable among all the tokens in the model's vocabulary, + * while a `topK` of 3 means that the next token is selected from among the 3 most probable using + * the `temperature`. For each token selection step, the `topK` tokens with the highest + * probabilities are sampled. Tokens are then further filtered based on `topP` with the final token + * selected using `temperature` sampling. Defaults to 40 if unspecified. + * + * @property topP The `topP` parameter changes how the model selects tokens for output. Tokens are + * selected from the most to least probable until the sum of their probabilities equals the `topP` + * value. For example, if tokens A, B, and C have probabilities of 0.3, 0.2, and 0.1 respectively + * and the topP value is 0.5, then the model will select either A or B as the next token by using + * the `temperature` and exclude C as a candidate. Defaults to 0.95 if unset. + * + * @property candidateCount The maximum number of generated response messages to return. This value + * must be between [1, 8], inclusive. If unset, this will default to 1. + * + * - Note: Only unique candidates are returned. Higher temperatures are more likely to produce + * unique candidates. Setting `temperature` to 0 will always produce exactly one candidate + * regardless of the `candidateCount`. + * + * @property presencePenalty Positive penalties. + * + * @property frequencyPenalty Frequency penalties. + * + * @property maxOutputTokens Specifies the maximum number of tokens that can be generated in the + * response. The number of tokens per word varies depending on the language outputted. Defaults to 0 + * (unbounded). + * + * @property stopSequences A set of up to 5 `String`s that will stop output generation. If + * specified, the API will stop at the first appearance of a stop sequence. The stop sequence will + * not be included as part of the response. + * + * @property responseMimeType Output response MIME type of the generated candidate text (IANA + * standard). + * + * Supported MIME types depend on the model used, but could include: + * - `text/plain`: Text output; the default behavior if unspecified. + * - `application/json`: JSON response in the candidates. + * + * @property responseSchema Output schema of the generated candidate text. If set, a compatible + * [responseMimeType] must also be set. + * + * Compatible MIME types: + * - `application/json`: Schema for JSON response. + * + * Refer to the + * [Control generated output](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/control-generated-output) + * guide for more details. + * + * @property responseModalities The format of data in which the model should respond with. + */ +public class GenerationConfig +private constructor( + internal val temperature: Float?, + internal val topK: Int?, + internal val topP: Float?, + internal val candidateCount: Int?, + internal val maxOutputTokens: Int?, + internal val presencePenalty: Float?, + internal val frequencyPenalty: Float?, + internal val stopSequences: List?, + internal val responseMimeType: String?, + internal val responseSchema: Schema?, + internal val responseModalities: List?, +) { + + /** + * Builder for creating a [GenerationConfig]. + * + * Mainly intended for Java interop. Kotlin consumers should use [generationConfig] for a more + * idiomatic experience. + * + * @property temperature See [GenerationConfig.temperature]. + * + * @property topK See [GenerationConfig.topK]. + * + * @property topP See [GenerationConfig.topP]. + * + * @property presencePenalty See [GenerationConfig.presencePenalty] + * + * @property frequencyPenalty See [GenerationConfig.frequencyPenalty] + * + * @property candidateCount See [GenerationConfig.candidateCount]. + * + * @property maxOutputTokens See [GenerationConfig.maxOutputTokens]. + * + * @property stopSequences See [GenerationConfig.stopSequences]. + * + * @property responseMimeType See [GenerationConfig.responseMimeType]. + * + * @property responseSchema See [GenerationConfig.responseSchema]. + * + * @property responseModalities See [GenerationConfig.responseModalities]. + * + * @see [generationConfig] + */ + public class Builder { + @JvmField public var temperature: Float? = null + @JvmField public var topK: Int? = null + @JvmField public var topP: Float? = null + @JvmField public var candidateCount: Int? = null + @JvmField public var maxOutputTokens: Int? = null + @JvmField public var presencePenalty: Float? = null + @JvmField public var frequencyPenalty: Float? = null + @JvmField public var stopSequences: List? = null + @JvmField public var responseMimeType: String? = null + @JvmField public var responseSchema: Schema? = null + @JvmField public var responseModalities: List? = null + + public fun setTemperature(temperature: Float?): Builder = apply { + this.temperature = temperature + } + public fun setTopK(topK: Int?): Builder = apply { this.topK = topK } + public fun setTopP(topP: Float?): Builder = apply { this.topP = topP } + public fun setCandidateCount(candidateCount: Int?): Builder = apply { + this.candidateCount = candidateCount + } + public fun setMaxOutputTokens(maxOutputTokens: Int?): Builder = apply { + this.maxOutputTokens = maxOutputTokens + } + public fun setPresencePenalty(presencePenalty: Float?): Builder = apply { + this.presencePenalty = presencePenalty + } + public fun setFrequencyPenalty(frequencyPenalty: Float?): Builder = apply { + this.frequencyPenalty = frequencyPenalty + } + public fun setStopSequences(stopSequences: List?): Builder = apply { + this.stopSequences = stopSequences + } + public fun setResponseMimeType(responseMimeType: String?): Builder = apply { + this.responseMimeType = responseMimeType + } + public fun setResponseSchema(responseSchema: Schema?): Builder = apply { + this.responseSchema = responseSchema + } + public fun setResponseModalities(responseModalities: List?): Builder = apply { + this.responseModalities = responseModalities + } + + /** Create a new [GenerationConfig] with the attached arguments. */ + public fun build(): GenerationConfig = + GenerationConfig( + temperature = temperature, + topK = topK, + topP = topP, + candidateCount = candidateCount, + maxOutputTokens = maxOutputTokens, + stopSequences = stopSequences, + presencePenalty = presencePenalty, + frequencyPenalty = frequencyPenalty, + responseMimeType = responseMimeType, + responseSchema = responseSchema, + responseModalities = responseModalities + ) + } + + internal fun toInternal() = + Internal( + temperature = temperature, + topP = topP, + topK = topK, + candidateCount = candidateCount, + maxOutputTokens = maxOutputTokens, + stopSequences = stopSequences, + frequencyPenalty = frequencyPenalty, + presencePenalty = presencePenalty, + responseMimeType = responseMimeType, + responseSchema = responseSchema?.toInternal(), + responseModalities = responseModalities?.map { it.toInternal() } + ) + + @Serializable + internal data class Internal( + val temperature: Float?, + @SerialName("top_p") val topP: Float?, + @SerialName("top_k") val topK: Int?, + @SerialName("candidate_count") val candidateCount: Int?, + @SerialName("max_output_tokens") val maxOutputTokens: Int?, + @SerialName("stop_sequences") val stopSequences: List?, + @SerialName("response_mime_type") val responseMimeType: String? = null, + @SerialName("presence_penalty") val presencePenalty: Float? = null, + @SerialName("frequency_penalty") val frequencyPenalty: Float? = null, + @SerialName("response_schema") val responseSchema: Schema.Internal? = null, + @SerialName("response_modalities") val responseModalities: List? = null + ) + + public companion object { + + /** + * Alternative casing for [GenerationConfig.Builder]: + * ``` + * val config = GenerationConfig.builder() + * ``` + */ + public fun builder(): Builder = Builder() + } +} + +/** + * Helper method to construct a [GenerationConfig] in a DSL-like manner. + * + * Example Usage: + * ``` + * generationConfig { + * temperature = 0.75f + * topP = 0.5f + * topK = 30 + * candidateCount = 4 + * maxOutputTokens = 300 + * stopSequences = listOf("in conclusion", "-----", "do you need") + * } + * ``` + */ +public fun generationConfig(init: GenerationConfig.Builder.() -> Unit): GenerationConfig { + val builder = GenerationConfig.builder() + builder.init() + return builder.build() +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerativeBackend.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerativeBackend.kt new file mode 100644 index 00000000000..9598266fa68 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerativeBackend.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +/** Represents a reference to a backend for generative AI. */ +public class GenerativeBackend +internal constructor(internal val location: String, internal val backend: GenerativeBackendEnum) { + public companion object { + + /** References the Google Developer API backend. */ + @JvmStatic + public fun googleAI(): GenerativeBackend = + GenerativeBackend("", GenerativeBackendEnum.GOOGLE_AI) + + /** + * References the VertexAI Enterprise backend. + * + * @param location passes a valid cloud server location, defaults to "us-central1" + */ + @JvmStatic + @JvmOverloads + public fun vertexAI(location: String = "us-central1"): GenerativeBackend { + if (location.isBlank() || location.contains("/")) { + throw InvalidLocationException(location) + } + return GenerativeBackend(location, GenerativeBackendEnum.VERTEX_AI) + } + } +} + +internal enum class GenerativeBackendEnum { + GOOGLE_AI, + VERTEX_AI, +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmBlockMethod.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmBlockMethod.kt new file mode 100644 index 00000000000..a65d153bc53 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmBlockMethod.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import com.google.firebase.ai.common.makeMissingCaseException +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Specifies how the block method computes the score that will be compared against the + * [HarmBlockThreshold] in [SafetySetting]. + */ +public class HarmBlockMethod private constructor(public val ordinal: Int) { + internal fun toInternal() = + when (this) { + SEVERITY -> Internal.SEVERITY + PROBABILITY -> Internal.PROBABILITY + else -> throw makeMissingCaseException("HarmBlockMethod", ordinal) + } + + @Serializable + internal enum class Internal { + @SerialName("HARM_BLOCK_METHOD_UNSPECIFIED") UNSPECIFIED, + SEVERITY, + PROBABILITY, + } + public companion object { + /** + * The harm block method uses both probability and severity scores. See [HarmSeverity] and + * [HarmProbability]. + */ + @JvmField public val SEVERITY: HarmBlockMethod = HarmBlockMethod(0) + + /** The harm block method uses the probability score. See [HarmProbability]. */ + @JvmField public val PROBABILITY: HarmBlockMethod = HarmBlockMethod(1) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmBlockThreshold.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmBlockThreshold.kt new file mode 100644 index 00000000000..93ebfde5da9 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmBlockThreshold.kt @@ -0,0 +1,67 @@ +/* + * 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. + */ + +package com.google.firebase.ai.type + +import com.google.firebase.ai.common.makeMissingCaseException +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Represents the threshold for a [HarmCategory] to be allowed by [SafetySetting]. */ +public class HarmBlockThreshold private constructor(public val ordinal: Int) { + + internal fun toInternal() = + when (this) { + OFF -> Internal.OFF + NONE -> Internal.BLOCK_NONE + ONLY_HIGH -> Internal.BLOCK_ONLY_HIGH + MEDIUM_AND_ABOVE -> Internal.BLOCK_MEDIUM_AND_ABOVE + LOW_AND_ABOVE -> Internal.BLOCK_LOW_AND_ABOVE + else -> throw makeMissingCaseException("HarmBlockThreshold", ordinal) + } + + @Serializable + internal enum class Internal { + @SerialName("HARM_BLOCK_THRESHOLD_UNSPECIFIED") UNSPECIFIED, + BLOCK_LOW_AND_ABOVE, + BLOCK_MEDIUM_AND_ABOVE, + BLOCK_ONLY_HIGH, + BLOCK_NONE, + OFF + } + + public companion object { + /** Content with negligible harm is allowed. */ + @JvmField public val LOW_AND_ABOVE: HarmBlockThreshold = HarmBlockThreshold(0) + + /** Content with negligible to low harm is allowed. */ + @JvmField public val MEDIUM_AND_ABOVE: HarmBlockThreshold = HarmBlockThreshold(1) + + /** Content with negligible to medium harm is allowed. */ + @JvmField public val ONLY_HIGH: HarmBlockThreshold = HarmBlockThreshold(2) + + /** All content is allowed regardless of harm. */ + @JvmField public val NONE: HarmBlockThreshold = HarmBlockThreshold(3) + + /** + * All content is allowed regardless of harm. + * + * The same as [NONE], but metadata when the corresponding [HarmCategory] occurs will **NOT** be + * present in the response. + */ + @JvmField public val OFF: HarmBlockThreshold = HarmBlockThreshold(4) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmCategory.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmCategory.kt new file mode 100644 index 00000000000..5144058ab44 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmCategory.kt @@ -0,0 +1,77 @@ +/* + * 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. + */ + +package com.google.firebase.ai.type + +import com.google.firebase.ai.common.makeMissingCaseException +import com.google.firebase.ai.common.util.FirstOrdinalSerializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Category for a given harm rating. */ +public class HarmCategory private constructor(public val ordinal: Int) { + internal fun toInternal() = + when (this) { + HARASSMENT -> Internal.HARASSMENT + HATE_SPEECH -> Internal.HATE_SPEECH + SEXUALLY_EXPLICIT -> Internal.SEXUALLY_EXPLICIT + DANGEROUS_CONTENT -> Internal.DANGEROUS_CONTENT + CIVIC_INTEGRITY -> Internal.CIVIC_INTEGRITY + UNKNOWN -> Internal.UNKNOWN + else -> throw makeMissingCaseException("HarmCategory", ordinal) + } + @Serializable(Internal.Serializer::class) + internal enum class Internal { + UNKNOWN, + @SerialName("HARM_CATEGORY_HARASSMENT") HARASSMENT, + @SerialName("HARM_CATEGORY_HATE_SPEECH") HATE_SPEECH, + @SerialName("HARM_CATEGORY_SEXUALLY_EXPLICIT") SEXUALLY_EXPLICIT, + @SerialName("HARM_CATEGORY_DANGEROUS_CONTENT") DANGEROUS_CONTENT, + @SerialName("HARM_CATEGORY_CIVIC_INTEGRITY") CIVIC_INTEGRITY; + + internal object Serializer : KSerializer by FirstOrdinalSerializer(Internal::class) + + internal fun toPublic() = + when (this) { + HARASSMENT -> HarmCategory.HARASSMENT + HATE_SPEECH -> HarmCategory.HATE_SPEECH + SEXUALLY_EXPLICIT -> HarmCategory.SEXUALLY_EXPLICIT + DANGEROUS_CONTENT -> HarmCategory.DANGEROUS_CONTENT + CIVIC_INTEGRITY -> HarmCategory.CIVIC_INTEGRITY + else -> HarmCategory.UNKNOWN + } + } + public companion object { + /** A new and not yet supported value. */ + @JvmField public val UNKNOWN: HarmCategory = HarmCategory(0) + + /** Harassment content. */ + @JvmField public val HARASSMENT: HarmCategory = HarmCategory(1) + + /** Hate speech and content. */ + @JvmField public val HATE_SPEECH: HarmCategory = HarmCategory(2) + + /** Sexually explicit content. */ + @JvmField public val SEXUALLY_EXPLICIT: HarmCategory = HarmCategory(3) + + /** Dangerous content. */ + @JvmField public val DANGEROUS_CONTENT: HarmCategory = HarmCategory(4) + + /** Content that may be used to harm civic integrity. */ + @JvmField public val CIVIC_INTEGRITY: HarmCategory = HarmCategory(5) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmProbability.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmProbability.kt new file mode 100644 index 00000000000..e586b94d5b5 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmProbability.kt @@ -0,0 +1,62 @@ +/* + * 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. + */ + +package com.google.firebase.ai.type + +import com.google.firebase.ai.common.util.FirstOrdinalSerializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Represents the probability that some [HarmCategory] is applicable in a [SafetyRating]. */ +public class HarmProbability private constructor(public val ordinal: Int) { + @Serializable(Internal.Serializer::class) + internal enum class Internal { + UNKNOWN, + @SerialName("HARM_PROBABILITY_UNSPECIFIED") UNSPECIFIED, + NEGLIGIBLE, + LOW, + MEDIUM, + HIGH; + + internal object Serializer : KSerializer by FirstOrdinalSerializer(Internal::class) + + internal fun toPublic() = + when (this) { + HIGH -> HarmProbability.HIGH + MEDIUM -> HarmProbability.MEDIUM + LOW -> HarmProbability.LOW + NEGLIGIBLE -> HarmProbability.NEGLIGIBLE + else -> HarmProbability.UNKNOWN + } + } + public companion object { + /** A new and not yet supported value. */ + @JvmField public val UNKNOWN: HarmProbability = HarmProbability(0) + + /** Probability for harm is negligible. */ + @JvmField public val NEGLIGIBLE: HarmProbability = HarmProbability(1) + + /** Probability for harm is low. */ + @JvmField public val LOW: HarmProbability = HarmProbability(2) + + /** Probability for harm is medium. */ + @JvmField public val MEDIUM: HarmProbability = HarmProbability(3) + + /** Probability for harm is high. */ + @JvmField public val HIGH: HarmProbability = HarmProbability(4) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmSeverity.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmSeverity.kt new file mode 100644 index 00000000000..36fa5d1b8c1 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/HarmSeverity.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import com.google.firebase.ai.common.util.FirstOrdinalSerializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Represents the severity of a [HarmCategory] being applicable in a [SafetyRating]. */ +public class HarmSeverity private constructor(public val ordinal: Int) { + @Serializable(Internal.Serializer::class) + internal enum class Internal { + UNKNOWN, + @SerialName("HARM_SEVERITY_UNSPECIFIED") UNSPECIFIED, + @SerialName("HARM_SEVERITY_NEGLIGIBLE") NEGLIGIBLE, + @SerialName("HARM_SEVERITY_LOW") LOW, + @SerialName("HARM_SEVERITY_MEDIUM") MEDIUM, + @SerialName("HARM_SEVERITY_HIGH") HIGH; + + internal object Serializer : KSerializer by FirstOrdinalSerializer(Internal::class) + + internal fun toPublic() = + when (this) { + HIGH -> HarmSeverity.HIGH + MEDIUM -> HarmSeverity.MEDIUM + LOW -> HarmSeverity.LOW + NEGLIGIBLE -> HarmSeverity.NEGLIGIBLE + else -> HarmSeverity.UNKNOWN + } + } + public companion object { + /** A new and not yet supported value. */ + @JvmField public val UNKNOWN: HarmSeverity = HarmSeverity(0) + + /** Severity for harm is negligible. */ + @JvmField public val NEGLIGIBLE: HarmSeverity = HarmSeverity(1) + + /** Low level of harm severity. */ + @JvmField public val LOW: HarmSeverity = HarmSeverity(2) + + /** Medium level of harm severity. */ + @JvmField public val MEDIUM: HarmSeverity = HarmSeverity(3) + + /** High level of harm severity. */ + @JvmField public val HIGH: HarmSeverity = HarmSeverity(4) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenAspectRatio.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenAspectRatio.kt new file mode 100644 index 00000000000..10a8b4fed84 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenAspectRatio.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +/** Represents the aspect ratio that the generated image should conform to. */ +@PublicPreviewAPI +public class ImagenAspectRatio private constructor(internal val internalVal: String) { + public companion object { + /** A square image, useful for icons, profile pictures, etc. */ + @JvmField public val SQUARE_1x1: ImagenAspectRatio = ImagenAspectRatio("1:1") + /** A portrait image in 3:4, the aspect ratio of older TVs. */ + @JvmField public val PORTRAIT_3x4: ImagenAspectRatio = ImagenAspectRatio("3:4") + /** A landscape image in 4:3, the aspect ratio of older TVs. */ + @JvmField public val LANDSCAPE_4x3: ImagenAspectRatio = ImagenAspectRatio("4:3") + /** A portrait image in 9:16, the aspect ratio of modern monitors and phone screens. */ + @JvmField public val PORTRAIT_9x16: ImagenAspectRatio = ImagenAspectRatio("9:16") + /** A landscape image in 16:9, the aspect ratio of modern monitors and phone screens. */ + @JvmField public val LANDSCAPE_16x9: ImagenAspectRatio = ImagenAspectRatio("16:9") + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenGCSImage.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenGCSImage.kt new file mode 100644 index 00000000000..6e2466da496 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenGCSImage.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +/** + * Represents an Imagen-generated image that is contained in Google Cloud Storage. + * + * @param gcsUri Contains the `gs://` URI for the image. + * @param mimeType Contains the MIME type of the image (for example, `"image/png"`). + */ +@PublicPreviewAPI +internal class ImagenGCSImage +internal constructor(public val gcsUri: String, public val mimeType: String) {} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenGenerationConfig.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenGenerationConfig.kt new file mode 100644 index 00000000000..b59ed4d0e44 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenGenerationConfig.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +/** + * Contains extra settings to configure image generation. + * + * @param negativePrompt This string contains things that should be explicitly excluded from + * generated images. + * @param numberOfImages How many images should be generated. + * @param aspectRatio The aspect ratio of the generated images. + * @param imageFormat The file format/compression of the generated images. + * @param addWatermark Adds an invisible watermark to mark the image as AI generated. + */ +import kotlin.jvm.JvmField + +@PublicPreviewAPI +public class ImagenGenerationConfig( + public val negativePrompt: String? = null, + public val numberOfImages: Int? = 1, + public val aspectRatio: ImagenAspectRatio? = null, + public val imageFormat: ImagenImageFormat? = null, + public val addWatermark: Boolean? = null, +) { + /** + * Builder for creating a [ImagenGenerationConfig]. + * + * This is mainly intended for Java interop. For Kotlin, use [imagenGenerationConfig] for a more + * idiomatic experience. + */ + public class Builder { + @JvmField public var negativePrompt: String? = null + @JvmField public var numberOfImages: Int? = 1 + @JvmField public var aspectRatio: ImagenAspectRatio? = null + @JvmField public var imageFormat: ImagenImageFormat? = null + @JvmField public var addWatermark: Boolean? = null + + /** See [ImagenGenerationConfig.negativePrompt]. */ + public fun setNegativePrompt(negativePrompt: String): Builder = apply { + this.negativePrompt = negativePrompt + } + + /** See [ImagenGenerationConfig.numberOfImages]. */ + public fun setNumberOfImages(numberOfImages: Int): Builder = apply { + this.numberOfImages = numberOfImages + } + + /** See [ImagenGenerationConfig.aspectRatio]. */ + public fun setAspectRatio(aspectRatio: ImagenAspectRatio): Builder = apply { + this.aspectRatio = aspectRatio + } + + /** See [ImagenGenerationConfig.imageFormat]. */ + public fun setImageFormat(imageFormat: ImagenImageFormat): Builder = apply { + this.imageFormat = imageFormat + } + + /** See [ImagenGenerationConfig.addWatermark]. */ + public fun setAddWatermark(addWatermark: Boolean): Builder = apply { + this.addWatermark = addWatermark + } + + /** + * Alternative casing for [ImagenGenerationConfig.Builder]: + * ``` + * val config = GenerationConfig.builder() + * ``` + */ + public fun build(): ImagenGenerationConfig = + ImagenGenerationConfig( + negativePrompt = negativePrompt, + numberOfImages = numberOfImages, + aspectRatio = aspectRatio, + imageFormat = imageFormat, + addWatermark = addWatermark, + ) + } + + public companion object { + public fun builder(): Builder = Builder() + } +} + +/** + * Helper method to construct a [ImagenGenerationConfig] in a DSL-like manner. + * + * Example Usage: + * ``` + * imagenGenerationConfig { + * negativePrompt = "People, black and white, painting" + * numberOfImages = 1 + * aspectRatio = ImagenAspecRatio.SQUARE_1x1 + * imageFormat = ImagenImageFormat.png() + * addWatermark = false + * } + * ``` + */ +@PublicPreviewAPI +public fun imagenGenerationConfig( + init: ImagenGenerationConfig.Builder.() -> Unit +): ImagenGenerationConfig { + val builder = ImagenGenerationConfig.builder() + builder.init() + return builder.build() +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenGenerationResponse.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenGenerationResponse.kt new file mode 100644 index 00000000000..67f13cff199 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenGenerationResponse.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import android.util.Base64 +import com.google.firebase.ai.ImagenModel +import kotlinx.serialization.Serializable + +/** + * Represents a response from a call to [ImagenModel.generateImages] + * + * @param images contains the generated images + * @param filteredReason if fewer images were generated than were requested, this field will contain + * the reason they were filtered out. + */ +@PublicPreviewAPI +public class ImagenGenerationResponse +internal constructor(public val images: List, public val filteredReason: String?) { + + @Serializable + internal data class Internal(val predictions: List) { + internal fun toPublicGCS() = + ImagenGenerationResponse( + images = predictions.filter { it.mimeType != null }.map { it.toPublicGCS() }, + null, + ) + + internal fun toPublicInline() = + ImagenGenerationResponse( + images = predictions.filter { it.mimeType != null }.map { it.toPublicInline() }, + null, + ) + } + + @Serializable + internal data class ImagenImageResponse( + val bytesBase64Encoded: String? = null, + val gcsUri: String? = null, + val mimeType: String? = null, + val raiFilteredReason: String? = null, + ) { + internal fun toPublicInline() = + ImagenInlineImage(Base64.decode(bytesBase64Encoded!!, Base64.NO_WRAP), mimeType!!) + + internal fun toPublicGCS() = ImagenGCSImage(gcsUri!!, mimeType!!) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenImageFormat.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenImageFormat.kt new file mode 100644 index 00000000000..014763fd54c --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenImageFormat.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.Serializable + +/** + * Represents the format an image should be returned in. + * + * @param mimeType A string (like `"image/jpeg"`) specifying the encoding MIME type of the image. + * @param compressionQuality an int (1-100) representing the quality of the image; a lower number + * means the image is permitted to be lower quality to reduce size. This parameter is not relevant + * for every MIME type. + */ +@PublicPreviewAPI +public class ImagenImageFormat +private constructor(public val mimeType: String, public val compressionQuality: Int?) { + + internal fun toInternal() = Internal(mimeType, compressionQuality) + + @Serializable internal data class Internal(val mimeType: String, val compressionQuality: Int?) + + public companion object { + /** + * An [ImagenImageFormat] representing a JPEG image. + * + * @param compressionQuality an int (1-100) representing the quality of the image; a lower + * number means the image is permitted to be lower quality to reduce size. + */ + @JvmStatic + public fun jpeg(compressionQuality: Int? = null): ImagenImageFormat { + return ImagenImageFormat("image/jpeg", compressionQuality) + } + + /** An [ImagenImageFormat] representing a PNG image */ + @JvmStatic + public fun png(): ImagenImageFormat { + return ImagenImageFormat("image/png", null) + } + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenInlineImage.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenInlineImage.kt new file mode 100644 index 00000000000..5fa1d0e183b --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenInlineImage.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import android.graphics.Bitmap +import android.graphics.BitmapFactory + +/** + * Represents an Imagen-generated image that is returned as inline data. + * + * @property data The raw image bytes in JPEG or PNG format, as specified by [mimeType]. + * @property mimeType The IANA standard MIME type of the image data; either `"image/png"` or + * `"image/jpeg"`; to request a different format, see [ImagenGenerationConfig.imageFormat]. + */ +@PublicPreviewAPI +public class ImagenInlineImage +internal constructor(public val data: ByteArray, public val mimeType: String) { + + /** + * Returns the image as an Android OS native [Bitmap] so that it can be saved or sent to the UI. + */ + public fun asBitmap(): Bitmap { + return BitmapFactory.decodeByteArray(data, 0, data.size) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenPersonFilterLevel.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenPersonFilterLevel.kt new file mode 100644 index 00000000000..5daa354bbbd --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenPersonFilterLevel.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +/** A filter used to prevent images from containing depictions of children or people. */ +@PublicPreviewAPI +public class ImagenPersonFilterLevel private constructor(internal val internalVal: String) { + public companion object { + /** + * Allow generation of images containing people of all ages. + * + * > Important: Generation of images containing people or faces may require your use case to be + * reviewed and approved by Cloud support; see the + * [Responsible AI and usage + * guidelines](https://cloud.google.com/vertex-ai/generative-ai/docs/image/responsible-ai-imagen#person-face-gen) + * for more details. + */ + @JvmField public val ALLOW_ALL: ImagenPersonFilterLevel = ImagenPersonFilterLevel("allow_all") + /** + * Allow generation of images containing adults only; images of children are filtered out. + * + * > Important: Generation of images containing people or faces may require your use case to be + * reviewed and approved by Cloud support; see the + * [Responsible AI and usage + * guidelines](https://cloud.google.com/vertex-ai/generative-ai/docs/image/responsible-ai-imagen#person-face-gen) + * for more details. + */ + @JvmField + public val ALLOW_ADULT: ImagenPersonFilterLevel = ImagenPersonFilterLevel("allow_adult") + /** + * Disallow generation of images containing people or faces; images of people are filtered out. + */ + @JvmField public val BLOCK_ALL: ImagenPersonFilterLevel = ImagenPersonFilterLevel("dont_allow") + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenSafetyFilterLevel.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenSafetyFilterLevel.kt new file mode 100644 index 00000000000..90872d5a15d --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenSafetyFilterLevel.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +/** Used for safety filtering. */ +@PublicPreviewAPI +public class ImagenSafetyFilterLevel private constructor(internal val internalVal: String) { + public companion object { + /** Strongest filtering level, most strict blocking. */ + @JvmField + public val BLOCK_LOW_AND_ABOVE: ImagenSafetyFilterLevel = + ImagenSafetyFilterLevel("block_low_and_above") + /** Block some problematic prompts and responses. */ + @JvmField + public val BLOCK_MEDIUM_AND_ABOVE: ImagenSafetyFilterLevel = + ImagenSafetyFilterLevel("block_medium_and_above") + /** + * Reduces the number of requests blocked due to safety filters. May increase objectionable + * content generated by the Imagen model. + */ + @JvmField + public val BLOCK_ONLY_HIGH: ImagenSafetyFilterLevel = ImagenSafetyFilterLevel("block_only_high") + /** Turns off all optional safety filters. */ + @JvmField public val BLOCK_NONE: ImagenSafetyFilterLevel = ImagenSafetyFilterLevel("block_none") + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenSafetySettings.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenSafetySettings.kt new file mode 100644 index 00000000000..7496fc6fcff --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ImagenSafetySettings.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +/** + * A configuration for filtering unsafe content or images containing people. + * + * @param safetyFilterLevel Used to filter unsafe content. + * @param personFilterLevel Used to filter images containing people. + */ +@PublicPreviewAPI +public class ImagenSafetySettings( + internal val safetyFilterLevel: ImagenSafetyFilterLevel, + internal val personFilterLevel: ImagenPersonFilterLevel, +) {} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveClientSetupMessage.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveClientSetupMessage.kt new file mode 100644 index 00000000000..36e06b184e8 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveClientSetupMessage.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable + +/** + * First message in a live session. + * + * Contains configuration that will be used for the duration of the session. + */ +@OptIn(ExperimentalSerializationApi::class) +@PublicPreviewAPI +internal class LiveClientSetupMessage( + val model: String, + // Some config options are supported in generateContent but not in bidi and vise versa; so bidi + // needs its own config class + val generationConfig: LiveGenerationConfig.Internal?, + val tools: List?, + val systemInstruction: Content.Internal? +) { + @Serializable + internal class Internal(val setup: LiveClientSetup) { + @Serializable + internal data class LiveClientSetup( + val model: String, + val generationConfig: LiveGenerationConfig.Internal?, + val tools: List?, + val systemInstruction: Content.Internal? + ) + } + + fun toInternal() = + Internal(Internal.LiveClientSetup(model, generationConfig, tools, systemInstruction)) +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveGenerationConfig.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveGenerationConfig.kt new file mode 100644 index 00000000000..3ded9338f9b --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveGenerationConfig.kt @@ -0,0 +1,217 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Configuration parameters to use for live content generation. + * + * @property temperature A parameter controlling the degree of randomness in token selection. A + * temperature of 0 means that the highest probability tokens are always selected. In this case, + * responses for a given prompt are mostly deterministic, but a small amount of variation is still + * possible. + * + * @property topK The `topK` parameter changes how the model selects tokens for output. A `topK` of + * 1 means the selected token is the most probable among all the tokens in the model's vocabulary, + * while a `topK` of 3 means that the next token is selected from among the 3 most probable using + * the `temperature`. For each token selection step, the `topK` tokens with the highest + * probabilities are sampled. Tokens are then further filtered based on `topP` with the final token + * selected using `temperature` sampling. Defaults to 40 if unspecified. + * + * @property topP The `topP` parameter changes how the model selects tokens for output. Tokens are + * selected from the most to least probable until the sum of their probabilities equals the `topP` + * value. For example, if tokens A, B, and C have probabilities of 0.3, 0.2, and 0.1 respectively + * and the topP value is 0.5, then the model will select either A or B as the next token by using + * the `temperature` and exclude C as a candidate. Defaults to 0.95 if unset. + * + * @property candidateCount The maximum number of generated response messages to return. This value + * must be between [1, 8], inclusive. If unset, this will default to 1. + * + * - Note: Only unique candidates are returned. Higher temperatures are more likely to produce + * unique candidates. Setting `temperature` to 0 will always produce exactly one candidate + * regardless of the `candidateCount`. + * + * @property presencePenalty Positive penalties. + * + * @property frequencyPenalty Frequency penalties. + * + * @property maxOutputTokens Specifies the maximum number of tokens that can be generated in the + * response. The number of tokens per word varies depending on the language outputted. Defaults to 0 + * (unbounded). + * + * @property responseModality Specifies the format of the data in which the server responds to + * requests + * + * @property speechConfig Specifies the voice configuration of the audio response from the server. + * + * Refer to the + * [Control generated output](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/control-generated-output) + * guide for more details. + */ +@PublicPreviewAPI +public class LiveGenerationConfig +private constructor( + internal val temperature: Float?, + internal val topK: Int?, + internal val topP: Float?, + internal val candidateCount: Int?, + internal val maxOutputTokens: Int?, + internal val presencePenalty: Float?, + internal val frequencyPenalty: Float?, + internal val responseModality: ResponseModality?, + internal val speechConfig: SpeechConfig? +) { + + /** + * Builder for creating a [LiveGenerationConfig]. + * + * Mainly intended for Java interop. Kotlin consumers should use [liveGenerationConfig] for a more + * idiomatic experience. + * + * @property temperature See [LiveGenerationConfig.temperature]. + * + * @property topK See [LiveGenerationConfig.topK]. + * + * @property topP See [LiveGenerationConfig.topP]. + * + * @property presencePenalty See [LiveGenerationConfig.presencePenalty] + * + * @property frequencyPenalty See [LiveGenerationConfig.frequencyPenalty] + * + * @property candidateCount See [LiveGenerationConfig.candidateCount]. + * + * @property maxOutputTokens See [LiveGenerationConfig.maxOutputTokens]. + * + * @property responseModality See [LiveGenerationConfig.responseModality] + * + * @property speechConfig See [LiveGenerationConfig.speechConfig] + */ + public class Builder { + @JvmField public var temperature: Float? = null + @JvmField public var topK: Int? = null + @JvmField public var topP: Float? = null + @JvmField public var candidateCount: Int? = null + @JvmField public var maxOutputTokens: Int? = null + @JvmField public var presencePenalty: Float? = null + @JvmField public var frequencyPenalty: Float? = null + @JvmField public var responseModality: ResponseModality? = null + @JvmField public var speechConfig: SpeechConfig? = null + + public fun setTemperature(temperature: Float?): Builder = apply { + this.temperature = temperature + } + public fun setTopK(topK: Int?): Builder = apply { this.topK = topK } + public fun setTopP(topP: Float?): Builder = apply { this.topP = topP } + public fun setCandidateCount(candidateCount: Int?): Builder = apply { + this.candidateCount = candidateCount + } + public fun setMaxOutputTokens(maxOutputTokens: Int?): Builder = apply { + this.maxOutputTokens = maxOutputTokens + } + public fun setPresencePenalty(presencePenalty: Float?): Builder = apply { + this.presencePenalty = presencePenalty + } + public fun setFrequencyPenalty(frequencyPenalty: Float?): Builder = apply { + this.frequencyPenalty = frequencyPenalty + } + public fun setResponseModality(responseModality: ResponseModality?): Builder = apply { + this.responseModality = responseModality + } + public fun setSpeechConfig(speechConfig: SpeechConfig?): Builder = apply { + this.speechConfig = speechConfig + } + + /** Create a new [LiveGenerationConfig] with the attached arguments. */ + public fun build(): LiveGenerationConfig = + LiveGenerationConfig( + temperature = temperature, + topK = topK, + topP = topP, + candidateCount = candidateCount, + maxOutputTokens = maxOutputTokens, + presencePenalty = presencePenalty, + frequencyPenalty = frequencyPenalty, + speechConfig = speechConfig, + responseModality = responseModality + ) + } + + internal fun toInternal(): Internal { + return Internal( + temperature = temperature, + topP = topP, + topK = topK, + candidateCount = candidateCount, + maxOutputTokens = maxOutputTokens, + frequencyPenalty = frequencyPenalty, + presencePenalty = presencePenalty, + speechConfig = speechConfig?.toInternal(), + responseModalities = + if (responseModality != null) listOf(responseModality.toInternal()) else null + ) + } + + @Serializable + internal data class Internal( + val temperature: Float?, + @SerialName("top_p") val topP: Float?, + @SerialName("top_k") val topK: Int?, + @SerialName("candidate_count") val candidateCount: Int?, + @SerialName("max_output_tokens") val maxOutputTokens: Int?, + @SerialName("presence_penalty") val presencePenalty: Float? = null, + @SerialName("frequency_penalty") val frequencyPenalty: Float? = null, + @SerialName("speech_config") val speechConfig: SpeechConfig.Internal? = null, + @SerialName("response_modalities") val responseModalities: List? = null + ) + + public companion object { + + /** + * Alternative casing for [LiveGenerationConfig.Builder]: + * ``` + * val config = LiveGenerationConfig.builder() + * ``` + */ + public fun builder(): Builder = Builder() + } +} + +/** + * Helper method to construct a [LiveGenerationConfig] in a DSL-like manner. + * + * Example Usage: + * ``` + * liveGenerationConfig { + * temperature = 0.75f + * topP = 0.5f + * topK = 30 + * candidateCount = 4 + * maxOutputTokens = 300 + * ... + * } + * ``` + */ +@OptIn(PublicPreviewAPI::class) +public fun liveGenerationConfig( + init: LiveGenerationConfig.Builder.() -> Unit +): LiveGenerationConfig { + val builder = LiveGenerationConfig.builder() + builder.init() + return builder.build() +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveServerMessage.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveServerMessage.kt new file mode 100644 index 00000000000..5ab520af474 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveServerMessage.kt @@ -0,0 +1,190 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject + +/** + * Parent interface for responses from the model during live interactions. + * + * @see LiveServerContent + * @see LiveServerToolCall + * @see LiveServerToolCallCancellation + * @see LiveServerSetupComplete + */ +@PublicPreviewAPI public interface LiveServerMessage + +/** + * Incremental server update generated by the model in response to client messages. + * + * Content is generated as quickly as possible, and not in realtime. You may choose to buffer and + * play it out in realtime. + */ +@PublicPreviewAPI +public class LiveServerContent( + /** + * The content that the model has generated as part of the current conversation with the user. + * + * This can be `null` if there is no content. + */ + public val content: Content?, + + /** + * The model was interrupted by the client while generating data. + * + * An interruption occurs when the client sends a message while the model is actively sending + * data. + */ + public val interrupted: Boolean, + + /** + * The model has finished sending data in the current turn. + * + * Generation will only start in response to additional client messages. + * + * Can be set alongside [content], indicating that the [content] is the last in the turn. + * + * @see generationComplete + */ + public val turnComplete: Boolean, + + /** + * The model has finished _generating_ data for the current turn. + * + * For realtime playback, there will be a delay between when the model finishes generating content + * and the client has finished playing back the generated content. [generationComplete] indicates + * that the model is done generating data, while [turnComplete] indicates the model is waiting for + * additional client messages. Sending a message during this delay may cause an [interrupted] + * message to be sent. + * + * Note that if the model was [interrupted], this will not be set. The model will go from + * [interrupted] -> [turnComplete]. + */ + public val generationComplete: Boolean, +) : LiveServerMessage { + @OptIn(ExperimentalSerializationApi::class) + @Serializable + internal data class Internal( + val modelTurn: Content.Internal? = null, + val interrupted: Boolean = false, + val turnComplete: Boolean = false, + val generationComplete: Boolean = false + ) + @Serializable + internal data class InternalWrapper(val serverContent: Internal) : InternalLiveServerMessage { + @OptIn(ExperimentalSerializationApi::class) + override fun toPublic() = + LiveServerContent( + serverContent.modelTurn?.toPublic(), + serverContent.interrupted, + serverContent.turnComplete, + serverContent.generationComplete + ) + } +} + +/** The model is ready to receive client messages. */ +@PublicPreviewAPI +public class LiveServerSetupComplete : LiveServerMessage { + @Serializable + internal data class Internal(val setupComplete: JsonObject) : InternalLiveServerMessage { + override fun toPublic() = LiveServerSetupComplete() + } +} + +/** + * Request for the client to execute the provided [functionCalls]. + * + * The client should return matching [FunctionResponsePart], where the `id` fields correspond to + * individual [FunctionCallPart]s. + * + * @property functionCalls A list of [FunctionCallPart] to run and return responses for. + */ +@PublicPreviewAPI +public class LiveServerToolCall(public val functionCalls: List) : + LiveServerMessage { + @Serializable + internal data class Internal( + val functionCalls: List = emptyList() + ) + @Serializable + internal data class InternalWrapper(val toolCall: Internal) : InternalLiveServerMessage { + override fun toPublic() = + LiveServerToolCall( + toolCall.functionCalls.map { functionCall -> + FunctionCallPart( + name = functionCall.name, + args = functionCall.args.orEmpty().mapValues { it.value ?: JsonNull } + ) + } + ) + } +} + +/** + * Notification for the client to cancel a previous function call from [LiveServerToolCall]. + * + * You do not need to send [FunctionResponsePart]s for the cancelled [FunctionCallPart]s. + * + * @property functionIds A list of `id`s matching the `id` provided in a previous + * [LiveServerToolCall], where only the provided `id`s should be cancelled. + */ +@PublicPreviewAPI +public class LiveServerToolCallCancellation(public val functionIds: List) : + LiveServerMessage { + @Serializable internal data class Internal(val functionIds: List = emptyList()) + @Serializable + internal data class InternalWrapper(val toolCallCancellation: Internal) : + InternalLiveServerMessage { + override fun toPublic() = LiveServerToolCallCancellation(toolCallCancellation.functionIds) + } +} + +@PublicPreviewAPI +@Serializable(LiveServerMessageSerializer::class) +internal sealed interface InternalLiveServerMessage { + fun toPublic(): LiveServerMessage +} + +@OptIn(PublicPreviewAPI::class) +internal object LiveServerMessageSerializer : + JsonContentPolymorphicSerializer(InternalLiveServerMessage::class) { + @OptIn(PublicPreviewAPI::class) + override fun selectDeserializer( + element: JsonElement + ): DeserializationStrategy { + val jsonObject = element.jsonObject + return when { + "serverContent" in jsonObject -> LiveServerContent.InternalWrapper.serializer() + "setupComplete" in jsonObject -> LiveServerSetupComplete.Internal.serializer() + "toolCall" in jsonObject -> LiveServerToolCall.InternalWrapper.serializer() + "toolCallCancellation" in jsonObject -> + LiveServerToolCallCancellation.InternalWrapper.serializer() + else -> + throw SerializationException( + "The given subclass of LiveServerMessage (${javaClass.simpleName}) is not supported in the serialization yet." + ) + } + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveSession.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveSession.kt new file mode 100644 index 00000000000..1f84c18a53b --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveSession.kt @@ -0,0 +1,446 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import android.Manifest.permission.RECORD_AUDIO +import android.media.AudioFormat +import android.media.AudioTrack +import android.util.Log +import androidx.annotation.RequiresPermission +import com.google.firebase.ai.common.JSON +import com.google.firebase.ai.common.util.CancelledCoroutineScope +import com.google.firebase.ai.common.util.accumulateUntil +import com.google.firebase.ai.common.util.childJob +import com.google.firebase.annotations.concurrent.Blocking +import io.ktor.client.plugins.websocket.ClientWebSocketSession +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readBytes +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** Represents a live WebSocket session capable of streaming content to and from the server. */ +@PublicPreviewAPI +@OptIn(ExperimentalSerializationApi::class) +public class LiveSession +internal constructor( + private val session: ClientWebSocketSession, + @Blocking private val blockingDispatcher: CoroutineContext, + private var audioHelper: AudioHelper? = null +) { + /** + * Coroutine scope that we batch data on for [startAudioConversation]. + * + * Makes it easy to stop all the work with [stopAudioConversation] by just cancelling the scope. + */ + private var scope = CancelledCoroutineScope + + /** + * Playback audio data sent from the model. + * + * Effectively, this is what the model is saying. + */ + private val playBackQueue = ConcurrentLinkedQueue() + + /** + * Toggled whenever [receive] and [stopReceiving] are called. + * + * Used to ensure only one flow is consuming the playback at once. + */ + private val startedReceiving = AtomicBoolean(false) + + /** + * Starts an audio conversation with the model, which can only be stopped using + * [stopAudioConversation] or [close]. + * + * @param functionCallHandler A callback function that is invoked whenever the model receives a + * function call. The [FunctionResponsePart] that the callback function returns will be + * automatically sent to the model. + */ + @RequiresPermission(RECORD_AUDIO) + public suspend fun startAudioConversation( + functionCallHandler: ((FunctionCallPart) -> FunctionResponsePart)? = null + ) { + FirebaseAIException.catchAsync { + if (scope.isActive) { + Log.w( + TAG, + "startAudioConversation called after the recording has already started. " + + "Call stopAudioConversation to close the previous connection." + ) + return@catchAsync + } + + scope = CoroutineScope(blockingDispatcher + childJob()) + audioHelper = AudioHelper.build() + + recordUserAudio() + processModelResponses(functionCallHandler) + listenForModelPlayback() + } + } + + /** + * Stops the audio conversation with the model. + * + * This only needs to be called after a previous call to [startAudioConversation]. + * + * If there is no audio conversation currently active, this function does nothing. + */ + public fun stopAudioConversation() { + FirebaseAIException.catch { + if (!startedReceiving.getAndSet(false)) return@catch + + scope.cancel() + playBackQueue.clear() + + audioHelper?.release() + audioHelper = null + } + } + + /** + * Receives responses from the model for both streaming and standard requests. + * + * Call [close] to stop receiving responses from the model. + * + * @return A [Flow] which will emit [LiveServerMessage] from the model. + * + * @throws [SessionAlreadyReceivingException] when the session is already receiving. + * @see stopReceiving + */ + public fun receive(): Flow { + return FirebaseAIException.catch { + if (startedReceiving.getAndSet(true)) { + throw SessionAlreadyReceivingException() + } + + // TODO(b/410059569): Remove when fixed + flow { + while (true) { + val response = session.incoming.tryReceive() + if (response.isClosed || !startedReceiving.get()) break + + response + .getOrNull() + ?.let { + JSON.decodeFromString( + it.readBytes().toString(Charsets.UTF_8) + ) + } + ?.let { emit(it.toPublic()) } + + yield() + } + } + .onCompletion { stopAudioConversation() } + .catch { throw FirebaseAIException.from(it) } + + // TODO(b/410059569): Add back when fixed + } + } + + /** + * Stops receiving from the model. + * + * If this function is called during an ongoing audio conversation, the model's response will not + * be received, and no audio will be played; the live session object will no longer receive data + * from the server. + * + * To resume receiving data, you must either handle it directly using [receive], or indirectly by + * using [startAudioConversation]. + * + * @see close + */ + // TODO(b/410059569): Remove when fixed + public fun stopReceiving() { + FirebaseAIException.catch { + if (!startedReceiving.getAndSet(false)) return@catch + + scope.cancel() + playBackQueue.clear() + + audioHelper?.release() + audioHelper = null + } + } + + /** + * Sends function calling responses to the model. + * + * **NOTE:** If you're using [startAudioConversation], the method will handle sending function + * responses to the model for you. You do _not_ need to call this method in that case. + * + * @param functionList The list of [FunctionResponsePart] instances indicating the function + * response from the client. + */ + public suspend fun sendFunctionResponse(functionList: List) { + FirebaseAIException.catchAsync { + val jsonString = + Json.encodeToString( + BidiGenerateContentToolResponseSetup(functionList.map { it.toInternalFunctionCall() }) + .toInternal() + ) + session.send(Frame.Text(jsonString)) + } + } + + /** + * Streams client data to the model. + * + * Calling this after [startAudioConversation] will play the response audio immediately. + * + * @param mediaChunks The list of [MediaData] instances representing the media data to be sent. + */ + public suspend fun sendMediaStream( + mediaChunks: List, + ) { + FirebaseAIException.catchAsync { + val jsonString = + Json.encodeToString( + BidiGenerateContentRealtimeInputSetup(mediaChunks.map { (it.toInternal()) }).toInternal() + ) + session.send(Frame.Text(jsonString)) + } + } + + /** + * Sends [data][Content] to the model. + * + * Calling this after [startAudioConversation] will play the response audio immediately. + * + * @param content Client [Content] to be sent to the model. + */ + public suspend fun send(content: Content) { + FirebaseAIException.catchAsync { + val jsonString = + Json.encodeToString( + BidiGenerateContentClientContentSetup(listOf(content.toInternal()), true).toInternal() + ) + session.send(Frame.Text(jsonString)) + } + } + + /** + * Sends text to the model. + * + * Calling this after [startAudioConversation] will play the response audio immediately. + * + * @param text Text to be sent to the model. + */ + public suspend fun send(text: String) { + FirebaseAIException.catchAsync { send(Content.Builder().text(text).build()) } + } + + /** + * Closes the client session. + * + * Once a [LiveSession] is closed, it can not be reopened; you'll need to start a new + * [LiveSession]. + * + * @see stopReceiving + */ + public suspend fun close() { + FirebaseAIException.catchAsync { + session.close() + stopAudioConversation() + } + } + + /** Listen to the user's microphone and send the data to the model. */ + private fun recordUserAudio() { + // Buffer the recording so we can keep recording while data is sent to the server + audioHelper + ?.listenToRecording() + ?.buffer(UNLIMITED) + ?.accumulateUntil(MIN_BUFFER_SIZE) + ?.onEach { sendMediaStream(listOf(MediaData(it, "audio/pcm"))) } + ?.catch { throw FirebaseAIException.from(it) } + ?.launchIn(scope) + } + + /** + * Processes responses from the model during an audio conversation. + * + * Audio messages are added to [playBackQueue]. + * + * Launched asynchronously on [scope]. + * + * @param functionCallHandler A callback function that is invoked whenever the server receives a + * function call. + */ + private fun processModelResponses( + functionCallHandler: ((FunctionCallPart) -> FunctionResponsePart)? + ) { + receive() + .onEach { + when (it) { + is LiveServerToolCall -> { + if (it.functionCalls.isEmpty()) { + Log.w( + TAG, + "The model sent a tool call request, but it was missing functions to call." + ) + } else if (functionCallHandler != null) { + // It's fine to suspend here since you can't have a function call running concurrently + // with an audio response + sendFunctionResponse(it.functionCalls.map(functionCallHandler).toList()) + } else { + Log.w( + TAG, + "Function calls were present in the response, but a functionCallHandler was not provided." + ) + } + } + is LiveServerToolCallCancellation -> { + Log.w( + TAG, + "The model sent a tool cancellation request, but tool cancellation is not supported when using startAudioConversation()." + ) + } + is LiveServerContent -> { + if (it.interrupted) { + playBackQueue.clear() + } else { + val audioParts = it.content?.parts?.filterIsInstance().orEmpty() + for (part in audioParts) { + playBackQueue.add(part.inlineData) + } + } + } + is LiveServerSetupComplete -> { + // we should only get this message when we initially `connect` in LiveGenerativeModel + Log.w( + TAG, + "The model sent LiveServerSetupComplete after the connection was established." + ) + } + } + } + .launchIn(scope) + } + + /** + * Listens for playback data from the model and plays the audio. + * + * Polls [playBackQueue] for data, and calls [AudioHelper.playAudio] when data is received. + * + * Launched asynchronously on [scope]. + */ + private fun listenForModelPlayback() { + scope.launch { + while (isActive) { + val playbackData = playBackQueue.poll() + if (playbackData == null) { + // The model playback queue is complete, so we can continue recording + // TODO(b/408223520): Conditionally resume when param is added + audioHelper?.resumeRecording() + yield() + } else { + /** + * We pause the recording while the model is speaking to avoid interrupting it because of + * no echo cancellation + */ + // TODO(b/408223520): Conditionally pause when param is added + audioHelper?.pauseRecording() + + audioHelper?.playAudio(playbackData) + } + } + } + } + + /** + * Incremental update of the current conversation delivered from the client. + * + * Effectively, a message from the client to the model. + */ + internal class BidiGenerateContentClientContentSetup( + val turns: List, + val turnComplete: Boolean + ) { + @Serializable + internal class Internal(val clientContent: BidiGenerateContentClientContent) { + @Serializable + internal data class BidiGenerateContentClientContent( + val turns: List, + val turnComplete: Boolean + ) + } + + fun toInternal() = Internal(Internal.BidiGenerateContentClientContent(turns, turnComplete)) + } + + /** Client generated responses to a [LiveServerToolCall]. */ + internal class BidiGenerateContentToolResponseSetup( + val functionResponses: List + ) { + @Serializable + internal data class Internal(val toolResponse: BidiGenerateContentToolResponse) { + @Serializable + internal data class BidiGenerateContentToolResponse( + val functionResponses: List + ) + } + + fun toInternal() = Internal(Internal.BidiGenerateContentToolResponse(functionResponses)) + } + + /** + * User input that is sent to the model in real time. + * + * End of turn is derived from user activity (eg; end of speech). + */ + internal class BidiGenerateContentRealtimeInputSetup(val mediaChunks: List) { + @Serializable + internal class Internal(val realtimeInput: BidiGenerateContentRealtimeInput) { + @Serializable + internal data class BidiGenerateContentRealtimeInput( + val mediaChunks: List + ) + } + fun toInternal() = Internal(Internal.BidiGenerateContentRealtimeInput(mediaChunks)) + } + + private companion object { + val TAG = LiveSession::class.java.simpleName + val MIN_BUFFER_SIZE = + AudioTrack.getMinBufferSize( + 24000, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/MediaData.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/MediaData.kt new file mode 100644 index 00000000000..1262027989d --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/MediaData.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import android.util.Base64 +import kotlinx.serialization.Serializable + +/** + * Represents the media data to be sent to the server + * + * @param data Byte array representing the data to be sent. + * @param mimeType an IANA standard MIME type. For supported MIME type values see the + * [Firebase documentation](https://firebase.google.com/docs/vertex-ai/input-file-requirements). + */ +@PublicPreviewAPI +public class MediaData(public val data: ByteArray, public val mimeType: String) { + @Serializable + internal class Internal( + val data: String, + val mimeType: String, + ) + + internal fun toInternal(): Internal { + return Internal(Base64.encodeToString(data, BASE_64_FLAGS), mimeType) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ModalityTokenCount.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ModalityTokenCount.kt new file mode 100644 index 00000000000..0e959ac4b3f --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ModalityTokenCount.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.Serializable + +/** + * Represents token counting info for a single modality. + * + * @property modality The modality associated with this token count. + * @property tokenCount The number of tokens counted. + */ +public class ModalityTokenCount +private constructor(public val modality: ContentModality, public val tokenCount: Int) { + + public operator fun component1(): ContentModality = modality + + public operator fun component2(): Int = tokenCount + + @Serializable + internal data class Internal( + val modality: ContentModality.Internal, + val tokenCount: Int? = null + ) { + internal fun toPublic() = ModalityTokenCount(modality.toPublic(), tokenCount ?: 0) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Part.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Part.kt new file mode 100644 index 00000000000..bcc7e14b657 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Part.kt @@ -0,0 +1,252 @@ +/* + * 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. + */ + +package com.google.firebase.ai.type + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import java.io.ByteArrayOutputStream +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import org.json.JSONObject + +/** Interface representing data sent to and received from requests. */ +public interface Part {} + +/** Represents text or string based data sent to and received from requests. */ +public class TextPart(public val text: String) : Part { + + @Serializable internal data class Internal(val text: String) : InternalPart +} + +/** + * Represents image data sent to and received from requests. The image is converted client-side to + * JPEG encoding at 80% quality before being sent to the server. + * + * @param image [Bitmap] to convert into a [Part] + */ +public class ImagePart(public val image: Bitmap) : Part { + + internal fun toInlineDataPart() = + InlineDataPart( + android.util.Base64.decode(encodeBitmapToBase64Jpeg(image), BASE_64_FLAGS), + "image/jpeg" + ) +} + +/** + * Represents binary data with an associated MIME type sent to and received from requests. + * + * @param inlineData the binary data as a [ByteArray] + * @param mimeType an IANA standard MIME type. For supported values, see the + * [Vertex AI documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/send-multimodal-prompts#media_requirements) + */ +public class InlineDataPart(public val inlineData: ByteArray, public val mimeType: String) : Part { + + @Serializable + internal data class Internal(@SerialName("inlineData") val inlineData: InlineData) : + InternalPart { + + @Serializable + internal data class InlineData(@SerialName("mimeType") val mimeType: String, val data: Base64) + } +} + +/** + * Represents function call name and params received from requests. + * + * @param name the name of the function to call + * @param args the function parameters and values as a [Map] + * @param id Unique id of the function call. If present, the returned [FunctionResponsePart] should + * have a matching `id` field. + */ +public class FunctionCallPart +@JvmOverloads +constructor( + public val name: String, + public val args: Map, + public val id: String? = null +) : Part { + + @Serializable + internal data class Internal(val functionCall: FunctionCall) : InternalPart { + + @Serializable + internal data class FunctionCall( + val name: String, + val args: Map? = null, + val id: String? = null + ) + } +} + +/** + * Represents function call output to be returned to the model when it requests a function call. + * + * @param name The name of the called function. + * @param response The response produced by the function as a [JSONObject]. + * @param id Matching `id` for a [FunctionCallPart], if one was provided. + */ +public class FunctionResponsePart +@JvmOverloads +constructor( + public val name: String, + public val response: JsonObject, + public val id: String? = null +) : Part { + + @Serializable + internal data class Internal(val functionResponse: FunctionResponse) : InternalPart { + + @Serializable + internal data class FunctionResponse( + val name: String, + val response: JsonObject, + val id: String? = null + ) + } + + internal fun toInternalFunctionCall(): Internal.FunctionResponse { + return Internal.FunctionResponse(name, response, id) + } +} + +/** + * Represents file data stored in Cloud Storage for Firebase, referenced by URI. + * + * @param uri The `"gs://"`-prefixed URI of the file in Cloud Storage for Firebase, for example, + * `"gs://bucket-name/path/image.jpg"` + * @param mimeType an IANA standard MIME type. For supported MIME type values see the + * [Firebase documentation](https://firebase.google.com/docs/vertex-ai/input-file-requirements). + */ +public class FileDataPart(public val uri: String, public val mimeType: String) : Part { + + @Serializable + internal data class Internal(@SerialName("file_data") val fileData: FileData) : InternalPart { + + @Serializable + internal data class FileData( + @SerialName("mime_type") val mimeType: String, + @SerialName("file_uri") val fileUri: String, + ) + } +} + +/** Returns the part as a [String] if it represents text, and null otherwise */ +public fun Part.asTextOrNull(): String? = (this as? TextPart)?.text + +/** Returns the part as a [Bitmap] if it represents an image, and null otherwise */ +public fun Part.asImageOrNull(): Bitmap? = (this as? ImagePart)?.image + +/** Returns the part as a [InlineDataPart] if it represents inline data, and null otherwise */ +public fun Part.asInlineDataPartOrNull(): InlineDataPart? = this as? InlineDataPart + +/** Returns the part as a [FileDataPart] if it represents a file, and null otherwise */ +public fun Part.asFileDataOrNull(): FileDataPart? = this as? FileDataPart + +internal typealias Base64 = String + +internal const val BASE_64_FLAGS = android.util.Base64.NO_WRAP + +@Serializable(PartSerializer::class) internal sealed interface InternalPart + +internal object PartSerializer : + JsonContentPolymorphicSerializer(InternalPart::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy { + val jsonObject = element.jsonObject + return when { + "text" in jsonObject -> TextPart.Internal.serializer() + "functionCall" in jsonObject -> FunctionCallPart.Internal.serializer() + "functionResponse" in jsonObject -> FunctionResponsePart.Internal.serializer() + "inlineData" in jsonObject -> InlineDataPart.Internal.serializer() + "fileData" in jsonObject -> FileDataPart.Internal.serializer() + else -> throw SerializationException("Unknown Part type") + } + } +} + +internal fun Part.toInternal(): InternalPart { + return when (this) { + is TextPart -> TextPart.Internal(text) + is ImagePart -> + InlineDataPart.Internal( + InlineDataPart.Internal.InlineData("image/jpeg", encodeBitmapToBase64Jpeg(image)) + ) + is InlineDataPart -> + InlineDataPart.Internal( + InlineDataPart.Internal.InlineData( + mimeType, + android.util.Base64.encodeToString(inlineData, BASE_64_FLAGS) + ) + ) + is FunctionCallPart -> + FunctionCallPart.Internal(FunctionCallPart.Internal.FunctionCall(name, args, id)) + is FunctionResponsePart -> + FunctionResponsePart.Internal( + FunctionResponsePart.Internal.FunctionResponse(name, response, id) + ) + is FileDataPart -> + FileDataPart.Internal(FileDataPart.Internal.FileData(mimeType = mimeType, fileUri = uri)) + else -> + throw com.google.firebase.ai.type.SerializationException( + "The given subclass of Part (${javaClass.simpleName}) is not supported in the serialization yet." + ) + } +} + +private fun encodeBitmapToBase64Jpeg(input: Bitmap): String { + ByteArrayOutputStream().let { + input.compress(Bitmap.CompressFormat.JPEG, 80, it) + return android.util.Base64.encodeToString(it.toByteArray(), BASE_64_FLAGS) + } +} + +internal fun InternalPart.toPublic(): Part { + return when (this) { + is TextPart.Internal -> TextPart(text) + is InlineDataPart.Internal -> { + val data = android.util.Base64.decode(inlineData.data, BASE_64_FLAGS) + if (inlineData.mimeType.contains("image")) { + ImagePart(decodeBitmapFromImage(data)) + } else { + InlineDataPart(data, inlineData.mimeType) + } + } + is FunctionCallPart.Internal -> + FunctionCallPart( + functionCall.name, + functionCall.args.orEmpty().mapValues { it.value ?: JsonNull }, + functionCall.id + ) + is FunctionResponsePart.Internal -> + FunctionResponsePart(functionResponse.name, functionResponse.response, functionResponse.id) + is FileDataPart.Internal -> FileDataPart(fileData.mimeType, fileData.fileUri) + else -> + throw com.google.firebase.ai.type.SerializationException( + "Unsupported part type \"${javaClass.simpleName}\" provided. This model may not be supported by this SDK." + ) + } +} + +private fun decodeBitmapFromImage(input: ByteArray) = + BitmapFactory.decodeByteArray(input, 0, input.size) diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/PromptFeedback.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/PromptFeedback.kt new file mode 100644 index 00000000000..5f9840263eb --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/PromptFeedback.kt @@ -0,0 +1,90 @@ +/* + * 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. + */ + +package com.google.firebase.ai.type + +import com.google.firebase.ai.common.util.FirstOrdinalSerializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Feedback on the prompt provided in the request. + * + * @param blockReason The reason that content was blocked, if at all. + * @param safetyRatings A list of relevant [SafetyRating]. + * @param blockReasonMessage A message describing the reason that content was blocked, if any. + */ +public class PromptFeedback( + public val blockReason: BlockReason?, + public val safetyRatings: List, + public val blockReasonMessage: String? +) { + + @Serializable + internal data class Internal( + val blockReason: BlockReason.Internal? = null, + val safetyRatings: List? = null, + val blockReasonMessage: String? = null, + ) { + + internal fun toPublic(): PromptFeedback { + val safetyRatings = safetyRatings?.mapNotNull { it.toPublic() }.orEmpty() + return PromptFeedback(blockReason?.toPublic(), safetyRatings, blockReasonMessage) + } + } +} + +/** Describes why content was blocked. */ +public class BlockReason private constructor(public val name: String, public val ordinal: Int) { + + @Serializable(Internal.Serializer::class) + internal enum class Internal { + UNKNOWN, + @SerialName("BLOCKED_REASON_UNSPECIFIED") UNSPECIFIED, + SAFETY, + OTHER, + BLOCKLIST, + PROHIBITED_CONTENT; + + internal object Serializer : KSerializer by FirstOrdinalSerializer(Internal::class) + + internal fun toPublic() = + when (this) { + SAFETY -> BlockReason.SAFETY + OTHER -> BlockReason.OTHER + BLOCKLIST -> BlockReason.BLOCKLIST + PROHIBITED_CONTENT -> BlockReason.PROHIBITED_CONTENT + else -> BlockReason.UNKNOWN + } + } + public companion object { + /** A new and not yet supported value. */ + @JvmField public val UNKNOWN: BlockReason = BlockReason("UNKNOWN", 0) + + /** Content was blocked for violating provided [SafetySetting]. */ + @JvmField public val SAFETY: BlockReason = BlockReason("SAFETY", 1) + + /** Content was blocked for another reason. */ + @JvmField public val OTHER: BlockReason = BlockReason("OTHER", 2) + + /** Content was blocked for another reason. */ + @JvmField public val BLOCKLIST: BlockReason = BlockReason("BLOCKLIST", 3) + + /** Candidates blocked due to the terms which are included from the terminology blocklist. */ + @JvmField public val PROHIBITED_CONTENT: BlockReason = BlockReason("PROHIBITED_CONTENT", 4) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/PublicPreviewAPI.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/PublicPreviewAPI.kt new file mode 100644 index 00000000000..bc4a53cc8eb --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/PublicPreviewAPI.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +@Retention(AnnotationRetention.BINARY) +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = + "This API is part of an experimental public preview and may change in " + + "backwards-incompatible ways without notice.", +) +public annotation class PublicPreviewAPI() diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/RequestOptions.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/RequestOptions.kt new file mode 100644 index 00000000000..dc4211e7222 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/RequestOptions.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +/** Configurable options unique to how requests to the backend are performed. */ +public class RequestOptions +internal constructor( + internal val timeout: Duration, + internal val endpoint: String = "https://firebasevertexai.googleapis.com", + internal val apiVersion: String = "v1beta", +) { + + /** + * Constructor for RequestOptions. + * + * @param timeoutInMillis the maximum amount of time, in milliseconds, for a request to take, from + * the first request to first response. + */ + @JvmOverloads + public constructor( + timeoutInMillis: Long = 180.seconds.inWholeMilliseconds, + ) : this( + timeout = timeoutInMillis.toDuration(DurationUnit.MILLISECONDS), + ) +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ResponseModality.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ResponseModality.kt new file mode 100644 index 00000000000..4c1586227a2 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ResponseModality.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import com.google.firebase.ai.common.util.FirstOrdinalSerializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable + +/** Represents the type of content present in a response (e.g., text, image, audio). */ +public class ResponseModality private constructor(public val ordinal: Int) { + + @Serializable(Internal.Serializer::class) + internal enum class Internal { + TEXT, + IMAGE, + AUDIO; + + internal object Serializer : KSerializer by FirstOrdinalSerializer(Internal::class) + + internal fun toPublic() = + when (this) { + TEXT -> ResponseModality.TEXT + IMAGE -> ResponseModality.IMAGE + else -> ResponseModality.AUDIO + } + } + + internal fun toInternal() = + when (this) { + TEXT -> "TEXT" + IMAGE -> "IMAGE" + else -> "AUDIO" + } + public companion object { + + /** Represents a plain text response modality. */ + @JvmField public val TEXT: ResponseModality = ResponseModality(1) + + /** Represents an image response modality. */ + @JvmField public val IMAGE: ResponseModality = ResponseModality(2) + + /** Represents an audio response modality. */ + @JvmField public val AUDIO: ResponseModality = ResponseModality(4) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/SafetySetting.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/SafetySetting.kt new file mode 100644 index 00000000000..107e0623c57 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/SafetySetting.kt @@ -0,0 +1,44 @@ +/* + * 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. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.Serializable + +/** + * A configuration for a [HarmBlockThreshold] of some [HarmCategory] allowed and blocked in + * responses. + * + * @param harmCategory The relevant [HarmCategory]. + * @param threshold The threshold form harm allowable. + * @param method Specify if the threshold is used for probability or severity score, if not + * specified it will default to [HarmBlockMethod.PROBABILITY]. + */ +public class SafetySetting( + internal val harmCategory: HarmCategory, + internal val threshold: HarmBlockThreshold, + internal val method: HarmBlockMethod? = null, +) { + internal fun toInternal() = + Internal(harmCategory.toInternal(), threshold.toInternal(), method?.toInternal()) + + @Serializable + internal data class Internal( + val category: HarmCategory.Internal, + val threshold: HarmBlockThreshold.Internal, + val method: HarmBlockMethod.Internal? = null, + ) +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Schema.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Schema.kt new file mode 100644 index 00000000000..9eaa4590aad --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Schema.kt @@ -0,0 +1,266 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.Serializable + +public abstract class StringFormat private constructor(internal val value: String) { + public class Custom(value: String) : StringFormat(value) +} + +/** + * Definition of a data type. + * + * These types can be objects, but also primitives and arrays. Represents a select subset of an + * [OpenAPI 3.0 schema object](https://spec.openapis.org/oas/v3.0.3#schema). + * + * **Note:** While optional, including a `description` field in your `Schema` is strongly + * encouraged. The more information the model has about what it's expected to generate, the better + * the results. + */ +public class Schema +internal constructor( + public val type: String, + public val description: String? = null, + public val format: String? = null, + public val nullable: Boolean? = null, + public val enum: List? = null, + public val properties: Map? = null, + public val required: List? = null, + public val items: Schema? = null, +) { + + public companion object { + /** + * Returns a [Schema] representing a boolean value. + * + * @param description An optional description of what the boolean should contain or represent. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmOverloads + public fun boolean(description: String? = null, nullable: Boolean = false): Schema = + Schema( + description = description, + nullable = nullable, + type = "BOOLEAN", + ) + + /** + * Returns a [Schema] for a 32-bit signed integer number. + * + * **Important:** This [Schema] provides a hint to the model that it should generate a 32-bit + * integer, but only guarantees that the value will be an integer. Therefore it's *possible* + * that decoding it as an `Int` variable (or `int` in Java) could overflow. + * + * @param description An optional description of what the integer should contain or represent. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmName("numInt") + @JvmOverloads + public fun integer(description: String? = null, nullable: Boolean = false): Schema = + Schema( + description = description, + format = "int32", + nullable = nullable, + type = "INTEGER", + ) + + /** + * Returns a [Schema] for a 64-bit signed integer number. + * + * @param description An optional description of what the number should contain or represent. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmName("numLong") + @JvmOverloads + public fun long(description: String? = null, nullable: Boolean = false): Schema = + Schema( + description = description, + nullable = nullable, + type = "INTEGER", + ) + + /** + * Returns a [Schema] for a double-precision floating-point number. + * + * @param description An optional description of what the number should contain or represent. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmName("numDouble") + @JvmOverloads + public fun double(description: String? = null, nullable: Boolean = false): Schema = + Schema(description = description, nullable = nullable, type = "NUMBER") + + /** + * Returns a [Schema] for a single-precision floating-point number. + * + * **Important:** This [Schema] provides a hint to the model that it should generate a + * single-precision floating-point number, but only guarantees that the value will be a number. + * Therefore it's *possible* that decoding it as a `Float` variable (or `float` in Java) could + * overflow. + * + * @param description An optional description of what the number should contain or represent. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmName("numFloat") + @JvmOverloads + public fun float(description: String? = null, nullable: Boolean = false): Schema = + Schema(description = description, nullable = nullable, type = "NUMBER", format = "float") + + /** + * Returns a [Schema] for a string. + * + * @param description An optional description of what the string should contain or represent. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + * @param format An optional pattern that values need to adhere to. + */ + @JvmStatic + @JvmName("str") + @JvmOverloads + public fun string( + description: String? = null, + nullable: Boolean = false, + format: StringFormat? = null + ): Schema = + Schema( + description = description, + format = format?.value, + nullable = nullable, + type = "STRING" + ) + + /** + * Returns a [Schema] for a complex data type. + * + * This schema instructs the model to produce data of type object, which has keys of type + * `String` and values of type [Schema]. + * + * **Example:** A `city` could be represented with the following object `Schema`. + * ``` + * Schema.obj(mapOf( + * "name" to Schema.string(), + * "population" to Schema.integer() + * )) + * ``` + * + * @param properties The map of the object's property names to their [Schema]s. + * @param optionalProperties The list of optional properties. They must correspond to the keys + * provided in the `properties` map. By default it's empty, signaling the model that all + * properties are to be included. + * @param description An optional description of what the object represents. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmOverloads + public fun obj( + properties: Map, + optionalProperties: List = emptyList(), + description: String? = null, + nullable: Boolean = false, + ): Schema { + if (!properties.keys.containsAll(optionalProperties)) { + throw IllegalArgumentException( + "All optional properties must be present in properties. Missing: ${optionalProperties.minus(properties.keys)}" + ) + } + return Schema( + description = description, + nullable = nullable, + properties = properties, + required = properties.keys.minus(optionalProperties.toSet()).toList(), + type = "OBJECT", + ) + } + + /** + * Returns a [Schema] for an array. + * + * @param items The [Schema] of the elements stored in the array. + * @param description An optional description of what the array represents. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmOverloads + public fun array( + items: Schema, + description: String? = null, + nullable: Boolean = false + ): Schema = + Schema( + description = description, + nullable = nullable, + items = items, + type = "ARRAY", + ) + + /** + * Returns a [Schema] for an enumeration. + * + * For example, the cardinal directions can be represented as: + * + * ``` + * Schema.enumeration(listOf("north", "east", "south", "west"), "Cardinal directions") + * ``` + * + * @param values The list of valid values for this enumeration + * @param description The description of what the parameter should contain or represent + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmOverloads + public fun enumeration( + values: List, + description: String? = null, + nullable: Boolean = false + ): Schema = + Schema( + description = description, + format = "enum", + nullable = nullable, + enum = values, + type = "STRING", + ) + } + + internal fun toInternal(): Internal = + Internal( + type, + description, + format, + nullable, + enum, + properties?.mapValues { it.value.toInternal() }, + required, + items?.toInternal(), + ) + @Serializable + internal data class Internal( + val type: String, + val description: String? = null, + val format: String? = null, + val nullable: Boolean? = false, + val enum: List? = null, + val properties: Map? = null, + val required: List? = null, + val items: Internal? = null, + ) +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/SpeechConfig.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/SpeechConfig.kt new file mode 100644 index 00000000000..12de21caff3 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/SpeechConfig.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Speech configuration class for setting up the voice of the server's response. */ +@PublicPreviewAPI +public class SpeechConfig( + /** The voice to be used for the server's speech response. */ + public val voice: Voice +) { + + @Serializable + internal data class Internal(@SerialName("voice_config") val voiceConfig: VoiceConfigInternal) { + @Serializable + internal data class VoiceConfigInternal( + @SerialName("prebuilt_voice_config") val prebuiltVoiceConfig: Voice.Internal, + ) + } + + internal fun toInternal(): Internal { + return Internal(Internal.VoiceConfigInternal(prebuiltVoiceConfig = voice.toInternal())) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Tool.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Tool.kt new file mode 100644 index 00000000000..83391166bd4 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Tool.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** + * Contains a set of function declarations that the model has access to. These can be used to gather + * information, or complete tasks + * + * @param functionDeclarations The set of functions that this tool allows the model access to + */ +public class Tool +internal constructor(internal val functionDeclarations: List?) { + internal fun toInternal() = Internal(functionDeclarations?.map { it.toInternal() } ?: emptyList()) + @Serializable + internal data class Internal( + val functionDeclarations: List? = null, + // This is a json object because it is not possible to make a data class with no parameters. + val codeExecution: JsonObject? = null, + ) + public companion object { + + /** + * Creates a [Tool] instance that provides the model with access to the [functionDeclarations]. + * + * @param functionDeclarations The list of functions that this tool allows the model access to. + */ + @JvmStatic + public fun functionDeclarations(functionDeclarations: List): Tool { + return Tool(functionDeclarations) + } + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ToolConfig.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ToolConfig.kt new file mode 100644 index 00000000000..43d20ec3fd6 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/ToolConfig.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Contains configuration for the function calling tools of the model. This can be used to change + * when the model can predict function calls. + * + * @param functionCallingConfig The config for function calling + */ +public class ToolConfig(internal val functionCallingConfig: FunctionCallingConfig?) { + + internal fun toInternal() = + Internal( + functionCallingConfig?.let { + FunctionCallingConfig.Internal( + when (it.mode) { + FunctionCallingConfig.Mode.ANY -> FunctionCallingConfig.Internal.Mode.ANY + FunctionCallingConfig.Mode.AUTO -> FunctionCallingConfig.Internal.Mode.AUTO + FunctionCallingConfig.Mode.NONE -> FunctionCallingConfig.Internal.Mode.NONE + }, + it.allowedFunctionNames + ) + } + ) + + @Serializable + internal data class Internal( + @SerialName("function_calling_config") + val functionCallingConfig: FunctionCallingConfig.Internal? + ) +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Type.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Type.kt new file mode 100644 index 00000000000..76d7623e0c6 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Type.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.json.JSONObject + +internal sealed interface Response + +@Serializable +internal data class GRpcErrorResponse(val error: GRpcError) : Response { + + @Serializable + internal data class GRpcError( + val code: Int, + val message: String, + val details: List? = null + ) { + + @Serializable + internal data class GRpcErrorDetails( + val reason: String? = null, + val domain: String? = null, + val metadata: Map? = null + ) + } +} + +internal fun JSONObject.toInternal() = Json.decodeFromString(toString()) + +internal fun JsonObject.toPublic() = JSONObject(toString()) diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/UsageMetadata.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/UsageMetadata.kt new file mode 100644 index 00000000000..1b858a1e6cd --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/UsageMetadata.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.Serializable + +/** + * Usage metadata about response(s). + * + * @param promptTokenCount Number of tokens in the request. + * @param candidatesTokenCount Number of tokens in the response(s). + * @param totalTokenCount Total number of tokens. + * @param promptTokensDetails The breakdown, by modality, of how many tokens are consumed by the + * prompt. + * @param candidatesTokensDetails The breakdown, by modality, of how many tokens are consumed by the + * candidates. + */ +public class UsageMetadata( + public val promptTokenCount: Int, + public val candidatesTokenCount: Int?, + public val totalTokenCount: Int, + public val promptTokensDetails: List, + public val candidatesTokensDetails: List, +) { + + @Serializable + internal data class Internal( + val promptTokenCount: Int? = null, + val candidatesTokenCount: Int? = null, + val totalTokenCount: Int? = null, + val promptTokensDetails: List? = null, + val candidatesTokensDetails: List? = null, + ) { + + internal fun toPublic(): UsageMetadata = + UsageMetadata( + promptTokenCount ?: 0, + candidatesTokenCount ?: 0, + totalTokenCount ?: 0, + promptTokensDetails = promptTokensDetails?.map { it.toPublic() } ?: emptyList(), + candidatesTokensDetails = candidatesTokensDetails?.map { it.toPublic() } ?: emptyList() + ) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Voice.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Voice.kt new file mode 100644 index 00000000000..7053fc986cf --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Voice.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Various voices supported by the server. The list of all voices can be found + * [here](https://cloud.google.com/text-to-speech/docs/chirp3-hd) + */ +@PublicPreviewAPI +public class Voice public constructor(public val voiceName: String) { + + @Serializable internal data class Internal(@SerialName("voice_name") val voiceName: String) + + internal fun toInternal(): Internal { + return Internal(this.voiceName) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Voices.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Voices.kt new file mode 100644 index 00000000000..d5e1f738dc2 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Voices.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Various voices supported by the server */ +@Deprecated("Please use the Voice class instead.", ReplaceWith("Voice")) +@PublicPreviewAPI +public class Voices private constructor(public val ordinal: Int) { + + @Serializable internal data class Internal(@SerialName("voice_name") val voiceName: String) + + @Serializable + internal enum class InternalEnum { + CHARON, + AOEDE, + FENRIR, + KORE, + PUCK; + internal fun toPublic() = + when (this) { + CHARON -> Voices.CHARON + AOEDE -> Voices.AOEDE + FENRIR -> Voices.FENRIR + KORE -> Voices.KORE + else -> Voices.PUCK + } + } + + internal fun toInternal(): Internal { + return when (this) { + CHARON -> Internal(InternalEnum.CHARON.name) + AOEDE -> Internal(InternalEnum.AOEDE.name) + FENRIR -> Internal(InternalEnum.FENRIR.name) + KORE -> Internal(InternalEnum.KORE.name) + else -> Internal(InternalEnum.PUCK.name) + } + } + + public companion object { + /** + * Unspecified voice. + * + * Will use the default voice of the model. + */ + @JvmField public val UNSPECIFIED: Voices = Voices(0) + + /** Represents the Charon voice. */ + @JvmField public val CHARON: Voices = Voices(1) + + /** Represents the Aoede voice. */ + @JvmField public val AOEDE: Voices = Voices(2) + + /** Represents the Fenrir voice. */ + @JvmField public val FENRIR: Voices = Voices(3) + + /** Represents the Kore voice. */ + @JvmField public val KORE: Voices = Voices(4) + + /** Represents the Puck voice. */ + @JvmField public val PUCK: Voices = Voices(5) + } +} diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/DevAPIStreamingSnapshotTests.kt b/firebase-ai/src/test/java/com/google/firebase/ai/DevAPIStreamingSnapshotTests.kt new file mode 100644 index 00000000000..967254a096c --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/DevAPIStreamingSnapshotTests.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai + +import com.google.firebase.ai.type.BlockReason +import com.google.firebase.ai.type.FinishReason +import com.google.firebase.ai.type.PromptBlockedException +import com.google.firebase.ai.type.ResponseStoppedException +import com.google.firebase.ai.type.ServerException +import com.google.firebase.ai.util.goldenDevAPIStreamingFile +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.ktor.http.HttpStatusCode +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.withTimeout +import org.junit.Test + +internal class DevAPIStreamingSnapshotTests { + private val testTimeout = 5.seconds + + @Test + fun `short reply`() = + goldenDevAPIStreamingFile("streaming-success-basic-reply-short.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val responseList = responses.toList() + responseList.isEmpty() shouldBe false + responseList.last().candidates.first().apply { + finishReason shouldBe FinishReason.STOP + content.parts.isEmpty() shouldBe false + } + } + } + + @Test + fun `long reply`() = + goldenDevAPIStreamingFile("streaming-success-basic-reply-long.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val responseList = responses.toList() + responseList.isEmpty() shouldBe false + responseList.last().candidates.first().apply { + finishReason shouldBe FinishReason.STOP + content.parts.isEmpty() shouldBe false + } + } + } + + @Test + fun `prompt blocked for safety`() = + goldenDevAPIStreamingFile("streaming-failure-prompt-blocked-safety.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val exception = shouldThrow { responses.collect() } + exception.response?.promptFeedback?.blockReason shouldBe BlockReason.SAFETY + } + } + + @Test + fun `citation parsed correctly`() = + goldenDevAPIStreamingFile("streaming-success-citations.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val responseList = responses.toList() + responseList.any { + it.candidates.any { it.citationMetadata?.citations?.isNotEmpty() ?: false } + } shouldBe true + } + } + + @Test + fun `stopped for recitation`() = + goldenDevAPIStreamingFile("streaming-failure-recitation-no-content.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val exception = shouldThrow { responses.collect() } + exception.response.candidates.first().finishReason shouldBe FinishReason.RECITATION + } + } + + @Test + fun `image rejected`() = + goldenDevAPIStreamingFile("streaming-failure-image-rejected.txt", HttpStatusCode.BadRequest) { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { shouldThrow { responses.collect() } } + } +} diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/DevAPIUnarySnapshotTests.kt b/firebase-ai/src/test/java/com/google/firebase/ai/DevAPIUnarySnapshotTests.kt new file mode 100644 index 00000000000..91a263f8c66 --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/DevAPIUnarySnapshotTests.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai + +import com.google.firebase.ai.type.FinishReason +import com.google.firebase.ai.type.InvalidAPIKeyException +import com.google.firebase.ai.type.ResponseStoppedException +import com.google.firebase.ai.type.ServerException +import com.google.firebase.ai.util.goldenDevAPIUnaryFile +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.ktor.http.HttpStatusCode +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.withTimeout +import org.junit.Test + +internal class DevAPIUnarySnapshotTests { + private val testTimeout = 5.seconds + + @Test + fun `short reply`() = + goldenDevAPIUnaryFile("unary-success-basic-reply-short.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + response.candidates.first().finishReason shouldBe FinishReason.STOP + response.candidates.first().content.parts.isEmpty() shouldBe false + } + } + + @Test + fun `long reply`() = + goldenDevAPIUnaryFile("unary-success-basic-reply-long.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + response.candidates.first().finishReason shouldBe FinishReason.STOP + response.candidates.first().content.parts.isEmpty() shouldBe false + } + } + + @Test + fun `citation returns correctly`() = + goldenDevAPIUnaryFile("unary-success-citations.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + response.candidates.first().citationMetadata?.citations?.size shouldBe 4 + response.candidates.first().citationMetadata?.citations?.forEach { + it.startIndex shouldNotBe null + it.endIndex shouldNotBe null + } + } + } + + @Test + fun `response blocked for safety`() = + goldenDevAPIUnaryFile("unary-failure-finish-reason-safety.txt") { + withTimeout(testTimeout) { + shouldThrow { model.generateContent("prompt") } should + { + it.response.candidates[0].finishReason shouldBe FinishReason.SAFETY + } + } + } + + @Test + fun `invalid api key`() = + goldenDevAPIUnaryFile("unary-failure-api-key.json", HttpStatusCode.BadRequest) { + withTimeout(testTimeout) { + shouldThrow { model.generateContent("prompt") } + } + } + @Test + fun `unknown model`() = + goldenDevAPIUnaryFile("unary-failure-unknown-model.json", HttpStatusCode.NotFound) { + withTimeout(testTimeout) { shouldThrow { model.generateContent("prompt") } } + } +} diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/GenerativeModelTesting.kt b/firebase-ai/src/test/java/com/google/firebase/ai/GenerativeModelTesting.kt new file mode 100644 index 00000000000..8301f48d968 --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/GenerativeModelTesting.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai + +import com.google.firebase.FirebaseApp +import com.google.firebase.ai.common.APIController +import com.google.firebase.ai.common.JSON +import com.google.firebase.ai.common.util.doBlocking +import com.google.firebase.ai.type.Candidate +import com.google.firebase.ai.type.Content +import com.google.firebase.ai.type.GenerateContentResponse +import com.google.firebase.ai.type.RequestOptions +import com.google.firebase.ai.type.ServerException +import com.google.firebase.ai.type.TextPart +import com.google.firebase.ai.type.content +import io.kotest.assertions.json.shouldContainJsonKey +import io.kotest.assertions.json.shouldContainJsonKeyValue +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeInstanceOf +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encodeToString +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito + +internal class GenerativeModelTesting { + private val TEST_CLIENT_ID = "test" + private val TEST_APP_ID = "1:android:12345" + private val TEST_VERSION = 1 + + private var mockFirebaseApp: FirebaseApp = Mockito.mock() + + @Before + fun setup() { + Mockito.`when`(mockFirebaseApp.isDataCollectionDefaultEnabled).thenReturn(false) + } + + @Test + fun `system calling in request`() = doBlocking { + val mockEngine = MockEngine { + respond( + generateContentResponseAsJsonString("text response"), + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json") + ) + } + + val apiController = + APIController( + "super_cool_test_key", + "gemini-1.5-flash", + RequestOptions(timeout = 5.seconds, endpoint = "https://my.custom.endpoint"), + mockEngine, + TEST_CLIENT_ID, + mockFirebaseApp, + TEST_VERSION, + TEST_APP_ID, + null, + ) + + val generativeModel = + GenerativeModel( + "gemini-1.5-flash", + systemInstruction = content { text("system instruction") }, + controller = apiController + ) + + withTimeout(5.seconds) { generativeModel.generateContent("my test prompt") } + + mockEngine.requestHistory.shouldNotBeEmpty() + + val request = mockEngine.requestHistory.first().body + request.shouldBeInstanceOf() + + request.text.let { + it shouldContainJsonKey "system_instruction" + it.shouldContainJsonKeyValue("$.system_instruction.role", "system") + it.shouldContainJsonKeyValue("$.system_instruction.parts[0].text", "system instruction") + } + } + + @Test + fun `exception thrown when using invalid location`() = doBlocking { + val mockEngine = MockEngine { + respond( + """ + + Error 404 (Not Found)!!1 + """ + .trimIndent(), + HttpStatusCode.NotFound, + headersOf(HttpHeaders.ContentType, "text/html; charset=utf-8") + ) + } + + val apiController = + APIController( + "super_cool_test_key", + "gemini-1.5-flash", + RequestOptions(), + mockEngine, + TEST_CLIENT_ID, + mockFirebaseApp, + TEST_VERSION, + TEST_APP_ID, + null, + ) + + // Creating the + val generativeModel = + GenerativeModel( + "projects/PROJECTID/locations/INVALID_LOCATION/publishers/google/models/gemini-1.5-flash", + controller = apiController + ) + + val exception = + shouldThrow { + withTimeout(5.seconds) { generativeModel.generateContent("my test prompt") } + } + + // Let's not be too strict on the wording to avoid breaking the test unnecessarily. + exception.message shouldContain "location" + } + + @OptIn(ExperimentalSerializationApi::class) + private fun generateContentResponseAsJsonString(text: String): String { + return JSON.encodeToString( + GenerateContentResponse.Internal( + listOf(Candidate.Internal(Content.Internal(parts = listOf(TextPart.Internal(text))))) + ) + ) + } +} diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/SchemaTests.kt b/firebase-ai/src/test/java/com/google/firebase/ai/SchemaTests.kt new file mode 100644 index 00000000000..f9bdf8c835f --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/SchemaTests.kt @@ -0,0 +1,221 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai + +import com.google.firebase.ai.type.Schema +import com.google.firebase.ai.type.StringFormat +import io.kotest.assertions.json.shouldEqualJson +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Test + +internal class SchemaTests { + @Test + fun `basic schema declaration`() { + val schemaDeclaration = + Schema.array( + Schema.obj( + mapOf( + "name" to Schema.string(), + "country" to Schema.string(), + "population" to Schema.integer(), + "coordinates" to + Schema.obj( + mapOf( + "latitude" to Schema.double(), + "longitude" to Schema.double(), + ) + ), + "hemisphere" to + Schema.obj( + mapOf( + "latitudinal" to Schema.enumeration(listOf("N", "S")), + "longitudinal" to Schema.enumeration(listOf("E", "W")), + ) + ), + "elevation" to Schema.double(), + "isCapital" to Schema.boolean(), + "foundingDate" to Schema.string(nullable = true, format = StringFormat.Custom("date")), + ), + optionalProperties = listOf("population") + ) + ) + + val expectedJson = + """ + { + "type": "ARRAY", + "items": { + "type": "OBJECT", + "properties": { + "name": {"type": "STRING"}, + "country": {"type": "STRING"}, + "population": {"type": "INTEGER", "format": "int32"}, + "coordinates": { + "type": "OBJECT", + "properties": { + "latitude": {"type": "NUMBER"}, + "longitude": {"type": "NUMBER"} + }, + "required": ["latitude","longitude"] + }, + "hemisphere": { + "type": "OBJECT", + "properties": { + "latitudinal": {"type": "STRING","format": "enum","enum": ["N","S"]}, + "longitudinal": {"type": "STRING","format": "enum","enum": ["E","W"]} + }, + "required": ["latitudinal","longitudinal"] + }, + "elevation": {"type": "NUMBER"}, + "isCapital": {"type": "BOOLEAN"}, + "foundingDate": {"type": "STRING","format": "date","nullable": true} + }, + "required": [ + "name","country","coordinates","hemisphere","elevation", + "isCapital","foundingDate"] + } + } + """ + .trimIndent() + + Json.encodeToString(schemaDeclaration.toInternal()).shouldEqualJson(expectedJson) + } + + @Test + fun `full schema declaration`() { + val schemaDeclaration = + Schema.array( + Schema.obj( + description = "generic description", + nullable = true, + properties = + mapOf( + "name" to Schema.string(description = null, nullable = false, format = null), + "country" to + Schema.string( + description = "country name", + nullable = true, + format = StringFormat.Custom("custom format") + ), + "population" to Schema.long(description = "population count", nullable = true), + "coordinates" to + Schema.obj( + description = "coordinates", + nullable = true, + properties = + mapOf( + "latitude" to Schema.double(description = "latitude", nullable = false), + "longitude" to Schema.double(description = "longitude", nullable = false), + ) + ), + "hemisphere" to + Schema.obj( + description = "hemisphere", + nullable = false, + properties = + mapOf( + "latitudinal" to + Schema.enumeration( + listOf("N", "S"), + description = "latitudinal", + nullable = true + ), + "longitudinal" to + Schema.enumeration( + listOf("E", "W"), + description = "longitudinal", + nullable = true + ), + ), + ), + "elevation" to Schema.float(description = "elevation", nullable = false), + "isCapital" to + Schema.boolean( + description = "True if the city is the capital of the country", + nullable = false + ), + "foundingDate" to + Schema.string( + description = "Founding date", + nullable = true, + format = StringFormat.Custom("date") + ), + ) + ) + ) + + val expectedJson = + """ + { + "type": "ARRAY", + "items": { + "type": "OBJECT", + "description": "generic description", + "nullable": true, + "properties": { + "name": {"type": "STRING"}, + "country": {"type": "STRING", "description": "country name", "format": "custom format", "nullable": true}, + "population": {"type": "INTEGER", "description": "population count", "nullable": true}, + "coordinates": { + "type": "OBJECT", + "description": "coordinates", + "nullable": true, + "properties": { + "latitude": {"type": "NUMBER", "description": "latitude"}, + "longitude": {"type": "NUMBER", "description": "longitude"} + }, + "required": ["latitude","longitude"] + }, + "hemisphere": { + "type": "OBJECT", + "description": "hemisphere", + "properties": { + "latitudinal": { + "type": "STRING", + "description": "latitudinal", + "format": "enum", + "nullable": true, + "enum": ["N","S"] + }, + "longitudinal": { + "type": "STRING", + "description": "longitudinal", + "format": "enum", + "nullable": true, + "enum": ["E","W"] + } + }, + "required": ["latitudinal","longitudinal"] + }, + "elevation": {"type": "NUMBER", "description": "elevation", "format": "float"}, + "isCapital": {"type": "BOOLEAN", "description": "True if the city is the capital of the country"}, + "foundingDate": {"type": "STRING", "description": "Founding date", "format": "date", "nullable": true} + }, + "required": [ + "name","country","population","coordinates","hemisphere", + "elevation","isCapital","foundingDate" + ] + } + } + + """ + .trimIndent() + + Json.encodeToString(schemaDeclaration.toInternal()).shouldEqualJson(expectedJson) + } +} diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/SerializationTests.kt b/firebase-ai/src/test/java/com/google/firebase/ai/SerializationTests.kt new file mode 100644 index 00000000000..d00f75c2714 --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/SerializationTests.kt @@ -0,0 +1,215 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai + +import com.google.firebase.ai.common.util.descriptorToJson +import com.google.firebase.ai.type.Candidate +import com.google.firebase.ai.type.CountTokensResponse +import com.google.firebase.ai.type.GenerateContentResponse +import com.google.firebase.ai.type.ModalityTokenCount +import com.google.firebase.ai.type.Schema +import io.kotest.assertions.json.shouldEqualJson +import org.junit.Test + +internal class SerializationTests { + @Test + fun `test countTokensResponse serialization as Json`() { + val expectedJsonAsString = + """ + { + "id": "CountTokensResponse", + "type": "object", + "properties": { + "totalTokens": { + "type": "integer" + }, + "totalBillableCharacters": { + "type": "integer" + }, + "promptTokensDetails": { + "type": "array", + "items": { + "${'$'}ref": "ModalityTokenCount" + } + } + } + } + """ + .trimIndent() + val actualJson = descriptorToJson(CountTokensResponse.Internal.serializer().descriptor) + expectedJsonAsString shouldEqualJson actualJson.toString() + } + + @Test + fun `test modalityTokenCount serialization as Json`() { + val expectedJsonAsString = + """ + { + "id": "ModalityTokenCount", + "type": "object", + "properties": { + "modality": { + "type": "string", + "enum": [ + "UNSPECIFIED", + "TEXT", + "IMAGE", + "VIDEO", + "AUDIO", + "DOCUMENT" + ] + }, + "tokenCount": { + "type": "integer" + } + } + } + """ + .trimIndent() + val actualJson = descriptorToJson(ModalityTokenCount.Internal.serializer().descriptor) + expectedJsonAsString shouldEqualJson actualJson.toString() + } + + @Test + fun `test GenerateContentResponse serialization as Json`() { + val expectedJsonAsString = + """ + { + "id": "GenerateContentResponse", + "type": "object", + "properties": { + "candidates": { + "type": "array", + "items": { + "${'$'}ref": "Candidate" + } + }, + "promptFeedback": { + "${'$'}ref": "PromptFeedback" + }, + "usageMetadata": { + "${'$'}ref": "UsageMetadata" + } + } + } + """ + .trimIndent() + val actualJson = descriptorToJson(GenerateContentResponse.Internal.serializer().descriptor) + expectedJsonAsString shouldEqualJson actualJson.toString() + } + + @Test + fun `test Candidate serialization as Json`() { + val expectedJsonAsString = + """ + { + "id": "Candidate", + "type": "object", + "properties": { + "content": { + "${'$'}ref": "Content" + }, + "finishReason": { + "type": "string", + "enum": [ + "UNKNOWN", + "UNSPECIFIED", + "STOP", + "MAX_TOKENS", + "SAFETY", + "RECITATION", + "OTHER", + "BLOCKLIST", + "PROHIBITED_CONTENT", + "SPII", + "MALFORMED_FUNCTION_CALL" + ] + }, + "safetyRatings": { + "type": "array", + "items": { + "${'$'}ref": "SafetyRating" + } + }, + "citationMetadata": { + "${'$'}ref": "CitationMetadata" + }, + "groundingMetadata": { + "${'$'}ref": "GroundingMetadata" + } + } + } + """ + .trimIndent() + val actualJson = descriptorToJson(Candidate.Internal.serializer().descriptor) + expectedJsonAsString shouldEqualJson actualJson.toString() + } + + @Test + fun `test Schema serialization as Json`() { + /** + * Unlike the actual schema in the background, we don't represent "type" as an enum, but rather + * as a string. This is because we restrict what values can be used (using helper methods, + * rather than type). + */ + val expectedJsonAsString = + """ + { + "id": "Schema", + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "format": { + "type": "string" + }, + "description": { + "type": "string" + }, + "nullable": { + "type": "boolean" + }, + "items": { + "${'$'}ref": "Schema" + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "${'$'}ref": "Schema" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + """ + .trimIndent() + val actualJson = descriptorToJson(Schema.Internal.serializer().descriptor) + expectedJsonAsString shouldEqualJson actualJson.toString() + } +} diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/VertexAIStreamingSnapshotTests.kt b/firebase-ai/src/test/java/com/google/firebase/ai/VertexAIStreamingSnapshotTests.kt new file mode 100644 index 00000000000..e6331401fde --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/VertexAIStreamingSnapshotTests.kt @@ -0,0 +1,246 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai + +import com.google.firebase.ai.type.BlockReason +import com.google.firebase.ai.type.FinishReason +import com.google.firebase.ai.type.HarmCategory +import com.google.firebase.ai.type.InvalidAPIKeyException +import com.google.firebase.ai.type.PromptBlockedException +import com.google.firebase.ai.type.ResponseStoppedException +import com.google.firebase.ai.type.SerializationException +import com.google.firebase.ai.type.ServerException +import com.google.firebase.ai.type.TextPart +import com.google.firebase.ai.util.goldenVertexStreamingFile +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.ktor.http.HttpStatusCode +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.withTimeout +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class VertexAIStreamingSnapshotTests { + private val testTimeout = 5.seconds + + @Test + fun `short reply`() = + goldenVertexStreamingFile("streaming-success-basic-reply-short.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val responseList = responses.toList() + responseList.isEmpty() shouldBe false + responseList.last().candidates.first().apply { + finishReason shouldBe FinishReason.STOP + content.parts.isEmpty() shouldBe false + safetyRatings.isEmpty() shouldBe false + } + } + } + + @Test + fun `long reply`() = + goldenVertexStreamingFile("streaming-success-basic-reply-long.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val responseList = responses.toList() + responseList.isEmpty() shouldBe false + responseList.last().candidates.first().apply { + finishReason shouldBe FinishReason.STOP + content.parts.isEmpty() shouldBe false + } + } + } + + @Test + fun `unknown enum in safety ratings`() = + goldenVertexStreamingFile("streaming-success-unknown-safety-enum.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val responseList = responses.toList() + + responseList.isEmpty() shouldBe false + responseList.any { + it.candidates.any { it.safetyRatings.any { it.category == HarmCategory.UNKNOWN } } + } shouldBe true + } + } + + @Test + fun `invalid safety ratings during image generation`() = + goldenVertexStreamingFile("streaming-success-image-invalid-safety-ratings.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val responseList = responses.toList() + + responseList.isEmpty() shouldBe false + } + } + + @Test + fun `unknown enum in finish reason`() = + goldenVertexStreamingFile("streaming-failure-unknown-finish-enum.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val exception = shouldThrow { responses.collect() } + exception.response.candidates.first().finishReason shouldBe FinishReason.UNKNOWN + } + } + + @Test + fun `quotes escaped`() = + goldenVertexStreamingFile("streaming-success-quotes-escaped.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val responseList = responses.toList() + + responseList.isEmpty() shouldBe false + val part = responseList.first().candidates.first().content.parts.first() as? TextPart + part.shouldNotBeNull() + part.text shouldContain "\"" + } + } + + @Test + fun `prompt blocked for safety`() = + goldenVertexStreamingFile("streaming-failure-prompt-blocked-safety.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val exception = shouldThrow { responses.collect() } + exception.response?.promptFeedback?.blockReason shouldBe BlockReason.SAFETY + } + } + + @Test + fun `prompt blocked for safety with message`() = + goldenVertexStreamingFile("streaming-failure-prompt-blocked-safety-with-message.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val exception = shouldThrow { responses.collect() } + exception.response?.promptFeedback?.blockReason shouldBe BlockReason.SAFETY + exception.response?.promptFeedback?.blockReasonMessage shouldBe "Reasons" + } + } + + @Test + fun `empty content`() = + goldenVertexStreamingFile("streaming-failure-empty-content.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { shouldThrow { responses.collect() } } + } + + @Test + fun `http errors`() = + goldenVertexStreamingFile( + "streaming-failure-http-error.txt", + HttpStatusCode.PreconditionFailed + ) { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { shouldThrow { responses.collect() } } + } + + @Test + fun `stopped for safety`() = + goldenVertexStreamingFile("streaming-failure-finish-reason-safety.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val exception = shouldThrow { responses.collect() } + exception.response.candidates.first().finishReason shouldBe FinishReason.SAFETY + } + } + + @Test + fun `citation parsed correctly`() = + goldenVertexStreamingFile("streaming-success-citations.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val responseList = responses.toList() + responseList.any { + it.candidates.any { it.citationMetadata?.citations?.isNotEmpty() ?: false } + } shouldBe true + } + } + + @Test + fun `stopped for recitation`() = + goldenVertexStreamingFile("streaming-failure-recitation-no-content.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { + val exception = shouldThrow { responses.collect() } + exception.response.candidates.first().finishReason shouldBe FinishReason.RECITATION + } + } + + @Test + fun `image rejected`() = + goldenVertexStreamingFile("streaming-failure-image-rejected.txt", HttpStatusCode.BadRequest) { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { shouldThrow { responses.collect() } } + } + + @Test + fun `unknown model`() = + goldenVertexStreamingFile("streaming-failure-unknown-model.txt", HttpStatusCode.NotFound) { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { shouldThrow { responses.collect() } } + } + + @Test + fun `invalid api key`() = + goldenVertexStreamingFile("streaming-failure-api-key.txt", HttpStatusCode.BadRequest) { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { shouldThrow { responses.collect() } } + } + + @Test + fun `invalid json`() = + goldenVertexStreamingFile("streaming-failure-invalid-json.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { shouldThrow { responses.collect() } } + } + + @Test + fun `malformed content`() = + goldenVertexStreamingFile("streaming-failure-malformed-content.txt") { + val responses = model.generateContentStream("prompt") + + withTimeout(testTimeout) { shouldThrow { responses.collect() } } + } +} diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/VertexAIUnarySnapshotTests.kt b/firebase-ai/src/test/java/com/google/firebase/ai/VertexAIUnarySnapshotTests.kt new file mode 100644 index 00000000000..ca1d279d288 --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/VertexAIUnarySnapshotTests.kt @@ -0,0 +1,592 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai + +import com.google.firebase.ai.type.BlockReason +import com.google.firebase.ai.type.ContentBlockedException +import com.google.firebase.ai.type.ContentModality +import com.google.firebase.ai.type.FinishReason +import com.google.firebase.ai.type.FunctionCallPart +import com.google.firebase.ai.type.HarmCategory +import com.google.firebase.ai.type.HarmProbability +import com.google.firebase.ai.type.HarmSeverity +import com.google.firebase.ai.type.InvalidAPIKeyException +import com.google.firebase.ai.type.PromptBlockedException +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.QuotaExceededException +import com.google.firebase.ai.type.ResponseStoppedException +import com.google.firebase.ai.type.SerializationException +import com.google.firebase.ai.type.ServerException +import com.google.firebase.ai.type.ServiceDisabledException +import com.google.firebase.ai.type.TextPart +import com.google.firebase.ai.type.UnsupportedUserLocationException +import com.google.firebase.ai.util.goldenVertexUnaryFile +import com.google.firebase.ai.util.shouldNotBeNullOrEmpty +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.inspectors.forAtLeastOne +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotBeEmpty +import io.kotest.matchers.types.shouldBeInstanceOf +import io.ktor.http.HttpStatusCode +import java.util.Calendar +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.json.JSONArray +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(PublicPreviewAPI::class) +@RunWith(RobolectricTestRunner::class) +internal class VertexAIUnarySnapshotTests { + private val testTimeout = 5.seconds + + @Test + fun `short reply`() = + goldenVertexUnaryFile("unary-success-basic-reply-short.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + response.candidates.first().finishReason shouldBe FinishReason.STOP + response.candidates.first().content.parts.isEmpty() shouldBe false + response.candidates.first().safetyRatings.isEmpty() shouldBe false + } + } + + @Test + fun `long reply`() = + goldenVertexUnaryFile("unary-success-basic-reply-long.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + response.candidates.first().finishReason shouldBe FinishReason.STOP + response.candidates.first().content.parts.isEmpty() shouldBe false + response.candidates.first().safetyRatings.isEmpty() shouldBe false + } + } + + @Test + fun `response with detailed token-based usageMetadata`() = + goldenVertexUnaryFile("unary-success-basic-response-long-usage-metadata.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + response.candidates.first().finishReason shouldBe FinishReason.STOP + response.candidates.first().content.parts.isEmpty() shouldBe false + response.usageMetadata shouldNotBe null + response.usageMetadata?.apply { + totalTokenCount shouldBe 1913 + candidatesTokenCount shouldBe 76 + promptTokensDetails?.forAtLeastOne { + it.modality shouldBe ContentModality.IMAGE + it.tokenCount shouldBe 1806 + } + candidatesTokensDetails?.forAtLeastOne { + it.modality shouldBe ContentModality.TEXT + it.tokenCount shouldBe 76 + } + } + } + } + + @Test + fun `unknown enum in safety ratings`() = + goldenVertexUnaryFile("unary-success-unknown-enum-safety-ratings.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + val candidate = response.candidates.first() + candidate.safetyRatings.any { it.category == HarmCategory.UNKNOWN } shouldBe true + response.promptFeedback?.safetyRatings?.any { it.category == HarmCategory.UNKNOWN } shouldBe + true + } + } + + @Test + fun `invalid safety ratings during image generation`() = + goldenVertexUnaryFile("unary-success-image-invalid-safety-ratings.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + } + } + + @Test + fun `unknown enum in finish reason`() = + goldenVertexUnaryFile("unary-failure-unknown-enum-finish-reason.json") { + withTimeout(testTimeout) { + shouldThrow { model.generateContent("prompt") } should + { + it.response.candidates.first().finishReason shouldBe FinishReason.UNKNOWN + } + } + } + + @Test + fun `unknown enum in block reason`() = + goldenVertexUnaryFile("unary-failure-unknown-enum-prompt-blocked.json") { + withTimeout(testTimeout) { + shouldThrow { model.generateContent("prompt") } should + { + it.response?.promptFeedback?.blockReason shouldBe BlockReason.UNKNOWN + } + } + } + + @Test + fun `quotes escaped`() = + goldenVertexUnaryFile("unary-success-quote-reply.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + response.candidates.first().content.parts.isEmpty() shouldBe false + val part = response.candidates.first().content.parts.first() as TextPart + part.text shouldContain "\"" + } + } + + @Test + fun `safetyRatings missing`() = + goldenVertexUnaryFile("unary-success-missing-safety-ratings.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + response.candidates.first().content.parts.isEmpty() shouldBe false + response.candidates.first().safetyRatings.isEmpty() shouldBe true + response.promptFeedback?.safetyRatings?.isEmpty() shouldBe true + } + } + + @Test + fun `safetyRatings including severity`() = + goldenVertexUnaryFile("unary-success-including-severity.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + response.candidates.first().safetyRatings.isEmpty() shouldBe false + response.candidates.first().safetyRatings.all { + it.probability == HarmProbability.NEGLIGIBLE + } shouldBe true + response.candidates.first().safetyRatings.all { + it.severity == HarmSeverity.NEGLIGIBLE + } shouldBe true + response.candidates.first().safetyRatings.all { it.severityScore != null } shouldBe true + } + } + + @Test + fun `function call has no arguments field`() = + goldenVertexUnaryFile("unary-success-function-call-empty-arguments.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + val content = response.candidates.shouldNotBeNullOrEmpty().first().content + content.shouldNotBeNull() + val callPart = content.parts.shouldNotBeNullOrEmpty().first() as FunctionCallPart + + callPart.name shouldBe "current_time" + callPart.args shouldBe emptyMap() + } + } + + @Test + fun `prompt blocked for safety`() = + goldenVertexUnaryFile("unary-failure-prompt-blocked-safety.json") { + withTimeout(testTimeout) { + shouldThrow { model.generateContent("prompt") } should + { + it.response?.promptFeedback?.blockReason shouldBe BlockReason.SAFETY + } + } + } + + @Test + fun `prompt blocked for safety with message`() = + goldenVertexUnaryFile("unary-failure-prompt-blocked-safety-with-message.json") { + withTimeout(testTimeout) { + shouldThrow { model.generateContent("prompt") } should + { + it.response?.promptFeedback?.blockReason shouldBe BlockReason.SAFETY + it.response?.promptFeedback?.blockReasonMessage shouldContain "Reasons" + } + } + } + + @Test + fun `empty content`() = + goldenVertexUnaryFile("unary-failure-empty-content.json") { + withTimeout(testTimeout) { + shouldThrow { model.generateContent("prompt") } + } + } + + @Test + fun `http error`() = + goldenVertexUnaryFile("unary-failure-http-error.json", HttpStatusCode.PreconditionFailed) { + withTimeout(testTimeout) { shouldThrow { model.generateContent("prompt") } } + } + + @Test + fun `user location error`() = + goldenVertexUnaryFile( + "unary-failure-unsupported-user-location.json", + HttpStatusCode.PreconditionFailed, + ) { + withTimeout(testTimeout) { + shouldThrow { model.generateContent("prompt") } + } + } + + @Test + fun `stopped for safety`() = + goldenVertexUnaryFile("unary-failure-finish-reason-safety.json") { + withTimeout(testTimeout) { + val exception = shouldThrow { model.generateContent("prompt") } + exception.response.candidates.first().finishReason shouldBe FinishReason.SAFETY + exception.response.candidates.first().safetyRatings.forAtLeastOne { + it.category shouldBe HarmCategory.HARASSMENT + it.probability shouldBe HarmProbability.LOW + it.severity shouldBe HarmSeverity.LOW + } + } + } + + @Test + fun `quota exceeded`() = + goldenVertexUnaryFile("unary-failure-quota-exceeded.json", HttpStatusCode.BadRequest) { + withTimeout(testTimeout) { + shouldThrow { model.generateContent("prompt") } + } + } + + @Test + fun `stopped for safety with no content`() = + goldenVertexUnaryFile("unary-failure-finish-reason-safety-no-content.json") { + withTimeout(testTimeout) { + val exception = shouldThrow { model.generateContent("prompt") } + exception.response.candidates.first().finishReason shouldBe FinishReason.SAFETY + } + } + + @Test + fun `citation returns correctly`() = + goldenVertexUnaryFile("unary-success-citations.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + response.candidates.first().citationMetadata?.citations?.size shouldBe 3 + response.candidates.first().citationMetadata?.citations?.forAtLeastOne { + it.publicationDate?.get(Calendar.YEAR) shouldBe 2019 + it.publicationDate?.get(Calendar.DAY_OF_MONTH) shouldBe 10 + } + } + } + + @Test + fun `citation returns correctly with missing license and startIndex`() = + goldenVertexUnaryFile("unary-success-citations-nolicense.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + response.candidates.first().citationMetadata?.citations?.isEmpty() shouldBe false + // Verify the values in the citation source + val firstCitation = response.candidates.first().citationMetadata?.citations?.first() + if (firstCitation != null) { + with(firstCitation) { + license shouldBe null + startIndex shouldBe 0 + } + } + } + } + + @Test + fun `response includes usage metadata`() = + goldenVertexUnaryFile("unary-success-usage-metadata.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + response.candidates.first().finishReason shouldBe FinishReason.STOP + response.usageMetadata shouldNotBe null + response.usageMetadata?.totalTokenCount shouldBe 363 + response.usageMetadata?.promptTokensDetails?.isEmpty() shouldBe true + } + } + + @Test + fun `response includes partial usage metadata`() = + goldenVertexUnaryFile("unary-success-partial-usage-metadata.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + response.candidates.first().finishReason shouldBe FinishReason.STOP + response.usageMetadata shouldNotBe null + response.usageMetadata?.promptTokenCount shouldBe 6 + response.usageMetadata?.totalTokenCount shouldBe 0 + } + } + + @Test + fun `properly translates json text`() = + goldenVertexUnaryFile("unary-success-constraint-decoding-json.json") { + val response = model.generateContent("prompt") + + response.candidates.isEmpty() shouldBe false + with(response.candidates.first().content.parts.first().shouldBeInstanceOf()) { + shouldNotBeNull() + val jsonArr = JSONArray(text) + jsonArr.length() shouldBe 3 + for (i in 0 until jsonArr.length()) { + with(jsonArr.getJSONObject(i)) { + shouldNotBeNull() + getString("name").shouldNotBeEmpty() + getJSONArray("colors").length() shouldBe 5 + } + } + } + } + + @Test + fun `invalid response`() = + goldenVertexUnaryFile("unary-failure-invalid-response.json") { + withTimeout(testTimeout) { + shouldThrow { model.generateContent("prompt") } + } + } + + @Test + fun `malformed content`() = + goldenVertexUnaryFile("unary-failure-malformed-content.json") { + withTimeout(testTimeout) { + shouldThrow { model.generateContent("prompt") } + } + } + + @Test + fun `invalid api key`() = + goldenVertexUnaryFile("unary-failure-api-key.json", HttpStatusCode.BadRequest) { + withTimeout(testTimeout) { + shouldThrow { model.generateContent("prompt") } + } + } + + @Test + fun `image rejected`() = + goldenVertexUnaryFile("unary-failure-image-rejected.json", HttpStatusCode.BadRequest) { + withTimeout(testTimeout) { shouldThrow { model.generateContent("prompt") } } + } + + @Test + fun `unknown model`() = + goldenVertexUnaryFile("unary-failure-unknown-model.json", HttpStatusCode.NotFound) { + withTimeout(testTimeout) { shouldThrow { model.generateContent("prompt") } } + } + + @Test + fun `service disabled`() = + goldenVertexUnaryFile( + "unary-failure-firebaseml-api-not-enabled.json", + HttpStatusCode.Forbidden + ) { + withTimeout(testTimeout) { + shouldThrow { model.generateContent("prompt") } + } + } + + @Test + fun `function call contains null param`() = + goldenVertexUnaryFile("unary-success-function-call-null.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + val callPart = (response.candidates.first().content.parts.first() as FunctionCallPart) + + callPart.args["season"] shouldBe JsonPrimitive(null) + } + } + + @Test + fun `function call contains json literal`() = + goldenVertexUnaryFile("unary-success-function-call-json-literal.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + val content = response.candidates.shouldNotBeNullOrEmpty().first().content + val callPart = + content.let { + it.shouldNotBeNull() + it.parts.shouldNotBeEmpty() + it.parts.first().shouldBeInstanceOf() + } + + callPart.args["current"] shouldBe JsonPrimitive(true) + } + } + + @Test + fun `function call with complex json literal parses correctly`() = + goldenVertexUnaryFile("unary-success-function-call-complex-json-literal.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + val content = response.candidates.shouldNotBeNullOrEmpty().first().content + val callPart = + content.let { + it.shouldNotBeNull() + it.parts.shouldNotBeEmpty() + it.parts.first().shouldBeInstanceOf() + } + + callPart.args["current"] shouldBe JsonPrimitive(true) + callPart.args["testObject"]!!.jsonObject["testProperty"]!!.jsonPrimitive.content shouldBe + "string property" + } + } + + @Test + fun `function call contains no arguments`() = + goldenVertexUnaryFile("unary-success-function-call-no-arguments.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + val callPart = response.functionCalls.shouldNotBeEmpty().first() + + callPart.name shouldBe "current_time" + callPart.args.isEmpty() shouldBe true + } + } + + @Test + fun `function call contains arguments`() = + goldenVertexUnaryFile("unary-success-function-call-with-arguments.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + val callPart = response.functionCalls.shouldNotBeEmpty().first() + + callPart.name shouldBe "sum" + callPart.args["x"] shouldBe JsonPrimitive(4) + callPart.args["y"] shouldBe JsonPrimitive(5) + } + } + + @Test + fun `function call with parallel calls`() = + goldenVertexUnaryFile("unary-success-function-call-parallel-calls.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + val callList = response.functionCalls + + callList.size shouldBe 3 + callList.forEach { + it.name shouldBe "sum" + it.args.size shouldBe 2 + } + } + } + + @Test + fun `function call with mixed content`() = + goldenVertexUnaryFile("unary-success-function-call-mixed-content.json") { + withTimeout(testTimeout) { + val response = model.generateContent("prompt") + val callList = response.functionCalls + + response.text shouldBe "The sum of [1, 2, 3] is" + callList.size shouldBe 2 + callList.forEach { it.args.size shouldBe 2 } + } + } + + @Test + fun `countTokens succeeds`() = + goldenVertexUnaryFile("unary-success-total-tokens.json") { + withTimeout(testTimeout) { + val response = model.countTokens("prompt") + + response.totalTokens shouldBe 6 + response.totalBillableCharacters shouldBe 16 + response.promptTokensDetails.isEmpty() shouldBe true + } + } + + @Test + fun `countTokens with modality fields returned`() = + goldenVertexUnaryFile("unary-success-detailed-token-response.json") { + withTimeout(testTimeout) { + val response = model.countTokens("prompt") + + response.totalTokens shouldBe 1837 + response.totalBillableCharacters shouldBe 117 + response.promptTokensDetails shouldNotBe null + response.promptTokensDetails?.forAtLeastOne { + it.modality shouldBe ContentModality.IMAGE + it.tokenCount shouldBe 1806 + } + } + } + + @Test + fun `countTokens succeeds with no billable characters`() = + goldenVertexUnaryFile("unary-success-no-billable-characters.json") { + withTimeout(testTimeout) { + val response = model.countTokens("prompt") + + response.totalTokens shouldBe 258 + response.totalBillableCharacters shouldBe 0 + } + } + + @Test + fun `countTokens fails with model not found`() = + goldenVertexUnaryFile("unary-failure-model-not-found.json", HttpStatusCode.NotFound) { + withTimeout(testTimeout) { shouldThrow { model.countTokens("prompt") } } + } + + @Test + fun `generateImages should throw when all images filtered`() = + goldenVertexUnaryFile("unary-failure-generate-images-all-filtered.json") { + withTimeout(testTimeout) { + shouldThrow { imagenModel.generateImages("prompt") } + } + } + + @Test + fun `generateImages should throw when prompt blocked`() = + goldenVertexUnaryFile( + "unary-failure-generate-images-prompt-blocked.json", + HttpStatusCode.BadRequest, + ) { + withTimeout(testTimeout) { + shouldThrow { imagenModel.generateImages("prompt") } + } + } +} diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/common/APIControllerTests.kt b/firebase-ai/src/test/java/com/google/firebase/ai/common/APIControllerTests.kt new file mode 100644 index 00000000000..b1ae69c25b2 --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/common/APIControllerTests.kt @@ -0,0 +1,435 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.common + +import com.google.firebase.FirebaseApp +import com.google.firebase.ai.BuildConfig +import com.google.firebase.ai.common.util.commonTest +import com.google.firebase.ai.common.util.createResponses +import com.google.firebase.ai.common.util.doBlocking +import com.google.firebase.ai.common.util.prepareStreamingResponse +import com.google.firebase.ai.type.Content +import com.google.firebase.ai.type.CountTokensResponse +import com.google.firebase.ai.type.FunctionCallingConfig +import com.google.firebase.ai.type.RequestOptions +import com.google.firebase.ai.type.TextPart +import com.google.firebase.ai.type.Tool +import com.google.firebase.ai.type.ToolConfig +import io.kotest.assertions.json.shouldContainJsonKey +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.content.TextContent +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.utils.io.ByteChannel +import io.ktor.utils.io.close +import io.ktor.utils.io.writeFully +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonObject +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.mockito.Mockito + +private val TEST_CLIENT_ID = "genai-android/test" + +private val TEST_APP_ID = "1:android:12345" + +private val TEST_VERSION = 1 + +internal class APIControllerTests { + private val testTimeout = 5.seconds + + @Test + fun `(generateContentStream) emits responses as they come in`() = commonTest { + val response = createResponses("The", " world", " is", " a", " beautiful", " place!") + val bytes = prepareStreamingResponse(response) + + bytes.forEach { channel.writeFully(it) } + val responses = apiController.generateContentStream(textGenerateContentRequest("test")) + + withTimeout(testTimeout) { + responses.collect { + it.candidates?.isEmpty() shouldBe false + channel.close() + } + } + } + + @Test + fun `(generateContent) respects a custom timeout`() = + commonTest(requestOptions = RequestOptions(2.seconds)) { + shouldThrow { + withTimeout(testTimeout) { + apiController.generateContent(textGenerateContentRequest("test")) + } + } + } +} + +@OptIn(ExperimentalSerializationApi::class) +internal class RequestFormatTests { + + private val mockFirebaseApp = Mockito.mock() + + @Before + fun setup() { + Mockito.`when`(mockFirebaseApp.isDataCollectionDefaultEnabled).thenReturn(false) + } + + @Test + fun `using default endpoint`() = doBlocking { + val channel = ByteChannel(autoFlush = true) + val mockEngine = MockEngine { + respond(channel, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + prepareStreamingResponse(createResponses("Random")).forEach { channel.writeFully(it) } + val controller = + APIController( + "super_cool_test_key", + "gemini-pro-1.5", + RequestOptions(), + mockEngine, + "genai-android/${BuildConfig.VERSION_NAME}", + mockFirebaseApp, + TEST_VERSION, + TEST_APP_ID, + null, + ) + + withTimeout(5.seconds) { + controller.generateContentStream(textGenerateContentRequest("cats")).collect { + it.candidates?.isEmpty() shouldBe false + channel.close() + } + } + + mockEngine.requestHistory.first().url.host shouldBe "firebasevertexai.googleapis.com" + } + + @Test + fun `using custom endpoint`() = doBlocking { + val channel = ByteChannel(autoFlush = true) + val mockEngine = MockEngine { + respond(channel, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + prepareStreamingResponse(createResponses("Random")).forEach { channel.writeFully(it) } + val controller = + APIController( + "super_cool_test_key", + "gemini-pro-1.5", + RequestOptions(timeout = 5.seconds, endpoint = "https://my.custom.endpoint"), + mockEngine, + TEST_CLIENT_ID, + mockFirebaseApp, + TEST_VERSION, + TEST_APP_ID, + null, + ) + + withTimeout(5.seconds) { + controller.generateContentStream(textGenerateContentRequest("cats")).collect { + it.candidates?.isEmpty() shouldBe false + channel.close() + } + } + + mockEngine.requestHistory.first().url.host shouldBe "my.custom.endpoint" + } + + @Test + fun `client id header is set correctly in the request`() = doBlocking { + val response = JSON.encodeToString(CountTokensResponse.Internal(totalTokens = 10)) + val mockEngine = MockEngine { + respond(response, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + + val controller = + APIController( + "super_cool_test_key", + "gemini-pro-1.5", + RequestOptions(), + mockEngine, + TEST_CLIENT_ID, + mockFirebaseApp, + TEST_VERSION, + TEST_APP_ID, + null, + ) + + withTimeout(5.seconds) { controller.countTokens(textCountTokenRequest("cats")) } + + mockEngine.requestHistory.first().headers["x-goog-api-client"] shouldBe TEST_CLIENT_ID + } + + @Test + fun `ml monitoring header is set correctly if data collection is enabled`() = doBlocking { + val response = JSON.encodeToString(CountTokensResponse.Internal(totalTokens = 10)) + val mockEngine = MockEngine { + respond(response, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + + Mockito.`when`(mockFirebaseApp.isDataCollectionDefaultEnabled).thenReturn(true) + + val controller = + APIController( + "super_cool_test_key", + "gemini-pro-1.5", + RequestOptions(), + mockEngine, + TEST_CLIENT_ID, + mockFirebaseApp, + TEST_VERSION, + TEST_APP_ID, + null, + ) + + withTimeout(5.seconds) { controller.countTokens(textCountTokenRequest("cats")) } + + mockEngine.requestHistory.first().headers["X-Firebase-AppId"] shouldBe TEST_APP_ID + mockEngine.requestHistory.first().headers["X-Firebase-AppVersion"] shouldBe + TEST_VERSION.toString() + } + + @Test + fun `ToolConfig serialization contains correct keys`() = doBlocking { + val channel = ByteChannel(autoFlush = true) + val mockEngine = MockEngine { + respond(channel, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + prepareStreamingResponse(createResponses("Random")).forEach { channel.writeFully(it) } + + val controller = + APIController( + "super_cool_test_key", + "gemini-pro-1.5", + RequestOptions(), + mockEngine, + TEST_CLIENT_ID, + mockFirebaseApp, + TEST_VERSION, + TEST_APP_ID, + null, + ) + + withTimeout(5.seconds) { + controller + .generateContentStream( + GenerateContentRequest( + model = "unused", + contents = listOf(Content.Internal(parts = listOf(TextPart.Internal("Arbitrary")))), + toolConfig = + ToolConfig.Internal( + FunctionCallingConfig.Internal( + mode = FunctionCallingConfig.Internal.Mode.ANY, + allowedFunctionNames = listOf("allowedFunctionName") + ) + ) + ), + ) + .collect { channel.close() } + } + + val requestBodyAsText = (mockEngine.requestHistory.first().body as TextContent).text + + requestBodyAsText shouldContainJsonKey "tool_config.function_calling_config.mode" + requestBodyAsText shouldContainJsonKey + "tool_config.function_calling_config.allowed_function_names" + } + + @Test + fun `headers from HeaderProvider are added to the request`() = doBlocking { + val response = JSON.encodeToString(CountTokensResponse.Internal(totalTokens = 10)) + val mockEngine = MockEngine { + respond(response, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + + val testHeaderProvider = + object : HeaderProvider { + override val timeout: Duration + get() = 5.seconds + + override suspend fun generateHeaders(): Map = + mapOf("header1" to "value1", "header2" to "value2") + } + + val controller = + APIController( + "super_cool_test_key", + "gemini-pro-1.5", + RequestOptions(), + mockEngine, + TEST_CLIENT_ID, + mockFirebaseApp, + TEST_VERSION, + TEST_APP_ID, + testHeaderProvider, + ) + + withTimeout(5.seconds) { controller.countTokens(textCountTokenRequest("cats")) } + + mockEngine.requestHistory.first().headers["header1"] shouldBe "value1" + mockEngine.requestHistory.first().headers["header2"] shouldBe "value2" + } + + @Test + fun `headers from HeaderProvider are ignored if timeout`() = doBlocking { + val response = JSON.encodeToString(CountTokensResponse.Internal(totalTokens = 10)) + val mockEngine = MockEngine { + respond(response, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + + val testHeaderProvider = + object : HeaderProvider { + override val timeout: Duration + get() = 5.milliseconds + + override suspend fun generateHeaders(): Map { + delay(10.milliseconds) + return mapOf("header1" to "value1") + } + } + + val controller = + APIController( + "super_cool_test_key", + "gemini-pro-1.5", + RequestOptions(), + mockEngine, + TEST_CLIENT_ID, + mockFirebaseApp, + TEST_VERSION, + TEST_APP_ID, + testHeaderProvider, + ) + + withTimeout(5.seconds) { controller.countTokens(textCountTokenRequest("cats")) } + + mockEngine.requestHistory.first().headers.contains("header1") shouldBe false + } + + @Test + fun `code execution tool serialization contains correct keys`() = doBlocking { + val channel = ByteChannel(autoFlush = true) + val mockEngine = MockEngine { + respond(channel, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + prepareStreamingResponse(createResponses("Random")).forEach { channel.writeFully(it) } + + val controller = + APIController( + "super_cool_test_key", + "gemini-pro-1.5", + RequestOptions(), + mockEngine, + TEST_CLIENT_ID, + mockFirebaseApp, + TEST_VERSION, + TEST_APP_ID, + null, + ) + + withTimeout(5.seconds) { + controller + .generateContentStream( + GenerateContentRequest( + model = "unused", + contents = listOf(Content.Internal(parts = listOf(TextPart.Internal("Arbitrary")))), + tools = listOf(Tool.Internal(codeExecution = JsonObject(emptyMap()))), + ) + ) + .collect { channel.close() } + } + + val requestBodyAsText = (mockEngine.requestHistory.first().body as TextContent).text + + requestBodyAsText shouldContainJsonKey "tools[0].codeExecution" + } +} + +@RunWith(Parameterized::class) +internal class ModelNamingTests(private val modelName: String, private val actualName: String) { + private val mockFirebaseApp = Mockito.mock() + + @Before + fun setup() { + Mockito.`when`(mockFirebaseApp.isDataCollectionDefaultEnabled).thenReturn(false) + } + + @Test + fun `request should include right model name`() = doBlocking { + val channel = ByteChannel(autoFlush = true) + val mockEngine = MockEngine { + respond(channel, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + prepareStreamingResponse(createResponses("Random")).forEach { channel.writeFully(it) } + val controller = + APIController( + "super_cool_test_key", + modelName, + RequestOptions(), + mockEngine, + TEST_CLIENT_ID, + mockFirebaseApp, + TEST_VERSION, + TEST_APP_ID, + null, + ) + + withTimeout(5.seconds) { + controller.generateContentStream(textGenerateContentRequest("cats")).collect { + it.candidates?.isEmpty() shouldBe false + channel.close() + } + } + + mockEngine.requestHistory.first().url.encodedPath shouldContain actualName + } + + companion object { + @JvmStatic + @Parameterized.Parameters + fun data() = + listOf( + arrayOf("gemini-pro", "models/gemini-pro"), + arrayOf("x/gemini-pro", "x/gemini-pro"), + arrayOf("models/gemini-pro", "models/gemini-pro"), + arrayOf("/modelname", "/modelname"), + arrayOf("modifiedNaming/mymodel", "modifiedNaming/mymodel"), + ) + } +} + +internal fun textGenerateContentRequest(prompt: String) = + GenerateContentRequest( + model = "unused", + contents = listOf(Content.Internal(parts = listOf(TextPart.Internal(prompt)))), + ) + +internal fun textCountTokenRequest(prompt: String) = + CountTokensRequest(generateContentRequest = textGenerateContentRequest(prompt)) diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/common/EnumUpdateTests.kt b/firebase-ai/src/test/java/com/google/firebase/ai/common/EnumUpdateTests.kt new file mode 100644 index 00000000000..dea94317743 --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/common/EnumUpdateTests.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.common + +import com.google.firebase.ai.type.HarmBlockMethod +import com.google.firebase.ai.type.HarmBlockThreshold +import com.google.firebase.ai.type.HarmCategory +import org.junit.Test + +/** + * Fetches all the `@JvmStatic` properties of a class that are instances of the class itself. + * + * For example, given the following class: + * ```kt + * public class HarmCategory private constructor(public val ordinal: Int) { + * public companion object { + * @JvmField public val UNKNOWN: HarmCategory = HarmCategory(0) + * @JvmField public val HARASSMENT: HarmCategory = HarmCategory(1) + * } + * } + * ``` + * This function will yield: + * ```kt + * [UNKNOWN, HARASSMENT] + * ``` + */ +internal inline fun getEnumValues(): List { + return T::class + .java + .declaredFields + .filter { it.type == T::class.java } + .mapNotNull { it.get(null) as? T } +} + +/** + * Ensures that whenever any of our "pseudo-enums" are updated, that the conversion layer is also + * updated. + */ +internal class EnumUpdateTests { + @Test + fun `HarmCategory#toInternal() covers all values`() { + val values = getEnumValues() + values.forEach { it.toInternal() } + } + + @Test + fun `HarmBlockMethod#toInternal() covers all values`() { + val values = getEnumValues() + values.forEach { it.toInternal() } + } + + @Test + fun `HarmBlockThreshold#toInternal() covers all values`() { + val values = getEnumValues() + values.forEach { it.toInternal() } + } +} diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/common/util/descriptorToJson.kt b/firebase-ai/src/test/java/com/google/firebase/ai/common/util/descriptorToJson.kt new file mode 100644 index 00000000000..797c4587665 --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/common/util/descriptorToJson.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.common.util + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.SerialKind +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.descriptors.elementDescriptors +import kotlinx.serialization.descriptors.elementNames +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +/** + * Returns a [JsonObject] representing the classes in the hierarchy of a serialization [descriptor]. + * + * The format of the JSON object is similar to that of a Discovery Document, but restricted to these + * fields: + * - id + * - type + * - properties + * - items + * - $ref + * + * @param descriptor The [SerialDescriptor] to process. + */ +@OptIn(ExperimentalSerializationApi::class) +internal fun descriptorToJson(descriptor: SerialDescriptor): JsonObject { + return buildJsonObject { + put("id", simpleNameFromSerialName(descriptor.serialName)) + put("type", typeNameFromKind(descriptor.kind)) + if (descriptor.kind != StructureKind.CLASS) { + throw UnsupportedOperationException("Only classes can be serialized to JSON for now.") + } + // For top-level enums, add them directly. + if (descriptor.serialName == "FirstOrdinalSerializer") { + addEnumDescription(descriptor) + } else { + addObjectProperties(descriptor) + } + } +} + +@OptIn(ExperimentalSerializationApi::class) +internal fun JsonObjectBuilder.addListDescription(descriptor: SerialDescriptor) = + putJsonObject("items") { + val itemDescriptor = descriptor.elementDescriptors.first() + val nestedIsPrimitive = (descriptor.elementsCount == 1 && itemDescriptor.kind is PrimitiveKind) + if (nestedIsPrimitive) { + put("type", typeNameFromKind(itemDescriptor.kind)) + } else { + put("\$ref", simpleNameFromSerialName(itemDescriptor.serialName)) + } + } + +@OptIn(ExperimentalSerializationApi::class) +internal fun JsonObjectBuilder.addEnumDescription(descriptor: SerialDescriptor): JsonElement? { + put("type", typeNameFromKind(SerialKind.ENUM)) + return put("enum", JsonArray(descriptor.elementNames.map { JsonPrimitive(it) })) +} + +@OptIn(ExperimentalSerializationApi::class) +internal fun JsonObjectBuilder.addObjectProperties(descriptor: SerialDescriptor): JsonElement? { + return putJsonObject("properties") { + for (i in 0 until descriptor.elementsCount) { + val elementDescriptor = descriptor.getElementDescriptor(i) + val elementName = descriptor.getElementName(i) + putJsonObject(elementName) { + when (elementDescriptor.kind) { + StructureKind.LIST -> { + put("type", typeNameFromKind(elementDescriptor.kind)) + addListDescription(elementDescriptor) + } + StructureKind.CLASS -> { + if (elementDescriptor.serialName.startsWith("FirstOrdinalSerializer")) { + addEnumDescription(elementDescriptor) + } else { + put("\$ref", simpleNameFromSerialName(elementDescriptor.serialName)) + } + } + StructureKind.MAP -> { + put("type", typeNameFromKind(elementDescriptor.kind)) + putJsonObject("additionalProperties") { + put( + "\$ref", + simpleNameFromSerialName(elementDescriptor.getElementDescriptor(1).serialName) + ) + } + } + else -> { + put("type", typeNameFromKind(elementDescriptor.kind)) + } + } + } + } + } +} + +@OptIn(ExperimentalSerializationApi::class) +internal fun typeNameFromKind(kind: SerialKind): String { + return when (kind) { + PrimitiveKind.BOOLEAN -> "boolean" + PrimitiveKind.BYTE -> "integer" + PrimitiveKind.CHAR -> "string" + PrimitiveKind.DOUBLE -> "number" + PrimitiveKind.FLOAT -> "number" + PrimitiveKind.INT -> "integer" + PrimitiveKind.LONG -> "integer" + PrimitiveKind.SHORT -> "integer" + PrimitiveKind.STRING -> "string" + StructureKind.CLASS -> "object" + StructureKind.LIST -> "array" + SerialKind.ENUM -> "string" + StructureKind.MAP -> "object" + /* Only add new cases if they show up in actual test scenarios. */ + else -> TODO() + } +} + +/** + * Extracts the name expected for a class from its serial name. + * + * Our serialization classes are nested within the public-facing classes, and that's the name we + * want in the json output. There are two class names + * + * - `com.google.firebase.ai.type.Content.Internal` for regular scenarios + * - `com.google.firebase.ai.type.Content.Internal.SomeClass` for nested classes in the serializer. + * + * For the later time we need the second to last component, for the former we need the last + * component. + * + * Additionally, given that types can be nullable, we need to strip the `?` from the end of the + * name. + */ +internal fun simpleNameFromSerialName(serialName: String): String = + serialName + .split(".") + .let { + if (it.last().startsWith("Internal")) { + it[it.size - 2] + } else { + it.last() + } + } + .replace("?", "") diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/common/util/kotlin.kt b/firebase-ai/src/test/java/com/google/firebase/ai/common/util/kotlin.kt new file mode 100644 index 00000000000..5187607cc3b --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/common/util/kotlin.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.common.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking + +/** + * Runs the given [block] using [runBlocking] on the current thread for side effect. + * + * Using this function is like [runBlocking] with default context (which runs the given block on the + * calling thread) but forces the return type to be `Unit`, which is helpful when implementing + * suspending tests as expression functions: + * ``` + * @Test + * fun myTest() = doBlocking {...} + * ``` + */ +internal fun doBlocking(block: suspend CoroutineScope.() -> Unit) { + runBlocking(block = block) +} diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/common/util/tests.kt b/firebase-ai/src/test/java/com/google/firebase/ai/common/util/tests.kt new file mode 100644 index 00000000000..6cc501cedd5 --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/common/util/tests.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("DEPRECATION") // a replacement for our purposes has not been published yet + +package com.google.firebase.ai.common.util + +import com.google.firebase.FirebaseApp +import com.google.firebase.ai.common.APIController +import com.google.firebase.ai.common.JSON +import com.google.firebase.ai.type.Candidate +import com.google.firebase.ai.type.Content +import com.google.firebase.ai.type.GenerateContentResponse +import com.google.firebase.ai.type.RequestOptions +import com.google.firebase.ai.type.TextPart +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.utils.io.ByteChannel +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encodeToString +import org.mockito.Mockito + +private val TEST_CLIENT_ID = "genai-android/test" +private val TEST_APP_ID = "1:android:12345" +private val TEST_VERSION = 1 + +internal fun prepareStreamingResponse( + response: List +): List = response.map { "data: ${JSON.encodeToString(it)}$SSE_SEPARATOR".toByteArray() } + +@OptIn(ExperimentalSerializationApi::class) +internal fun createResponses(vararg text: String): List { + val candidates = + text.map { Candidate.Internal(Content.Internal(parts = listOf(TextPart.Internal(it)))) } + + return candidates.map { GenerateContentResponse.Internal(candidates = listOf(it)) } +} + +/** + * Wrapper around common instances needed in tests. + * + * @param channel A [ByteChannel] for sending responses through the mock HTTP engine + * @param apiController A [APIController] that consumes the [channel] + * @see commonTest + * @see send + */ +internal data class CommonTestScope(val channel: ByteChannel, val apiController: APIController) + +/** A test that runs under a [CommonTestScope]. */ +internal typealias CommonTest = suspend CommonTestScope.() -> Unit + +/** + * Common test block for providing a [CommonTestScope] during tests. + * + * Example usage: + * ``` + * @Test + * fun `(generateContent) generates a proper response`() = commonTest { + * val request = createRequest("say something nice") + * val response = createResponse("The world is a beautiful place!") + * + * channel.send(prepareResponse(response)) + * + * withTimeout(testTimeout) { + * val data = controller.generateContent(request) + * data.candidates.shouldNotBeEmpty() + * } + * } + * ``` + * + * @param status An optional [HttpStatusCode] to return as a response + * @param requestOptions Optional [RequestOptions] to utilize in the underlying controller + * @param block The test contents themselves, with the [CommonTestScope] implicitly provided + * @see CommonTestScope + */ +internal fun commonTest( + status: HttpStatusCode = HttpStatusCode.OK, + requestOptions: RequestOptions = RequestOptions(), + block: CommonTest, +) = doBlocking { + val mockFirebaseApp = Mockito.mock() + Mockito.`when`(mockFirebaseApp.isDataCollectionDefaultEnabled).thenReturn(false) + + val channel = ByteChannel(autoFlush = true) + val apiController = + APIController( + "super_cool_test_key", + "gemini-pro", + requestOptions, + MockEngine { + respond(channel, status, headersOf(HttpHeaders.ContentType, "application/json")) + }, + TEST_CLIENT_ID, + mockFirebaseApp, + TEST_VERSION, + TEST_APP_ID, + null, + ) + CommonTestScope(channel, apiController).block() +} diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/type/FunctionDeclarationTest.kt b/firebase-ai/src/test/java/com/google/firebase/ai/type/FunctionDeclarationTest.kt new file mode 100644 index 00000000000..7719044b498 --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/type/FunctionDeclarationTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import io.kotest.assertions.json.shouldEqualJson +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Test + +internal class FunctionDeclarationTest { + + @Test + fun `Basic FunctionDeclaration with name, description and parameters`() { + val functionDeclaration = + FunctionDeclaration( + name = "isUserAGoat", + description = "Determine if the user is subject to teleportations.", + parameters = mapOf("userID" to Schema.string("ID of the User making the call")) + ) + + val expectedJson = + """ + { + "name": "isUserAGoat", + "description": "Determine if the user is subject to teleportations.", + "parameters": { + "type": "OBJECT", + "properties": { + "userID": { + "type": "STRING", + "description": "ID of the User making the call" + } + }, + "required": [ + "userID" + ] + } + } + """ + .trimIndent() + + Json.encodeToString(functionDeclaration.toInternal()).shouldEqualJson(expectedJson) + } + + @Test + fun `FunctionDeclaration with optional parameters`() { + val functionDeclaration = + FunctionDeclaration( + name = "isUserAGoat", + description = "Determine if the user is subject to teleportations.", + parameters = + mapOf( + "userID" to Schema.string("ID of the user making the call"), + "userName" to Schema.string("Name of the user making the call") + ), + optionalParameters = listOf("userName") + ) + + val expectedJson = + """ + { + "name": "isUserAGoat", + "description": "Determine if the user is subject to teleportations.", + "parameters": { + "type": "OBJECT", + "properties": { + "userID": { + "type": "STRING", + "description": "ID of the user making the call" + }, + "userName": { + "type": "STRING", + "description": "Name of the user making the call" + } + }, + "required": [ + "userID" + ] + } + } + """ + .trimIndent() + + Json.encodeToString(functionDeclaration.toInternal()).shouldEqualJson(expectedJson) + } +} diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/util/kotlin.kt b/firebase-ai/src/test/java/com/google/firebase/ai/util/kotlin.kt new file mode 100644 index 00000000000..6726923f7bf --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/util/kotlin.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking + +/** + * Runs the given [block] using [runBlocking] on the current thread for side effect. + * + * Using this function is like [runBlocking] with default context (which runs the given block on the + * calling thread) but forces the return type to be `Unit`, which is helpful when implementing + * suspending tests as expression functions: + * ``` + * @Test + * fun myTest() = doBlocking {...} + * ``` + */ +internal fun doBlocking(block: suspend CoroutineScope.() -> Unit) { + runBlocking(block = block) +} diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/util/tests.kt b/firebase-ai/src/test/java/com/google/firebase/ai/util/tests.kt new file mode 100644 index 00000000000..393a2a16adc --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/util/tests.kt @@ -0,0 +1,276 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(PublicPreviewAPI::class) + +package com.google.firebase.ai.util + +import com.google.firebase.FirebaseApp +import com.google.firebase.ai.GenerativeModel +import com.google.firebase.ai.ImagenModel +import com.google.firebase.ai.common.APIController +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.RequestOptions +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.nulls.shouldNotBeNull +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.utils.io.ByteChannel +import io.ktor.utils.io.close +import io.ktor.utils.io.writeFully +import java.io.File +import kotlinx.coroutines.launch +import org.mockito.Mockito + +private val TEST_CLIENT_ID = "firebase-ai-android/test" +private val TEST_APP_ID = "1:android:12345" +private val TEST_VERSION = 1 + +/** String separator used in SSE communication to signal the end of a message. */ +internal const val SSE_SEPARATOR = "\r\n\r\n" + +/** + * Writes the provided [bytes] to the channel and closes it. + * + * Just a wrapper around [writeFully] that closes the channel after writing is complete. + * + * @param bytes the data to send through the channel + */ +internal suspend fun ByteChannel.send(bytes: ByteArray) { + writeFully(bytes) + close() +} + +/** + * Wrapper around common instances needed in tests. + * + * @param channel A [ByteChannel] for sending responses through the mock HTTP engine + * @param apiController A [APIController] that consumes the [channel] + * @see commonTest + * @see send + */ +internal data class CommonTestScope( + val channel: ByteChannel, + val model: GenerativeModel, + val imagenModel: ImagenModel, +) + +/** A test that runs under a [CommonTestScope]. */ +internal typealias CommonTest = suspend CommonTestScope.() -> Unit + +/** + * Common test block for providing a [CommonTestScope] during tests. + * + * Example usage: + * ``` + * @Test + * fun `(generateContent) generates a proper response`() = commonTest { + * val request = createRequest("say something nice") + * val response = createResponse("The world is a beautiful place!") + * + * channel.send(prepareResponse(response)) + * + * withTimeout(testTimeout) { + * val data = controller.generateContent(request) + * data.candidates.shouldNotBeEmpty() + * } + * } + * ``` + * + * @param status An optional [HttpStatusCode] to return as a response + * @param requestOptions Optional [RequestOptions] to utilize in the underlying controller + * @param block The test contents themselves, with the [CommonTestScope] implicitly provided + * @see CommonTestScope + */ +internal fun commonTest( + status: HttpStatusCode = HttpStatusCode.OK, + requestOptions: RequestOptions = RequestOptions(), + backend: GenerativeBackend = GenerativeBackend.vertexAI(), + block: CommonTest, +) = doBlocking { + val channel = ByteChannel(autoFlush = true) + val mockFirebaseApp = Mockito.mock() + Mockito.`when`(mockFirebaseApp.isDataCollectionDefaultEnabled).thenReturn(false) + + val apiController = + APIController( + "super_cool_test_key", + "gemini-pro", + requestOptions, + MockEngine { + respond(channel, status, headersOf(HttpHeaders.ContentType, "application/json")) + }, + TEST_CLIENT_ID, + mockFirebaseApp, + TEST_VERSION, + TEST_APP_ID, + null, + ) + val model = + GenerativeModel("cool-model-name", generativeBackend = backend, controller = apiController) + val imagenModel = ImagenModel("cooler-model-name", controller = apiController) + CommonTestScope(channel, model, imagenModel).block() +} + +/** + * A variant of [commonTest] for performing *streaming-based* snapshot tests. + * + * Loads the *Golden File* and automatically parses the messages from it; providing it to the + * channel. + * + * @param name The name of the *Golden File* to load + * @param httpStatusCode An optional [HttpStatusCode] to return as a response + * @param block The test contents themselves, with a [CommonTestScope] implicitly provided + * @see goldenVertexUnaryFile + */ +internal fun goldenStreamingFile( + name: String, + httpStatusCode: HttpStatusCode = HttpStatusCode.OK, + backend: GenerativeBackend = GenerativeBackend.vertexAI(), + block: CommonTest, +) = doBlocking { + val goldenFile = loadGoldenFile(name) + val messages = goldenFile.readLines().filter { it.isNotBlank() } + + commonTest(httpStatusCode, backend = backend) { + launch { + for (message in messages) { + channel.writeFully("$message$SSE_SEPARATOR".toByteArray()) + } + channel.close() + } + + block() + } +} + +/** + * A variant of [goldenStreamingFile] for testing vertexAI + * + * Loads the *Golden File* and automatically parses the messages from it; providing it to the + * channel. + * + * @param name The name of the *Golden File* to load + * @param httpStatusCode An optional [HttpStatusCode] to return as a response + * @param block The test contents themselves, with a [CommonTestScope] implicitly provided + * @see goldenStreamingFile + */ +internal fun goldenVertexStreamingFile( + name: String, + httpStatusCode: HttpStatusCode = HttpStatusCode.OK, + block: CommonTest, +) = goldenStreamingFile("vertexai/$name", httpStatusCode, block = block) + +/** + * A variant of [goldenStreamingFile] for testing the developer api + * + * Loads the *Golden File* and automatically parses the messages from it; providing it to the + * channel. + * + * @param name The name of the *Golden File* to load + * @param httpStatusCode An optional [HttpStatusCode] to return as a response + * @param block The test contents themselves, with a [CommonTestScope] implicitly provided + * @see goldenStreamingFile + */ +internal fun goldenDevAPIStreamingFile( + name: String, + httpStatusCode: HttpStatusCode = HttpStatusCode.OK, + block: CommonTest, +) = goldenStreamingFile("googleai/$name", httpStatusCode, GenerativeBackend.googleAI(), block) + +/** + * A variant of [commonTest] for performing snapshot tests. + * + * Loads the *Golden File* and automatically provides it to the channel. + * + * @param name The name of the *Golden File* to load + * @param httpStatusCode An optional [HttpStatusCode] to return as a response + * @param block The test contents themselves, with a [CommonTestScope] implicitly provided + * @see goldenStreamingFile + */ +internal fun goldenUnaryFile( + name: String, + httpStatusCode: HttpStatusCode = HttpStatusCode.OK, + backend: GenerativeBackend = GenerativeBackend.vertexAI(), + block: CommonTest, +) = doBlocking { + commonTest(httpStatusCode, backend = backend) { + val goldenFile = loadGoldenFile(name) + val message = goldenFile.readText() + + launch { channel.send(message.toByteArray()) } + + block() + } +} + +/** + * A variant of [goldenUnaryFile] for vertexai tests Loads the *Golden File* and automatically + * provides it to the channel. + * + * @param name The name of the *Golden File* to load + * @param httpStatusCode An optional [HttpStatusCode] to return as a response + * @param block The test contents themselves, with a [CommonTestScope] implicitly provided + * @see goldenUnaryFile + */ +internal fun goldenVertexUnaryFile( + name: String, + httpStatusCode: HttpStatusCode = HttpStatusCode.OK, + block: CommonTest, +) = goldenUnaryFile("vertexai/$name", httpStatusCode, block = block) + +/** + * A variant of [goldenUnaryFile] for developer api tests Loads the *Golden File* and automatically + * provides it to the channel. + * + * @param name The name of the *Golden File* to load + * @param httpStatusCode An optional [HttpStatusCode] to return as a response + * @param block The test contents themselves, with a [CommonTestScope] implicitly provided + * @see goldenUnaryFile + */ +internal fun goldenDevAPIUnaryFile( + name: String, + httpStatusCode: HttpStatusCode = HttpStatusCode.OK, + block: CommonTest, +) = goldenUnaryFile("googleai/$name", httpStatusCode, GenerativeBackend.googleAI(), block) + +/** + * Loads a *Golden File* from the resource directory. + * + * Expects golden files to live under `golden-files` in the resource files. + * + * @see goldenUnaryFile + */ +internal fun loadGoldenFile(path: String): File = + loadResourceFile("vertexai-sdk-test-data/mock-responses/$path") + +/** Loads a file from the test resources directory. */ +internal fun loadResourceFile(path: String) = File("src/test/resources/$path") + +/** + * Ensures that a collection is neither null or empty. + * + * Syntax sugar for [shouldNotBeNull] and [shouldNotBeEmpty]. + */ +inline fun Collection?.shouldNotBeNullOrEmpty(): Collection { + shouldNotBeNull() + shouldNotBeEmpty() + return this +} diff --git a/firebase-ai/src/test/resources/README.md b/firebase-ai/src/test/resources/README.md new file mode 100644 index 00000000000..372846e739d --- /dev/null +++ b/firebase-ai/src/test/resources/README.md @@ -0,0 +1,2 @@ +Mock response files should be cloned into this directory to run unit tests. See +the Firebase AI [README](../../..#running-tests) for instructions. \ No newline at end of file diff --git a/firebase-ai/src/testUtil/java/com/google/firebase/ai/JavaCompileTests.java b/firebase-ai/src/testUtil/java/com/google/firebase/ai/JavaCompileTests.java new file mode 100644 index 00000000000..559c4ac8a04 --- /dev/null +++ b/firebase-ai/src/testUtil/java/com/google/firebase/ai/JavaCompileTests.java @@ -0,0 +1,379 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package java.com.google.firebase.ai; + +import android.graphics.Bitmap; +import androidx.annotation.Nullable; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.firebase.ai.FirebaseAI; +import com.google.firebase.ai.GenerativeModel; +import com.google.firebase.ai.LiveGenerativeModel; +import com.google.firebase.ai.java.ChatFutures; +import com.google.firebase.ai.java.GenerativeModelFutures; +import com.google.firebase.ai.java.LiveModelFutures; +import com.google.firebase.ai.java.LiveSessionFutures; +import com.google.firebase.ai.type.BlockReason; +import com.google.firebase.ai.type.Candidate; +import com.google.firebase.ai.type.Citation; +import com.google.firebase.ai.type.CitationMetadata; +import com.google.firebase.ai.type.Content; +import com.google.firebase.ai.type.ContentModality; +import com.google.firebase.ai.type.CountTokensResponse; +import com.google.firebase.ai.type.FileDataPart; +import com.google.firebase.ai.type.FinishReason; +import com.google.firebase.ai.type.FunctionCallPart; +import com.google.firebase.ai.type.FunctionResponsePart; +import com.google.firebase.ai.type.GenerateContentResponse; +import com.google.firebase.ai.type.GenerationConfig; +import com.google.firebase.ai.type.HarmCategory; +import com.google.firebase.ai.type.HarmProbability; +import com.google.firebase.ai.type.HarmSeverity; +import com.google.firebase.ai.type.ImagePart; +import com.google.firebase.ai.type.InlineDataPart; +import com.google.firebase.ai.type.LiveGenerationConfig; +import com.google.firebase.ai.type.LiveServerContent; +import com.google.firebase.ai.type.LiveServerMessage; +import com.google.firebase.ai.type.LiveServerSetupComplete; +import com.google.firebase.ai.type.LiveServerToolCall; +import com.google.firebase.ai.type.LiveServerToolCallCancellation; +import com.google.firebase.ai.type.MediaData; +import com.google.firebase.ai.type.ModalityTokenCount; +import com.google.firebase.ai.type.Part; +import com.google.firebase.ai.type.PromptFeedback; +import com.google.firebase.ai.type.PublicPreviewAPI; +import com.google.firebase.ai.type.ResponseModality; +import com.google.firebase.ai.type.SafetyRating; +import com.google.firebase.ai.type.Schema; +import com.google.firebase.ai.type.SpeechConfig; +import com.google.firebase.ai.type.TextPart; +import com.google.firebase.ai.type.UsageMetadata; +import com.google.firebase.ai.type.Voice; +import com.google.firebase.concurrent.FirebaseExecutors; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; +import kotlin.OptIn; +import kotlinx.serialization.json.JsonElement; +import kotlinx.serialization.json.JsonNull; +import kotlinx.serialization.json.JsonObject; +import org.junit.Assert; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +/** + * Tests in this file exist to be compiled, not invoked + */ +@OptIn(markerClass = PublicPreviewAPI.class) +public class JavaCompileTests { + + public void initializeJava() throws Exception { + FirebaseAI vertex = FirebaseAI.getInstance(); + GenerativeModel model = vertex.generativeModel("fake-model-name", getConfig()); + LiveGenerativeModel live = vertex.liveModel("fake-model-name", getLiveConfig()); + GenerativeModelFutures futures = GenerativeModelFutures.from(model); + LiveModelFutures liveFutures = LiveModelFutures.from(live); + testFutures(futures); + testLiveFutures(liveFutures); + } + + private GenerationConfig getConfig() { + return new GenerationConfig.Builder() + .setTopK(10) + .setTopP(11.0F) + .setTemperature(32.0F) + .setCandidateCount(1) + .setMaxOutputTokens(0xCAFEBABE) + .setFrequencyPenalty(1.0F) + .setPresencePenalty(2.0F) + .setStopSequences(List.of("foo", "bar")) + .setResponseMimeType("image/jxl") + .setResponseModalities(List.of(ResponseModality.TEXT, ResponseModality.TEXT)) + .setResponseSchema(getSchema()) + .build(); + } + + private Schema getSchema() { + return Schema.obj( + Map.of( + "foo", Schema.numInt(), + "bar", Schema.numInt("Some integer"), + "baz", Schema.numInt("Some integer", false), + "qux", Schema.numDouble(), + "quux", Schema.numFloat("Some floating point number"), + "xyzzy", Schema.array(Schema.numInt(), "A list of integers"), + "fee", Schema.numLong(), + "ber", + Schema.obj( + Map.of( + "bez", Schema.array(Schema.numDouble("Nullable double", true)), + "qez", Schema.enumeration(List.of("A", "B", "C"), "One of 3 letters"), + "qeez", Schema.str("A funny string"))))); + } + + private LiveGenerationConfig getLiveConfig() { + return new LiveGenerationConfig.Builder() + .setTopK(10) + .setTopP(11.0F) + .setTemperature(32.0F) + .setCandidateCount(1) + .setMaxOutputTokens(0xCAFEBABE) + .setFrequencyPenalty(1.0F) + .setPresencePenalty(2.0F) + .setResponseModality(ResponseModality.AUDIO) + .setSpeechConfig(new SpeechConfig(new Voice("AOEDE"))) + .build(); + } + + private void testFutures(GenerativeModelFutures futures) throws Exception { + Content content = + new Content.Builder() + .setParts(new ArrayList<>()) + .addText("Fake prompt") + .addFileData("fakeuri", "image/png") + .addInlineData(new byte[] {}, "text/json") + .addImage(Bitmap.createBitmap(0, 0, Bitmap.Config.HARDWARE)) + .addPart(new FunctionCallPart("fakeFunction", Map.of("fakeArg", JsonNull.INSTANCE))) + .setRole("user") + .build(); + Executor executor = FirebaseExecutors.directExecutor(); + ListenableFuture countResponse = futures.countTokens(content); + validateCountTokensResponse(countResponse.get()); + ListenableFuture generateResponse = futures.generateContent(content); + validateGenerateContentResponse(generateResponse.get()); + ChatFutures chat = futures.startChat(); + ListenableFuture future = chat.sendMessage(content); + future.addListener( + () -> { + try { + validateGenerateContentResponse(future.get()); + } catch (Exception e) { + // Ignore + } + }, + executor); + Publisher responsePublisher = futures.generateContentStream(content); + responsePublisher.subscribe( + new Subscriber() { + private boolean complete = false; + + @Override + public void onSubscribe(Subscription s) { + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(GenerateContentResponse response) { + Assert.assertFalse(complete); + validateGenerateContentResponse(response); + } + + @Override + public void onError(Throwable t) { + // Ignore + } + + @Override + public void onComplete() { + complete = true; + } + }); + } + + public void validateCountTokensResponse(CountTokensResponse response) { + int tokens = response.getTotalTokens(); + Integer billable = response.getTotalBillableCharacters(); + Assert.assertEquals(tokens, response.component1()); + Assert.assertEquals(billable, response.component2()); + Assert.assertEquals(response.getPromptTokensDetails(), response.component3()); + for (ModalityTokenCount count : response.getPromptTokensDetails()) { + ContentModality modality = count.getModality(); + int tokenCount = count.getTokenCount(); + } + } + + public void validateGenerateContentResponse(GenerateContentResponse response) { + List candidates = response.getCandidates(); + if (candidates.size() == 1 + && candidates.get(0).getContent().getParts().stream() + .anyMatch(p -> p instanceof TextPart && !((TextPart) p).getText().isEmpty())) { + String text = response.getText(); + Assert.assertNotNull(text); + Assert.assertFalse(text.isBlank()); + } + validateCandidates(candidates); + validateFunctionCalls(response.getFunctionCalls()); + validatePromptFeedback(response.getPromptFeedback()); + validateUsageMetadata(response.getUsageMetadata()); + } + + public void validateCandidates(List candidates) { + for (Candidate candidate : candidates) { + validateCitationMetadata(candidate.getCitationMetadata()); + FinishReason reason = candidate.getFinishReason(); + validateSafetyRatings(candidate.getSafetyRatings()); + validateCitationMetadata(candidate.getCitationMetadata()); + validateContent(candidate.getContent()); + } + } + + public void validateContent(@Nullable Content content) { + if (content == null) { + return; + } + String role = content.getRole(); + for (Part part : content.getParts()) { + if (part instanceof TextPart) { + String text = ((TextPart) part).getText(); + } else if (part instanceof ImagePart) { + Bitmap bitmap = ((ImagePart) part).getImage(); + } else if (part instanceof InlineDataPart) { + String mime = ((InlineDataPart) part).getMimeType(); + byte[] data = ((InlineDataPart) part).getInlineData(); + } else if (part instanceof FileDataPart) { + String mime = ((FileDataPart) part).getMimeType(); + String uri = ((FileDataPart) part).getUri(); + } + } + } + + public void validateCitationMetadata(CitationMetadata metadata) { + if (metadata != null) { + for (Citation citation : metadata.getCitations()) { + String uri = citation.getUri(); + String license = citation.getLicense(); + Calendar calendar = citation.getPublicationDate(); + int startIndex = citation.getStartIndex(); + int endIndex = citation.getEndIndex(); + Assert.assertTrue(startIndex <= endIndex); + } + } + } + + public void validateFunctionCalls(List parts) { + if (parts != null) { + for (FunctionCallPart part : parts) { + String functionName = part.getName(); + Map args = part.getArgs(); + Assert.assertFalse(functionName.isBlank()); + } + } + } + + public void validatePromptFeedback(PromptFeedback feedback) { + if (feedback != null) { + String message = feedback.getBlockReasonMessage(); + BlockReason reason = feedback.getBlockReason(); + validateSafetyRatings(feedback.getSafetyRatings()); + } + } + + public void validateSafetyRatings(List ratings) { + for (SafetyRating rating : ratings) { + Boolean blocked = rating.getBlocked(); + HarmCategory category = rating.getCategory(); + HarmProbability probability = rating.getProbability(); + float score = rating.getProbabilityScore(); + HarmSeverity severity = rating.getSeverity(); + Float severityScore = rating.getSeverityScore(); + if (severity != null) { + Assert.assertNotNull(severityScore); + } + } + } + + public void validateUsageMetadata(UsageMetadata metadata) { + if (metadata != null) { + int totalTokens = metadata.getTotalTokenCount(); + int promptTokenCount = metadata.getPromptTokenCount(); + for (ModalityTokenCount count : metadata.getPromptTokensDetails()) { + ContentModality modality = count.getModality(); + int tokenCount = count.getTokenCount(); + } + Integer candidatesTokenCount = metadata.getCandidatesTokenCount(); + for (ModalityTokenCount count : metadata.getCandidatesTokensDetails()) { + ContentModality modality = count.getModality(); + int tokenCount = count.getTokenCount(); + } + } + } + + private void testLiveFutures(LiveModelFutures futures) throws Exception { + LiveSessionFutures session = futures.connect().get(); + session + .receive() + .subscribe( + new Subscriber() { + @Override + public void onSubscribe(Subscription s) { + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(LiveServerMessage message) { + validateLiveContentResponse(message); + } + + @Override + public void onError(Throwable t) { + // Ignore + } + + @Override + public void onComplete() { + // Also ignore + } + }); + + session.send("Fake message"); + session.send(new Content.Builder().addText("Fake message").build()); + + byte[] bytes = new byte[] {(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE}; + session.sendMediaStream(List.of(new MediaData(bytes, "image/jxl"))); + + FunctionResponsePart functionResponse = + new FunctionResponsePart("myFunction", new JsonObject(Map.of())); + session.sendFunctionResponse(List.of(functionResponse, functionResponse)); + + session.startAudioConversation(part -> functionResponse); + session.startAudioConversation(); + session.stopAudioConversation(); + session.stopReceiving(); + session.close(); + } + + private void validateLiveContentResponse(LiveServerMessage message) { + if (message instanceof LiveServerContent) { + LiveServerContent content = (LiveServerContent) message; + validateContent(content.getContent()); + boolean complete = content.getGenerationComplete(); + boolean interrupted = content.getInterrupted(); + boolean turnComplete = content.getTurnComplete(); + } else if (message instanceof LiveServerSetupComplete) { + LiveServerSetupComplete setup = (LiveServerSetupComplete) message; + // No methods + } else if (message instanceof LiveServerToolCall) { + LiveServerToolCall call = (LiveServerToolCall) message; + validateFunctionCalls(call.getFunctionCalls()); + } else if (message instanceof LiveServerToolCallCancellation) { + LiveServerToolCallCancellation cancel = (LiveServerToolCallCancellation) message; + List functions = cancel.getFunctionIds(); + } + } +} diff --git a/firebase-ai/update_responses.sh b/firebase-ai/update_responses.sh new file mode 100755 index 00000000000..7d6ea18e0ee --- /dev/null +++ b/firebase-ai/update_responses.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This script replaces mock response files for Vertex AI unit tests with a fresh +# clone of the shared repository of Vertex AI test data. + +RESPONSES_VERSION='v13.*' # The major version of mock responses to use +REPO_NAME="vertexai-sdk-test-data" +REPO_LINK="https://github.com/FirebaseExtended/$REPO_NAME.git" + +set -x + +cd "$(dirname "$0")/src/test/resources" || exit +rm -rf "$REPO_NAME" +git clone "$REPO_LINK" --quiet || exit +cd "$REPO_NAME" || exit + +# Find and checkout latest tag matching major version +TAG=$(git tag -l "$RESPONSES_VERSION" --sort=v:refname | tail -n1) +if [ -z "$TAG" ]; then + echo "Error: No tag matching '$RESPONSES_VERSION' found in $REPO_NAME" + exit +fi +git checkout "$TAG" --quiet diff --git a/firebase-appdistribution-api/CHANGELOG.md b/firebase-appdistribution-api/CHANGELOG.md index 52fc8534ef5..44afcf8054e 100644 --- a/firebase-appdistribution-api/CHANGELOG.md +++ b/firebase-appdistribution-api/CHANGELOG.md @@ -1,6 +1,16 @@ # Unreleased +# 16.0.0-beta15 +* [unchanged] Updated to accommodate the release of the updated + [appdistro] library. + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-appdistribution-api` library. The Kotlin extensions library has no additional +updates. + # 16.0.0-beta14 * [unchanged] Updated to accommodate the release of the updated [appdistro] library. diff --git a/firebase-appdistribution-api/gradle.properties b/firebase-appdistribution-api/gradle.properties index 43fb0b20a81..a39a1d388f4 100644 --- a/firebase-appdistribution-api/gradle.properties +++ b/firebase-appdistribution-api/gradle.properties @@ -12,5 +12,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -version=16.0.0-beta15 -latestReleasedVersion=16.0.0-beta14 +version=16.0.0-beta16 +latestReleasedVersion=16.0.0-beta15 diff --git a/firebase-appdistribution/CHANGELOG.md b/firebase-appdistribution/CHANGELOG.md index 6aa5cbfad70..c7ef8338657 100644 --- a/firebase-appdistribution/CHANGELOG.md +++ b/firebase-appdistribution/CHANGELOG.md @@ -1,6 +1,9 @@ # Unreleased +# 16.0.0-beta15 +* [fixed] Added custom tab support for more browsers [#6692] + # 16.0.0-beta14 * [changed] Internal improvements to testing on Android 14 diff --git a/firebase-appdistribution/gradle.properties b/firebase-appdistribution/gradle.properties index d02dfc98183..5dcfcbcc66c 100644 --- a/firebase-appdistribution/gradle.properties +++ b/firebase-appdistribution/gradle.properties @@ -12,5 +12,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -version=16.0.0-beta15 -latestReleasedVersion=16.0.0-beta14 +version=16.0.0-beta16 +latestReleasedVersion=16.0.0-beta15 diff --git a/firebase-appdistribution/src/main/AndroidManifest.xml b/firebase-appdistribution/src/main/AndroidManifest.xml index ef91581edc3..452fe856e6c 100644 --- a/firebase-appdistribution/src/main/AndroidManifest.xml +++ b/firebase-appdistribution/src/main/AndroidManifest.xml @@ -21,6 +21,12 @@ + + + + + + resolveInfos = - context.getPackageManager().queryIntentServices(customTabIntent, 0); - return resolveInfos != null && !resolveInfos.isEmpty(); + String packageName = CustomTabsClient.getPackageName(context, Collections.emptyList()); + return packageName != null; } } diff --git a/firebase-config/CHANGELOG.md b/firebase-config/CHANGELOG.md index 93a7da1867e..fc00b486a87 100644 --- a/firebase-config/CHANGELOG.md +++ b/firebase-config/CHANGELOG.md @@ -1,6 +1,26 @@ # Unreleased +# 22.1.2 +* [fixed] Fixed `NetworkOnMainThreadException` on Android versions below 8 by disconnecting + `HttpURLConnection` only on API levels 26 and higher. GitHub Issue [#6934] + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-config` library. The Kotlin extensions library has no additional +updates. + +# 22.1.1 +* [fixed] Fixed an issue where the connection to the real-time Remote Config backend could remain +open in the background. + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-config` library. The Kotlin extensions library has no additional +updates. + # 22.1.0 * [feature] Added support for custom signal targeting in Remote Config. Use `setCustomSignals` API for setting custom signals and use them to build custom targeting conditions in Remote Config. diff --git a/firebase-config/bandwagoner/src/main/AndroidManifest.xml b/firebase-config/bandwagoner/src/main/AndroidManifest.xml index 409b510c785..01252cf60bb 100644 --- a/firebase-config/bandwagoner/src/main/AndroidManifest.xml +++ b/firebase-config/bandwagoner/src/main/AndroidManifest.xml @@ -17,9 +17,8 @@ + android:versionCode="1" + android:versionName="3.0.0"> @@ -30,22 +29,22 @@ - - - - - - - - - - - + android:name=".MainApplication" + android:label="Bandwagoner" + android:theme="@style/LightNoActionBarTheme"> + + + + + + + + + + + diff --git a/firebase-config/gradle.properties b/firebase-config/gradle.properties index 26e0ad751e6..02671b7fc28 100644 --- a/firebase-config/gradle.properties +++ b/firebase-config/gradle.properties @@ -14,7 +14,7 @@ # limitations under the License. # -version=22.1.1 -latestReleasedVersion=22.1.0 +version=22.1.3 +latestReleasedVersion=22.1.2 android.enableUnitTestBinaryResources=true diff --git a/firebase-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java b/firebase-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java index 808892e7521..abd09dd0330 100644 --- a/firebase-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java +++ b/firebase-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java @@ -656,16 +656,17 @@ private Task setDefaultsWithStringsMapAsync(Map defaultsSt * Asynchronously changes the custom signals for this {@link FirebaseRemoteConfig} instance. * *

Custom signals are subject to limits on the size of key/value pairs and the total - * number of signals. Any calls that exceed these limits will be discarded. + * number of signals. Any calls that exceed these limits will be discarded. See Custom + * Signal Limits. * * @param customSignals The custom signals to set for this instance. - *

    + *
      *
    • New keys will add new key-value pairs in the custom signals. *
    • Existing keys with new values will update the corresponding signals. *
    • Setting a key's value to {@code null} will remove the associated signal. - *
+ * */ - // TODO(b/385028620): Add link to documentation about custom signal limits. @NonNull public Task setCustomSignals(@NonNull CustomSignals customSignals) { return Tasks.call( diff --git a/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigAutoFetch.java b/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigAutoFetch.java index 81016602532..a93b1dc5784 100644 --- a/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigAutoFetch.java +++ b/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigAutoFetch.java @@ -54,6 +54,7 @@ public class ConfigAutoFetch { private final ConfigUpdateListener retryCallback; private final ScheduledExecutorService scheduledExecutorService; private final Random random; + private boolean isInBackground; public ConfigAutoFetch( HttpURLConnection httpURLConnection, @@ -69,6 +70,7 @@ public ConfigAutoFetch( this.retryCallback = retryCallback; this.scheduledExecutorService = scheduledExecutorService; this.random = new Random(); + this.isInBackground = false; } private synchronized void propagateErrors(FirebaseRemoteConfigException exception) { @@ -87,6 +89,10 @@ private synchronized boolean isEventListenersEmpty() { return this.eventListeners.isEmpty(); } + public void setIsInBackground(boolean isInBackground) { + this.isInBackground = isInBackground; + } + private String parseAndValidateConfigUpdateMessage(String message) { int left = message.indexOf('{'); int right = message.lastIndexOf('}'); @@ -105,15 +111,29 @@ public void listenForNotifications() { return; } + // Maintain a reference to the InputStream to guarantee its closure upon completion or in case + // of an exception. + InputStream inputStream = null; try { - InputStream inputStream = httpURLConnection.getInputStream(); + inputStream = httpURLConnection.getInputStream(); handleNotifications(inputStream); - inputStream.close(); } catch (IOException ex) { - // Stream was interrupted due to a transient issue and the system will retry the connection. - Log.d(TAG, "Stream was cancelled due to an exception. Retrying the connection...", ex); + // If the real-time connection is at an unexpected lifecycle state when the app is + // backgrounded, it's expected closing the httpURLConnection will throw an exception. + if (!isInBackground) { + // Otherwise, the real-time server connection was closed due to a transient issue. + Log.d(TAG, "Real-time connection was closed due to an exception.", ex); + } } finally { - httpURLConnection.disconnect(); + if (inputStream != null) { + try { + // Only need to close the InputStream, ConfigRealtimeHttpClient will disconnect + // HttpUrlConnection + inputStream.close(); + } catch (IOException ex) { + Log.d(TAG, "Exception thrown when closing connection stream. Retrying connection...", ex); + } + } } } @@ -186,7 +206,6 @@ private void handleNotifications(InputStream inputStream) throws IOException { } reader.close(); - inputStream.close(); } private void autoFetch(int remainingAttempts, long targetVersion) { diff --git a/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigRealtimeHandler.java b/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigRealtimeHandler.java index 5ed1135dfc7..e340ef0b8c0 100644 --- a/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigRealtimeHandler.java +++ b/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigRealtimeHandler.java @@ -91,7 +91,7 @@ public synchronized ConfigUpdateListenerRegistration addRealtimeConfigUpdateList } public synchronized void setBackgroundState(boolean isInBackground) { - configRealtimeHttpClient.setRealtimeBackgroundState(isInBackground); + configRealtimeHttpClient.setIsInBackground(isInBackground); if (!isInBackground) { beginRealtime(); } diff --git a/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigRealtimeHttpClient.java b/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigRealtimeHttpClient.java index 2c1c44480e2..7be3ef97136 100644 --- a/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigRealtimeHttpClient.java +++ b/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigRealtimeHttpClient.java @@ -22,6 +22,7 @@ import android.annotation.SuppressLint; import android.content.Context; import android.content.pm.PackageManager; +import android.os.Build; import android.util.Log; import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; @@ -100,6 +101,10 @@ public class ConfigRealtimeHttpClient { /** Flag to indicate whether or not the app is in the background or not. */ private boolean isInBackground; + // The HttpUrlConnection and auto-fetcher for this client. Only one of each exist at a time. + private HttpURLConnection httpURLConnection; + private ConfigAutoFetch configAutoFetch; + private final int ORIGINAL_RETRIES = 8; private final ScheduledExecutorService scheduledExecutorService; private final ConfigFetchHandler configFetchHandler; @@ -111,6 +116,7 @@ public class ConfigRealtimeHttpClient { private final Random random; private final Clock clock; private final ConfigSharedPrefsClient sharedPrefsClient; + private final Object backgroundLock; public ConfigRealtimeHttpClient( FirebaseApp firebaseApp, @@ -145,6 +151,7 @@ public ConfigRealtimeHttpClient( this.sharedPrefsClient = sharedPrefsClient; this.isRealtimeDisabled = false; this.isInBackground = false; + this.backgroundLock = new Object(); } private static String extractProjectNumberFromAppId(String gmpAppId) { @@ -391,14 +398,47 @@ public void run() { } } - void setRealtimeBackgroundState(boolean backgroundState) { - isInBackground = backgroundState; + public void setIsInBackground(boolean isInBackground) { + // Make changes in synchronized block so only one thread sets the background state and calls + // disconnect. + synchronized (backgroundLock) { + this.isInBackground = isInBackground; + + // Propagate to ConfigAutoFetch as well. + if (configAutoFetch != null) { + configAutoFetch.setIsInBackground(isInBackground); + } + // Close the connection if the app is in the background and there is an active + // HttpUrlConnection. + // This is now only done on Android versions >= O (API 26) because + // on older versions, background detection callbacks run on the main thread, which + // could lead to a NetworkOnMainThreadException when disconnecting the connection. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (isInBackground && httpURLConnection != null) { + httpURLConnection.disconnect(); + } + } + } } private synchronized void resetRetryCount() { httpRetriesRemaining = ORIGINAL_RETRIES; } + /** + * The check and set http connection method are combined so that when canMakeHttpStreamConnection + * returns true, the same thread can mark isHttpConnectionIsRunning as true to prevent a race + * condition with another thread. + */ + private synchronized boolean checkAndSetHttpConnectionFlagIfNotRunning() { + boolean canMakeConnection = canMakeHttpStreamConnection(); + if (canMakeConnection) { + setIsHttpConnectionRunning(true); + } + + return canMakeConnection; + } + private synchronized void setIsHttpConnectionRunning(boolean connectionRunning) { isHttpConnectionRunning = connectionRunning; } @@ -469,7 +509,7 @@ private String parseForbiddenErrorResponseMessage(InputStream inputStream) { */ @SuppressLint({"VisibleForTests", "DefaultLocale"}) public void beginRealtimeHttpStream() { - if (!canMakeHttpStreamConnection()) { + if (!checkAndSetHttpConnectionFlagIfNotRunning()) { return; } @@ -489,17 +529,21 @@ public void beginRealtimeHttpStream() { this.scheduledExecutorService, (completedHttpUrlConnectionTask) -> { Integer responseCode = null; - HttpURLConnection httpURLConnection = null; + // Get references to InputStream and ErrorStream before listening on the stream so + // that they can be closed without getting them from HttpUrlConnection. + InputStream inputStream = null; + InputStream errorStream = null; try { // If HTTP connection task failed throw exception to move to the catch block. if (!httpURLConnectionTask.isSuccessful()) { throw new IOException(httpURLConnectionTask.getException()); } - setIsHttpConnectionRunning(true); // Get HTTP connection and check response code. httpURLConnection = httpURLConnectionTask.getResult(); + inputStream = httpURLConnection.getInputStream(); + errorStream = httpURLConnection.getErrorStream(); responseCode = httpURLConnection.getResponseCode(); // If the connection returned a 200 response code, start listening for messages. @@ -509,23 +553,32 @@ public void beginRealtimeHttpStream() { sharedPrefsClient.resetRealtimeBackoff(); // Start listening for realtime notifications. - ConfigAutoFetch configAutoFetch = startAutoFetch(httpURLConnection); + configAutoFetch = startAutoFetch(httpURLConnection); configAutoFetch.listenForNotifications(); } } catch (IOException e) { - // Stream could not be open due to a transient issue and the system will retry the - // connection - // without user intervention. - Log.d( - TAG, - "Exception connecting to real-time RC backend. Retrying the connection...", - e); + if (isInBackground) { + // It's possible the app was backgrounded while the connection was open, which + // threw an exception trying to read the response. No real error here, so treat + // this as a success, even if we haven't read a 200 response code yet. + resetRetryCount(); + } else { + // If it's not in the background, there might have been a transient error so the + // client will retry the connection. + Log.d( + TAG, + "Exception connecting to real-time RC backend. Retrying the connection...", + e); + } } finally { - closeRealtimeHttpStream(httpURLConnection); + // Close HTTP connection and associated streams. + closeRealtimeHttpConnection(inputStream, errorStream); setIsHttpConnectionRunning(false); + // Update backoff metadata if the connection failed in the foreground. boolean connectionFailed = - responseCode == null || isStatusCodeRetryable(responseCode); + !isInBackground + && (responseCode == null || isStatusCodeRetryable(responseCode)); if (connectionFailed) { updateBackoffMetadataWithLastFailedStreamConnectionTime( new Date(clock.currentTimeMillis())); @@ -556,24 +609,34 @@ public void beginRealtimeHttpStream() { } } + // Reset parameters. + httpURLConnection = null; + configAutoFetch = null; + return Tasks.forResult(null); }); } - // Pauses Http stream listening - public void closeRealtimeHttpStream(HttpURLConnection httpURLConnection) { - if (httpURLConnection != null) { - httpURLConnection.disconnect(); - - // Explicitly close the input stream due to a bug in the Android okhttp implementation. - // See github.com/firebase/firebase-android-sdk/pull/808. + private void closeHttpConnectionInputStream(InputStream inputStream) { + if (inputStream != null) { try { - httpURLConnection.getInputStream().close(); - if (httpURLConnection.getErrorStream() != null) { - httpURLConnection.getErrorStream().close(); - } - } catch (IOException e) { + inputStream.close(); + } catch (IOException ex) { + Log.d(TAG, "Error closing connection stream.", ex); } } } + + // Pauses Http stream listening by disconnecting the HttpUrlConnection and underlying InputStream + // and ErrorStream if they exist. + @VisibleForTesting + public void closeRealtimeHttpConnection(InputStream inputStream, InputStream errorStream) { + // Disconnect only if the connection is not null and in the foreground. + if (httpURLConnection != null && !isInBackground) { + httpURLConnection.disconnect(); + } + + closeHttpConnectionInputStream(inputStream); + closeHttpConnectionInputStream(errorStream); + } } diff --git a/firebase-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java b/firebase-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java index fffc439dc2b..9e8f65c767e 100644 --- a/firebase-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java +++ b/firebase-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java @@ -37,6 +37,7 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; @@ -80,6 +81,7 @@ import com.google.firebase.remoteconfig.internal.rollouts.RolloutsStateSubscriptionsHandler; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; @@ -350,6 +352,7 @@ public void onError(@NonNull FirebaseRemoteConfigException error) { listeners, mockRetryListener, scheduledExecutorService); + configAutoFetch.setIsInBackground(false); realtimeSharedPrefsClient = new ConfigSharedPrefsClient( context.getSharedPreferences("test_file", Context.MODE_PRIVATE)); @@ -1274,17 +1277,18 @@ public void realtime_client_removeListener_success() { @Test public void realtime_stream_listen_and_end_connection() throws Exception { - when(mockHttpURLConnection.getInputStream()) - .thenReturn( - new ByteArrayInputStream( - "{ \"latestTemplateVersionNumber\": 1 }".getBytes(StandardCharsets.UTF_8))); + InputStream inputStream = + new ByteArrayInputStream( + "{ \"latestTemplateVersionNumber\": 1 }".getBytes(StandardCharsets.UTF_8)); + InputStream inputStreamSpy = spy(inputStream); + when(mockHttpURLConnection.getInputStream()).thenReturn(inputStreamSpy); when(mockFetchHandler.getTemplateVersionNumber()).thenReturn(1L); when(mockFetchHandler.fetchNowWithTypeAndAttemptNumber( ConfigFetchHandler.FetchType.REALTIME, 1)) .thenReturn(Tasks.forResult(realtimeFetchedContainerResponse)); configAutoFetch.listenForNotifications(); - verify(mockHttpURLConnection).disconnect(); + verify(inputStreamSpy, times(2)).close(); } @Test @@ -1308,7 +1312,7 @@ public void realtime_redirectStatusCode_noRetries() throws Exception { .createRealtimeConnection(); doNothing() .when(configRealtimeHttpClientSpy) - .closeRealtimeHttpStream(any(HttpURLConnection.class)); + .closeRealtimeHttpConnection(any(InputStream.class), any(InputStream.class)); when(mockHttpURLConnection.getResponseCode()).thenReturn(301); configRealtimeHttpClientSpy.beginRealtimeHttpStream(); @@ -1329,7 +1333,7 @@ public void realtime_okStatusCode_startAutofetchAndRetries() throws Exception { doNothing().when(configRealtimeHttpClientSpy).retryHttpConnectionWhenBackoffEnds(); doNothing() .when(configRealtimeHttpClientSpy) - .closeRealtimeHttpStream(any(HttpURLConnection.class)); + .closeRealtimeHttpConnection(any(InputStream.class), any(InputStream.class)); when(mockHttpURLConnection.getResponseCode()).thenReturn(200); configRealtimeHttpClientSpy.beginRealtimeHttpStream(); @@ -1348,7 +1352,7 @@ public void realtime_badGatewayStatusCode_noAutofetchButRetries() throws Excepti doNothing().when(configRealtimeHttpClientSpy).retryHttpConnectionWhenBackoffEnds(); doNothing() .when(configRealtimeHttpClientSpy) - .closeRealtimeHttpStream(any(HttpURLConnection.class)); + .closeRealtimeHttpConnection(any(InputStream.class), any(InputStream.class)); when(mockHttpURLConnection.getResponseCode()).thenReturn(502); configRealtimeHttpClientSpy.beginRealtimeHttpStream(); @@ -1367,7 +1371,7 @@ public void realtime_retryableStatusCode_increasesConfigMetadataFailedStreams() doNothing().when(configRealtimeHttpClientSpy).retryHttpConnectionWhenBackoffEnds(); doNothing() .when(configRealtimeHttpClientSpy) - .closeRealtimeHttpStream(any(HttpURLConnection.class)); + .closeRealtimeHttpConnection(any(InputStream.class), any(InputStream.class)); when(mockHttpURLConnection.getResponseCode()).thenReturn(502); int failedStreams = configRealtimeHttpClientSpy.getNumberOfFailedStreams(); @@ -1386,7 +1390,7 @@ public void realtime_retryableStatusCode_increasesConfigMetadataBackoffDate() th doNothing().when(configRealtimeHttpClientSpy).retryHttpConnectionWhenBackoffEnds(); doNothing() .when(configRealtimeHttpClientSpy) - .closeRealtimeHttpStream(any(HttpURLConnection.class)); + .closeRealtimeHttpConnection(any(InputStream.class), any(InputStream.class)); when(mockHttpURLConnection.getResponseCode()).thenReturn(502); Date backoffDate = configRealtimeHttpClientSpy.getBackoffEndTime(); @@ -1407,7 +1411,7 @@ public void realtime_successfulStatusCode_doesNotIncreaseConfigMetadataFailedStr doNothing().when(configRealtimeHttpClientSpy).retryHttpConnectionWhenBackoffEnds(); doNothing() .when(configRealtimeHttpClientSpy) - .closeRealtimeHttpStream(any(HttpURLConnection.class)); + .closeRealtimeHttpConnection(any(InputStream.class), any(InputStream.class)); when(mockHttpURLConnection.getResponseCode()).thenReturn(200); int failedStreams = configRealtimeHttpClientSpy.getNumberOfFailedStreams(); @@ -1428,7 +1432,7 @@ public void realtime_successfulStatusCode_doesNotIncreaseConfigMetadataBackoffDa doNothing().when(configRealtimeHttpClientSpy).retryHttpConnectionWhenBackoffEnds(); doNothing() .when(configRealtimeHttpClientSpy) - .closeRealtimeHttpStream(any(HttpURLConnection.class)); + .closeRealtimeHttpConnection(any(InputStream.class), any(InputStream.class)); when(mockHttpURLConnection.getResponseCode()).thenReturn(200); Date backoffDate = configRealtimeHttpClientSpy.getBackoffEndTime(); @@ -1446,7 +1450,7 @@ public void realtime_forbiddenStatusCode_returnsStreamError() throws Exception { .createRealtimeConnection(); doNothing() .when(configRealtimeHttpClientSpy) - .closeRealtimeHttpStream(any(HttpURLConnection.class)); + .closeRealtimeHttpConnection(any(InputStream.class), any(InputStream.class)); when(mockHttpURLConnection.getErrorStream()) .thenReturn( new ByteArrayInputStream(FORBIDDEN_ERROR_MESSAGE.getBytes(StandardCharsets.UTF_8))); @@ -1469,7 +1473,7 @@ public void realtime_exceptionThrown_noAutofetchButRetries() throws Exception { doNothing().when(configRealtimeHttpClientSpy).retryHttpConnectionWhenBackoffEnds(); doNothing() .when(configRealtimeHttpClientSpy) - .closeRealtimeHttpStream(any(HttpURLConnection.class)); + .closeRealtimeHttpConnection(any(InputStream.class), any(InputStream.class)); configRealtimeHttpClientSpy.beginRealtimeHttpStream(); flushScheduledTasks(); @@ -1511,6 +1515,25 @@ public void realtime_stream_listen_and_failsafe_disabled() throws Exception { verify(mockFetchHandler).getTemplateVersionNumber(); } + @Test + public void realtime_stream_listen_backgrounded_disconnects() throws Exception { + ConfigRealtimeHttpClient configRealtimeHttpClientSpy = spy(configRealtimeHttpClient); + doReturn(Tasks.forResult(mockHttpURLConnection)) + .when(configRealtimeHttpClientSpy) + .createRealtimeConnection(); + doReturn(mockConfigAutoFetch).when(configRealtimeHttpClientSpy).startAutoFetch(any()); + doNothing().when(configRealtimeHttpClientSpy).retryHttpConnectionWhenBackoffEnds(); + doNothing() + .when(configRealtimeHttpClientSpy) + .closeRealtimeHttpConnection(any(InputStream.class), any(InputStream.class)); + when(mockHttpURLConnection.getResponseCode()).thenReturn(200); + configRealtimeHttpClientSpy.beginRealtimeHttpStream(); + configRealtimeHttpClientSpy.setIsInBackground(true); + flushScheduledTasks(); + + verify(mockHttpURLConnection, times(1)).disconnect(); + } + @Test public void realtimeStreamListen_andUnableToParseMessage() throws Exception { when(mockHttpURLConnection.getResponseCode()).thenReturn(200); @@ -1530,15 +1553,28 @@ public void realtimeStreamListen_andUnableToParseMessage() throws Exception { @Test public void realtime_stream_listen_get_inputstream_fail() throws Exception { + InputStream inputStream = mock(InputStream.class); when(mockHttpURLConnection.getResponseCode()).thenReturn(200); - when(mockHttpURLConnection.getInputStream()).thenThrow(IOException.class); + when(mockHttpURLConnection.getInputStream()).thenReturn(inputStream); + when(inputStream.read()).thenThrow(IOException.class); when(mockFetchHandler.getTemplateVersionNumber()).thenReturn(1L); when(mockFetchHandler.fetchNowWithTypeAndAttemptNumber( ConfigFetchHandler.FetchType.REALTIME, 1)) .thenReturn(Tasks.forResult(realtimeFetchedContainerResponse)); configAutoFetch.listenForNotifications(); - verify(mockHttpURLConnection).disconnect(); + verify(inputStream).close(); + } + + @Test + public void realtime_stream_listen_get_inputstream_exception_handling() throws Exception { + InputStream inputStream = mock(InputStream.class); + when(mockHttpURLConnection.getResponseCode()).thenReturn(200); + when(mockHttpURLConnection.getInputStream()).thenThrow(IOException.class); + configAutoFetch.listenForNotifications(); + + verify(mockHttpURLConnection, times(1)).getInputStream(); + verify(inputStream, never()).close(); } @Test diff --git a/firebase-crashlytics-ndk/CHANGELOG.md b/firebase-crashlytics-ndk/CHANGELOG.md index b5b8f7868d4..38387afdd12 100644 --- a/firebase-crashlytics-ndk/CHANGELOG.md +++ b/firebase-crashlytics-ndk/CHANGELOG.md @@ -1,6 +1,16 @@ # Unreleased +* [changed] Updated `firebase-crashlytics` dependency to v19.4.4 +# 19.4.3 +* [changed] Updated internal Crashpad version to commit `21a20e`. + +# 19.4.2 +* [changed] Updated `firebase-crashlytics` dependency to v19.4.2 + +# 19.4.1 +* [changed] Updated `firebase-crashlytics` dependency to v19.4.1 + # 19.3.0 * [changed] Updated `firebase-crashlytics` dependency to v19.3.0 diff --git a/firebase-crashlytics-ndk/firebase-crashlytics-ndk.gradle b/firebase-crashlytics-ndk/firebase-crashlytics-ndk.gradle index aafc02f489c..4cf82e95069 100644 --- a/firebase-crashlytics-ndk/firebase-crashlytics-ndk.gradle +++ b/firebase-crashlytics-ndk/firebase-crashlytics-ndk.gradle @@ -37,7 +37,7 @@ android { timeOutInMs 60 * 1000 } namespace "com.google.firebase.crashlytics.ndk" - ndkVersion "25.1.8937393" + ndkVersion "27.2.12479018" compileSdkVersion project.compileSdkVersion defaultConfig { minSdkVersion project.minSdkVersion diff --git a/firebase-crashlytics-ndk/gradle.properties b/firebase-crashlytics-ndk/gradle.properties index 5ab96e1d760..7ef5196b4b5 100644 --- a/firebase-crashlytics-ndk/gradle.properties +++ b/firebase-crashlytics-ndk/gradle.properties @@ -1,2 +1,2 @@ -version=19.4.1 -latestReleasedVersion=19.4.0 +version=19.4.4 +latestReleasedVersion=19.4.3 diff --git a/firebase-crashlytics-ndk/src/main/jni/Application.mk b/firebase-crashlytics-ndk/src/main/jni/Application.mk index af0b6b867d1..affe25cf47c 100644 --- a/firebase-crashlytics-ndk/src/main/jni/Application.mk +++ b/firebase-crashlytics-ndk/src/main/jni/Application.mk @@ -1,3 +1,3 @@ APP_ABI := arm64-v8a armeabi-v7a x86_64 x86 APP_STL := c++_static -APP_PLATFORM := android-16 +APP_PLATFORM := android-21 diff --git a/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_client/Android.mk b/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_client/Android.mk index f08db85fed0..db8e082111c 100644 --- a/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_client/Android.mk +++ b/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_client/Android.mk @@ -17,7 +17,7 @@ LOCAL_CPPFLAGS := \ -Wall \ -Os \ -flto \ - -std=c++17 \ + -std=c++20 \ LOCAL_SRC_FILES := \ $(THIRD_PARTY_PATH)/crashpad/client/annotation.cc \ diff --git a/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_compat/Android.mk b/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_compat/Android.mk index ddec4ff67a6..e3157757580 100644 --- a/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_compat/Android.mk +++ b/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_compat/Android.mk @@ -21,7 +21,7 @@ LOCAL_EXPORT_C_INCLUDES := \ LOCAL_CPPFLAGS := \ -D_FILE_OFFSET_BITS=64 \ -Wall \ - -std=c++17 \ + -std=c++20 \ -Os \ -flto \ -fvisibility=hidden \ diff --git a/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_handler_lib/Android.mk b/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_handler_lib/Android.mk index 94928ae917d..4b5f4e31b09 100644 --- a/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_handler_lib/Android.mk +++ b/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_handler_lib/Android.mk @@ -13,7 +13,7 @@ LOCAL_CPPFLAGS := \ -D_FILE_OFFSET_BITS=64 \ -DCRASHPAD_ZLIB_SOURCE_SYSTEM \ -Wall \ - -std=c++17 \ + -std=c++20 \ -Os \ -flto \ -fvisibility=hidden \ diff --git a/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_minidump/Android.mk b/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_minidump/Android.mk index 3a717888749..8c5309bf003 100644 --- a/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_minidump/Android.mk +++ b/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_minidump/Android.mk @@ -8,7 +8,7 @@ LOCAL_MODULE := crashpad_minidump LOCAL_C_INCLUDES := $(LOCAL_PATH)/$(THIRD_PARTY_PATH)/crashpad LOCAL_CPPFLAGS := \ -D_FILE_OFFSET_BITS=64 \ - -std=c++17 \ + -std=c++20 \ -Wall \ -Os \ -flto \ diff --git a/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_snapshot/Android.mk b/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_snapshot/Android.mk index de07187cf21..60f2eda169b 100644 --- a/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_snapshot/Android.mk +++ b/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_snapshot/Android.mk @@ -8,7 +8,7 @@ LOCAL_MODULE := crashpad_snapshot LOCAL_C_INCLUDES := $(LOCAL_PATH)/$(THIRD_PARTY_PATH)/crashpad LOCAL_CPPFLAGS := \ -D_FILE_OFFSET_BITS=64 \ - -std=c++17 \ + -std=c++20 \ -Wall \ -Os \ -flto \ diff --git a/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_tool_support/Android.mk b/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_tool_support/Android.mk index 11c2e0c51e2..b7b1281bae6 100644 --- a/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_tool_support/Android.mk +++ b/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_tool_support/Android.mk @@ -9,7 +9,7 @@ LOCAL_C_INCLUDES := $(LOCAL_PATH)/$(THIRD_PARTY_PATH)/crashpad LOCAL_CPPFLAGS := \ -D_FILE_OFFSET_BITS=64 \ - -std=c++17 \ + -std=c++20 \ -Wall \ -Os \ -flto \ diff --git a/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_util/Android.mk b/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_util/Android.mk index 56d1a65ea5b..0b75dc36e53 100644 --- a/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_util/Android.mk +++ b/firebase-crashlytics-ndk/src/main/jni/crashpad/crashpad_util/Android.mk @@ -18,7 +18,7 @@ LOCAL_CPPFLAGS := \ -DZLIB_CONST \ -DCRASHPAD_ZLIB_SOURCE_SYSTEM \ -DCRASHPAD_LSS_SOURCE_EXTERNAL \ - -std=c++17 \ + -std=c++20 \ -Wall \ -Os \ -flto \ @@ -44,6 +44,7 @@ LOCAL_SRC_FILES := \ $(THIRD_PARTY_PATH)/crashpad/util/linux/exception_handler_protocol.cc \ $(THIRD_PARTY_PATH)/crashpad/util/linux/initial_signal_dispositions.cc \ $(THIRD_PARTY_PATH)/crashpad/util/linux/memory_map.cc \ + $(THIRD_PARTY_PATH)/crashpad/util/linux/pac_helper.cc \ $(THIRD_PARTY_PATH)/crashpad/util/linux/proc_stat_reader.cc \ $(THIRD_PARTY_PATH)/crashpad/util/linux/proc_task_reader.cc \ $(THIRD_PARTY_PATH)/crashpad/util/linux/ptrace_broker.cc \ diff --git a/firebase-crashlytics-ndk/src/main/jni/crashpad/mini_chromium_base/Android.mk b/firebase-crashlytics-ndk/src/main/jni/crashpad/mini_chromium_base/Android.mk index 62cab7de0d4..a2a5b656c47 100644 --- a/firebase-crashlytics-ndk/src/main/jni/crashpad/mini_chromium_base/Android.mk +++ b/firebase-crashlytics-ndk/src/main/jni/crashpad/mini_chromium_base/Android.mk @@ -10,7 +10,7 @@ LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)/$(THIRD_PARTY_PATH)/mini_chromium LOCAL_CPPFLAGS := \ -D_FILE_OFFSET_BITS=64 \ - -std=c++17 \ + -std=c++20 \ -Wall \ -Os \ -flto \ @@ -25,6 +25,7 @@ LOCAL_SRC_FILES := \ $(THIRD_PARTY_PATH)/mini_chromium/base/posix/safe_strerror.cc \ $(THIRD_PARTY_PATH)/mini_chromium/base/process/memory.cc \ $(THIRD_PARTY_PATH)/mini_chromium/base/rand_util.cc \ + $(THIRD_PARTY_PATH)/mini_chromium/base/strings/pattern.cc \ $(THIRD_PARTY_PATH)/mini_chromium/base/strings/string_number_conversions.cc \ $(THIRD_PARTY_PATH)/mini_chromium/base/strings/string_util.cc \ $(THIRD_PARTY_PATH)/mini_chromium/base/strings/stringprintf.cc \ diff --git a/firebase-crashlytics-ndk/src/main/jni/libcrashlytics-common/Android.mk b/firebase-crashlytics-ndk/src/main/jni/libcrashlytics-common/Android.mk index fcd45bd0b4f..bf322f3a873 100644 --- a/firebase-crashlytics-ndk/src/main/jni/libcrashlytics-common/Android.mk +++ b/firebase-crashlytics-ndk/src/main/jni/libcrashlytics-common/Android.mk @@ -18,7 +18,7 @@ LOCAL_C_INCLUDES := \ LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)/include LOCAL_CPPFLAGS := \ - -std=c++17 \ + -std=c++20 \ -Wall \ -Os \ -s \ diff --git a/firebase-crashlytics-ndk/src/main/jni/libcrashlytics-handler/Android.mk b/firebase-crashlytics-ndk/src/main/jni/libcrashlytics-handler/Android.mk index ac89324387b..1dc59d2d7cc 100644 --- a/firebase-crashlytics-ndk/src/main/jni/libcrashlytics-handler/Android.mk +++ b/firebase-crashlytics-ndk/src/main/jni/libcrashlytics-handler/Android.mk @@ -12,7 +12,7 @@ LOCAL_C_INCLUDES := \ $(LOCAL_PATH)/../libcrashlytics-common/include \ LOCAL_CPPFLAGS := \ - -std=c++17 \ + -std=c++20 \ -Wall \ -Os \ -s \ diff --git a/firebase-crashlytics-ndk/src/main/jni/libcrashlytics-trampoline/Android.mk b/firebase-crashlytics-ndk/src/main/jni/libcrashlytics-trampoline/Android.mk index 2eb43b255fb..46114835893 100644 --- a/firebase-crashlytics-ndk/src/main/jni/libcrashlytics-trampoline/Android.mk +++ b/firebase-crashlytics-ndk/src/main/jni/libcrashlytics-trampoline/Android.mk @@ -9,7 +9,7 @@ endif LOCAL_MODULE := crashlytics-trampoline LOCAL_C_INCLUDES := $(LOCAL_PATH)/include LOCAL_CPPFLAGS := \ - -std=c++17 \ + -std=c++20 \ -Wall \ -Os \ -s \ diff --git a/firebase-crashlytics-ndk/src/main/jni/libcrashlytics/Android.mk b/firebase-crashlytics-ndk/src/main/jni/libcrashlytics/Android.mk index 12c9f2088ce..d62658e9112 100644 --- a/firebase-crashlytics-ndk/src/main/jni/libcrashlytics/Android.mk +++ b/firebase-crashlytics-ndk/src/main/jni/libcrashlytics/Android.mk @@ -16,7 +16,7 @@ LOCAL_C_INCLUDES := \ $(LOCAL_PATH)/$(THIRD_PARTY_PATH)/mini_chromium \ LOCAL_CPPFLAGS := \ - -std=c++17 \ + -std=c++20 \ -Wall \ -Os \ -s \ diff --git a/firebase-crashlytics-ndk/src/third_party/crashpad b/firebase-crashlytics-ndk/src/third_party/crashpad index c902f6b1c9e..21a20ef8adf 160000 --- a/firebase-crashlytics-ndk/src/third_party/crashpad +++ b/firebase-crashlytics-ndk/src/third_party/crashpad @@ -1 +1 @@ -Subproject commit c902f6b1c9e43224181969110b83e0053b2ddd3c +Subproject commit 21a20ef8adf3949de8dd65758a16f83aab344b3c diff --git a/firebase-crashlytics-ndk/src/third_party/lss b/firebase-crashlytics-ndk/src/third_party/lss index 9719c1e1e67..ed31caa60f2 160000 --- a/firebase-crashlytics-ndk/src/third_party/lss +++ b/firebase-crashlytics-ndk/src/third_party/lss @@ -1 +1 @@ -Subproject commit 9719c1e1e676814c456b55f5f070eabad6709d31 +Subproject commit ed31caa60f20a4f6569883b2d752ef7522de51e0 diff --git a/firebase-crashlytics-ndk/src/third_party/mini_chromium b/firebase-crashlytics-ndk/src/third_party/mini_chromium index 4332ddb6963..7477036e238 160000 --- a/firebase-crashlytics-ndk/src/third_party/mini_chromium +++ b/firebase-crashlytics-ndk/src/third_party/mini_chromium @@ -1 +1 @@ -Subproject commit 4332ddb6963750e1106efdcece6d6e2de6dc6430 +Subproject commit 7477036e238e54f220bed206f71036db8064dd34 diff --git a/firebase-crashlytics/CHANGELOG.md b/firebase-crashlytics/CHANGELOG.md index 7086b0b0c9d..aace588a1fe 100644 --- a/firebase-crashlytics/CHANGELOG.md +++ b/firebase-crashlytics/CHANGELOG.md @@ -1,5 +1,36 @@ # Unreleased +* [fixed] Fixed more strict mode violations +# 19.4.3 +* [fixed] Fixed UnbufferedIoViolation strict mode violation [#6822] + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-crashlytics` library. The Kotlin extensions library has no additional +updates. + +# 19.4.2 +* [changed] Internal changes to read version control info more efficiently [#6754] +* [fixed] Fixed NoSuchMethodError when getting process info on Android 13 on some devices [#6720] +* [changed] Updated `firebase-sessions` dependency to v2.1.0 + * [changed] Add warning for known issue [b/328687152](https://issuetracker.google.com/328687152) [#6755] + * [changed] Updated datastore dependency to v1.1.3 to fix [CVE-2024-7254](https://github.com/advisories/GHSA-735f-pc8j-v9w8) [#6688] + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-crashlytics` library. The Kotlin extensions library has no additional +updates. + +# 19.4.1 +* [changed] Updated `firebase-sessions` dependency to v2.0.9 + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-crashlytics` library. The Kotlin extensions library has no additional +updates. # 19.4.0 * [feature] Added an overload for `recordException` that allows logging additional custom @@ -324,10 +355,10 @@ updates. # 18.2.10 * [fixed] Fixed a bug that could prevent unhandled exceptions from being - propogated to the default handler when the network is unavailable. + propagated to the default handler when the network is unavailable. * [changed] Internal changes to support on-demand fatal crash reporting for Flutter apps. -* [fixed] Fixed a bug that prevented [crashlytics] from initalizing on some +* [fixed] Fixed a bug that prevented [crashlytics] from initializing on some devices in some cases. (#3269) diff --git a/firebase-crashlytics/gradle.properties b/firebase-crashlytics/gradle.properties index 5ab96e1d760..7ef5196b4b5 100644 --- a/firebase-crashlytics/gradle.properties +++ b/firebase-crashlytics/gradle.properties @@ -1,2 +1,2 @@ -version=19.4.1 -latestReleasedVersion=19.4.0 +version=19.4.4 +latestReleasedVersion=19.4.3 diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/KeyValueBuilder.kt b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/KeyValueBuilder.kt index 636b975ab1d..74d3793e215 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/KeyValueBuilder.kt +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/KeyValueBuilder.kt @@ -23,7 +23,7 @@ private constructor( private val builder: CustomKeysAndValues.Builder, ) { @Deprecated( - "Do not construct this directly. Use [setCustomKeys] instead. To be removed in the next major release." + "Do not construct this directly. Use `setCustomKeys` instead. To be removed in the next major release." ) constructor(crashlytics: FirebaseCrashlytics) : this(crashlytics, CustomKeysAndValues.Builder()) diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/ProcessDetailsProvider.kt b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/ProcessDetailsProvider.kt index 49fd2fafd18..172ebaaf477 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/ProcessDetailsProvider.kt +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/ProcessDetailsProvider.kt @@ -29,6 +29,8 @@ import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Session. * @hide */ internal object ProcessDetailsProvider { + // TODO(mrober): Merge this with [com.google.firebase.sessions.ProcessDetailsProvider]. + /** Gets the details for all of this app's running processes. */ fun getAppProcessDetails(context: Context): List { val appUid = context.applicationInfo.uid @@ -70,7 +72,7 @@ internal object ProcessDetailsProvider { processName: String, pid: Int = 0, importance: Int = 0, - isDefaultProcess: Boolean = false + isDefaultProcess: Boolean = false, ) = ProcessDetails.builder() .setProcessName(processName) @@ -81,7 +83,7 @@ internal object ProcessDetailsProvider { /** Gets the app's current process name. If the API is not available, returns an empty string. */ private fun getProcessName(): String = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU) { Process.myProcessName() } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { Application.getProcessName() ?: "" diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CommonUtils.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CommonUtils.java index b29863f66c5..d76ccbc2133 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CommonUtils.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CommonUtils.java @@ -69,6 +69,8 @@ public class CommonUtils { "com.google.firebase.crashlytics.build_ids_arch"; static final String BUILD_IDS_BUILD_ID_RESOURCE_NAME = "com.google.firebase.crashlytics.build_ids_build_id"; + static final String VERSION_CONTROL_INFO_RESOURCE_NAME = + "com.google.firebase.crashlytics.version_control_info"; // TODO: Maybe move this method into a more appropriate class. public static SharedPreferences getSharedPrefs(Context context) { @@ -137,8 +139,9 @@ static Architecture getValue() { public static String streamToString(InputStream is) { // Previous code was running into this: http://code.google.com/p/android/issues/detail?id=14562 // on Android 2.3.3. The below code below does not exhibit that problem. - final java.util.Scanner s = new java.util.Scanner(is).useDelimiter("\\A"); - return s.hasNext() ? s.next() : ""; + try (final java.util.Scanner s = new java.util.Scanner(is).useDelimiter("\\A")) { + return s.hasNext() ? s.next() : ""; + } } public static String sha1(String source) { @@ -525,6 +528,15 @@ public static List getBuildIdInfo(Context context) { return buildIdInfoList; } + @Nullable + public static String getVersionControlInfo(Context context) { + int id = getResourcesIdentifier(context, VERSION_CONTROL_INFO_RESOURCE_NAME, "string"); + if (id == 0) { + return null; + } + return context.getResources().getString(id); + } + public static void closeQuietly(Closeable closeable) { if (closeable != null) { try { diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java index b55a26678d4..5a1322fc9b6 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java @@ -48,6 +48,7 @@ import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.Charset; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -77,6 +78,8 @@ class CrashlyticsController { private static final String VERSION_CONTROL_INFO_FILE = "version-control-info.textproto"; private static final String META_INF_FOLDER = "META-INF/"; + private static final Charset UTF_8 = Charset.forName("UTF-8"); + private final Context context; private final DataCollectionArbiter dataCollectionArbiter; private final CrashlyticsFileMarker crashMarker; @@ -628,13 +631,23 @@ void saveVersionControlInfo() { } String getVersionControlInfo() throws IOException { - InputStream is = getResourceAsStream(META_INF_FOLDER + VERSION_CONTROL_INFO_FILE); - if (is == null) { - return null; + // Attempt to read from an Android string resource + String versionControlInfo = CommonUtils.getVersionControlInfo(context); + if (versionControlInfo != null) { + Logger.getLogger().d("Read version control info from string resource"); + return Base64.encodeToString(versionControlInfo.getBytes(UTF_8), 0); + } + + // Fallback to reading the file + try (InputStream is = getResourceAsStream(META_INF_FOLDER + VERSION_CONTROL_INFO_FILE)) { + if (is != null) { + Logger.getLogger().d("Read version control info from file"); + return Base64.encodeToString(readResource(is), 0); + } } - Logger.getLogger().d("Read version control info"); - return Base64.encodeToString(readResource(is), 0); + Logger.getLogger().i("No version control information found"); + return null; } private InputStream getResourceAsStream(String resource) { @@ -644,25 +657,19 @@ private InputStream getResourceAsStream(String resource) { return null; } - InputStream is = classLoader.getResourceAsStream(resource); - if (is == null) { - Logger.getLogger().i("No version control information found"); - return null; - } - - return is; + return classLoader.getResourceAsStream(resource); } private static byte[] readResource(InputStream is) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - byte[] buffer = new byte[1024]; - int length; + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buffer = new byte[1024]; + int length; - while ((length = is.read(buffer)) != -1) { - out.write(buffer, 0, length); + while ((length = is.read(buffer)) != -1) { + out.write(buffer, 0, length); + } + return out.toByteArray(); } - - return out.toByteArray(); } private void finalizePreviousNativeSession(String previousSessionId) { diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java index 5abc282d556..50533be05b1 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java @@ -36,6 +36,7 @@ import com.google.firebase.crashlytics.internal.send.DataTransportCrashlyticsReportSender; import com.google.firebase.crashlytics.internal.settings.SettingsProvider; import com.google.firebase.crashlytics.internal.stacktrace.StackTraceTrimmingStrategy; +import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; @@ -427,13 +428,15 @@ private static CrashlyticsReport.ApplicationExitInfo convertApplicationExitInfo( @VisibleForTesting @RequiresApi(api = Build.VERSION_CODES.KITKAT) public static String convertInputStreamToString(InputStream inputStream) throws IOException { - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - byte[] bytes = new byte[DEFAULT_BUFFER_SIZE]; - int length; - while ((length = inputStream.read(bytes)) != -1) { - byteArrayOutputStream.write(bytes, 0, length); + try (BufferedInputStream bufferedInputStream = new BufferedInputStream(inputStream); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { + byte[] bytes = new byte[DEFAULT_BUFFER_SIZE]; + int length; + while ((length = bufferedInputStream.read(bytes)) != -1) { + byteArrayOutputStream.write(bytes, 0, length); + } + return byteArrayOutputStream.toString(StandardCharsets.UTF_8.name()); } - return byteArrayOutputStream.toString(StandardCharsets.UTF_8.name()); } /** Finds the first ANR ApplicationExitInfo within the session. */ diff --git a/firebase-dataconnect/CHANGELOG.md b/firebase-dataconnect/CHANGELOG.md index fa16a7ed32d..57be7a46be0 100644 --- a/firebase-dataconnect/CHANGELOG.md +++ b/firebase-dataconnect/CHANGELOG.md @@ -1,5 +1,26 @@ # Unreleased +* [fixed] Fixed occasional `NullPointerException` when registering with + FirebaseAuth, leading to erroneous UNAUTHENTICATED exceptions. + ([#7001](https://github.com/firebase/firebase-android-sdk/pull/7001)) +# 16.0.2 +* [changed] Improved code robustness related to state management in + `FirebaseDataConnect` objects. + ([#6861](https://github.com/firebase/firebase-android-sdk/pull/6861)) + +# 16.0.1 +* [changed] Internal improvements. + +# 16.0.0 +* [changed] DataConnectOperationException added, enabling support for partial + errors; that is, any data that was received and/or was able to be decoded is + now available via the "response" property of the exception thrown when a + query or mutation is executed. + ([#6794](https://github.com/firebase/firebase-android-sdk/pull/6794)) + +# 16.0.0-beta05 +* [changed] Changed gRPC proto package to v1 (was v1beta). + ([#6729](https://github.com/firebase/firebase-android-sdk/pull/6729)) # 16.0.0-beta04 * [changed] `FirebaseDataConnect.logLevel` type changed from `LogLevel` to diff --git a/firebase-dataconnect/api.txt b/firebase-dataconnect/api.txt index 19fb52985f5..d919cc593db 100644 --- a/firebase-dataconnect/api.txt +++ b/firebase-dataconnect/api.txt @@ -42,6 +42,47 @@ package com.google.firebase.dataconnect { ctor public DataConnectException(String message, Throwable? cause = null); } + public class DataConnectOperationException extends com.google.firebase.dataconnect.DataConnectException { + ctor public DataConnectOperationException(String message, Throwable? cause = null, com.google.firebase.dataconnect.DataConnectOperationFailureResponse response); + method public final com.google.firebase.dataconnect.DataConnectOperationFailureResponse getResponse(); + property public final com.google.firebase.dataconnect.DataConnectOperationFailureResponse response; + } + + public interface DataConnectOperationFailureResponse { + method public Data? getData(); + method public java.util.List getErrors(); + method public java.util.Map? getRawData(); + method public String toString(); + property public abstract Data? data; + property public abstract java.util.List errors; + property public abstract java.util.Map? rawData; + } + + public static interface DataConnectOperationFailureResponse.ErrorInfo { + method public boolean equals(Object? other); + method public String getMessage(); + method public java.util.List getPath(); + method public int hashCode(); + method public String toString(); + property public abstract String message; + property public abstract java.util.List path; + } + + public sealed interface DataConnectPathSegment { + } + + @kotlin.jvm.JvmInline public static final value class DataConnectPathSegment.Field implements com.google.firebase.dataconnect.DataConnectPathSegment { + ctor public DataConnectPathSegment.Field(String field); + method public String getField(); + property public final String field; + } + + @kotlin.jvm.JvmInline public static final value class DataConnectPathSegment.ListIndex implements com.google.firebase.dataconnect.DataConnectPathSegment { + ctor public DataConnectPathSegment.ListIndex(int index); + method public int getIndex(); + property public final int index; + } + public final class DataConnectSettings { ctor public DataConnectSettings(String host = "firebasedataconnect.googleapis.com", boolean sslEnabled = true); method public String getHost(); diff --git a/firebase-dataconnect/ci/README.md b/firebase-dataconnect/ci/README.md new file mode 100644 index 00000000000..8dd8c015eb9 --- /dev/null +++ b/firebase-dataconnect/ci/README.md @@ -0,0 +1,22 @@ +# Firebase Data Connect Android SDK Continuous Integration Scripts + +These scripts are used by GitHub Actions. + +There are GitHub Actions workflows that verify code formatting, lint checks, type annotations, +and running unit tests of code in this directory. Although they are not "required" checks, it +is requested to wait for these checks to pass. See `dataconnect.yaml`. + +The minimum required Python version (at the time of writing, April 2025) is 3.13. +See `pyproject.toml` for the most up-to-date requirement. + +Before running the scripts, install the required dependencies by running: + +``` +pip install -r requirements.txt +``` + +Then, run all of these presubmit checks by running the following command: + +``` +ruff check --fix && ruff format && pyright && pytest && echo 'SUCCESS!!!!!!!!!!!!!!!' +``` diff --git a/firebase-dataconnect/ci/calculate_github_issue_for_commenting.py b/firebase-dataconnect/ci/calculate_github_issue_for_commenting.py new file mode 100644 index 00000000000..2f71eea86ea --- /dev/null +++ b/firebase-dataconnect/ci/calculate_github_issue_for_commenting.py @@ -0,0 +1,148 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import argparse +import logging +import pathlib +import re +import typing + +from util import fetch_pr_info, pr_number_from_github_ref + + +def main() -> None: + args = parse_args() + logging.basicConfig(format="%(message)s", level=logging.INFO) + + github_issue = calculate_github_issue( + github_event_name=args.github_event_name, + github_issue_for_scheduled_run=args.github_issue_for_scheduled_run, + github_ref=args.github_ref, + github_repository=args.github_repository, + pr_body_github_issue_key=args.pr_body_github_issue_key, + ) + + issue_file_text = "" if github_issue is None else str(github_issue) + logging.info("Writing '%s' to %s", issue_file_text, args.issue_output_file) + args.issue_output_file.write_text(issue_file_text, encoding="utf8", errors="replace") + + +def calculate_github_issue( + github_event_name: str, + github_issue_for_scheduled_run: int, + github_ref: str, + github_repository: str, + pr_body_github_issue_key: str, +) -> int | None: + if github_event_name == "schedule": + logging.info( + "GitHub Event name is: %s; using GitHub Issue: %s", + github_event_name, + github_issue_for_scheduled_run, + ) + return github_issue_for_scheduled_run + + logging.info("Extracting PR number from string: %s", github_ref) + pr_number = pr_number_from_github_ref(github_ref) + if pr_number is None: + logging.info("No PR number extracted") + return None + typing.assert_type(pr_number, int) + + logging.info("PR number extracted: %s", pr_number) + logging.info("Loading body text of PR: %s", pr_number) + pr_info = fetch_pr_info( + pr_number=pr_number, + github_repository=github_repository, + ) + + logging.info("Looking for GitHub Issue key in PR body text: %s=NNNN", pr_body_github_issue_key) + github_issue = github_issue_from_pr_body( + pr_body=pr_info.body, + issue_key=pr_body_github_issue_key, + ) + + if github_issue is None: + logging.info("No GitHub Issue key found in PR body") + return None + typing.assert_type(github_issue, int) + + logging.info("Found GitHub Issue key in PR body: %s", github_issue) + return github_issue + + +def github_issue_from_pr_body(pr_body: str, issue_key: str) -> int | None: + expr = re.compile(r"\s*" + re.escape(issue_key) + r"\s*=\s*(\d+)\s*") + for line in pr_body.splitlines(): + match = expr.fullmatch(line.strip()) + if match: + return int(match.group(1)) + return None + + +class ParsedArgs(typing.Protocol): + issue_output_file: pathlib.Path + github_ref: str + github_repository: str + github_event_name: str + pr_body_github_issue_key: str + github_issue_for_scheduled_run: int + + +def parse_args() -> ParsedArgs: + arg_parser = argparse.ArgumentParser() + arg_parser.add_argument( + "--issue-output-file", + required=True, + help="The file to which to write the calculated issue number" + "if no issue number was found, then an empty file will be written", + ) + arg_parser.add_argument( + "--github-ref", + required=True, + help="The value of ${{ github.ref }} in the workflow", + ) + arg_parser.add_argument( + "--github-repository", + required=True, + help="The value of ${{ github.repository }} in the workflow", + ) + arg_parser.add_argument( + "--github-event-name", + required=True, + help="The value of ${{ github.event_name }} in the workflow", + ) + arg_parser.add_argument( + "--pr-body-github-issue-key", + required=True, + help="The string to search for in a Pull Request body to determine the GitHub Issue number " + "for commenting. For example, if the value is 'foobar' then this script searched a PR " + "body for a line of the form 'foobar=NNNN' where 'NNNN' is the GitHub issue number", + ) + arg_parser.add_argument( + "--github-issue-for-scheduled-run", + type=int, + required=True, + help="The GitHub Issue number to use for commenting when --github-event-name is 'schedule'", + ) + + parse_result = arg_parser.parse_args() + parse_result.issue_output_file = pathlib.Path(parse_result.issue_output_file) + return typing.cast("ParsedArgs", parse_result) + + +if __name__ == "__main__": + main() diff --git a/firebase-dataconnect/ci/calculate_github_issue_for_commenting_test.py b/firebase-dataconnect/ci/calculate_github_issue_for_commenting_test.py new file mode 100644 index 00000000000..a7e1df7da03 --- /dev/null +++ b/firebase-dataconnect/ci/calculate_github_issue_for_commenting_test.py @@ -0,0 +1,162 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import hypothesis +import hypothesis.strategies as st +import pytest + +import calculate_github_issue_for_commenting as sut + + +class Test_pr_number_from_github_ref: + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_returns_number_from_valid_github_ref(self, number: int) -> None: + github_ref = f"refs/pull/{number}/merge" + assert sut.pr_number_from_github_ref(github_ref) == number + + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_ignores_leading_zeroes(self, number: int) -> None: + github_ref = f"refs/pull/0{number}/merge" + assert sut.pr_number_from_github_ref(github_ref) == number + + @hypothesis.given(invalid_github_ref=st.text()) + def test_returns_none_on_random_input(self, invalid_github_ref: str) -> None: + assert sut.pr_number_from_github_ref(invalid_github_ref) is None + + @pytest.mark.parametrize( + "invalid_number", + [ + "", + "-1", + "123a", + "a123", + "12a34", + "1.2", + pytest.param( + "1234", + marks=pytest.mark.xfail( + reason="make sure that the test would otherwise pass on valid int values", + strict=True, + ), + ), + ], + ) + def test_returns_none_on_invalid_number(self, invalid_number: str) -> None: + invalid_github_ref = f"refs/pull/{invalid_number}/merge" + assert sut.pr_number_from_github_ref(invalid_github_ref) is None + + @pytest.mark.parametrize( + "malformed_ref", + [ + "", + "refs", + "refs/", + "refs/pull", + "refs/pull/", + "refs/pull/1234", + "refs/pull/1234/", + "Refs/pull/1234/merge", + "refs/Pull/1234/merge", + "refs/pull/1234/Merge", + "Arefs/pull/1234/merge", + "refs/pull/1234/mergeZ", + " refs/pull/1234/merge", + "refs/pull/1234/merge ", + pytest.param( + "refs/pull/1234/merge", + marks=pytest.mark.xfail( + reason="make sure that the test would otherwise pass on valid ref", + strict=True, + ), + ), + ], + ) + def test_returns_none_on_malformed_ref(self, malformed_ref: str) -> None: + assert sut.pr_number_from_github_ref(malformed_ref) is None + + +class Test_github_issue_from_pr_body: + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_returns_number(self, number: int) -> None: + text = f"zzyzx={number}" + assert sut.github_issue_from_pr_body(text, "zzyzx") == number + + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_ignores_leading_zeroes(self, number: int) -> None: + text = f"zzyzx=0{number}" + assert sut.github_issue_from_pr_body(text, "zzyzx") == number + + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_ignores_whitespace(self, number: int) -> None: + text = f" zzyzx = {number} " + assert sut.github_issue_from_pr_body(text, "zzyzx") == number + + @hypothesis.given( + number1=st.integers(min_value=0, max_value=10000), + number2=st.integers(min_value=0, max_value=10000), + ) + def test_does_not_ignore_whitespace_in_key(self, number1: int, number2: int) -> None: + text = f"zzyzx={number1}\n z z y z x = {number2} " + assert sut.github_issue_from_pr_body(text, "z z y z x") == number2 + + @hypothesis.given( + number1=st.integers(min_value=0, max_value=10000), + number2=st.integers(min_value=0, max_value=10000), + ) + def test_returns_first_number_ignoring_second(self, number1: int, number2: int) -> None: + text = f"zzyzx={number1}\nzzyzx={number2}" + assert sut.github_issue_from_pr_body(text, "zzyzx") == number1 + + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_returns_first_valid_number_ignoring_invalid(self, number: int) -> None: + text = f"zzyzx=12X34\nzzyzx={number}" + assert sut.github_issue_from_pr_body(text, "zzyzx") == number + + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_returns_number_amidst_other_lines(self, number: int) -> None: + text = f"line 1\nline 2\nzzyzx={number}\nline 3" + assert sut.github_issue_from_pr_body(text, "zzyzx") == number + + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_returns_escapes_regex_special_chars_in_key(self, number: int) -> None: + text = f"*+={number}" + assert sut.github_issue_from_pr_body(text, "*+") == number + + @pytest.mark.parametrize( + "text", + [ + "", + "asdf", + "zzyzx=", + "=zzyzx", + "zzyzx=a", + "zzyzx=-1", + "zzyzx=a123", + "zzyzx=123a", + "zzyzx=1.2", + "a zzyzx=1234", + "zzyzx=1234 a", + pytest.param( + "zzyzx=1234", + marks=pytest.mark.xfail( + reason="make sure that the test would otherwise pass on valid text", + strict=True, + ), + ), + ], + ) + def test_returns_none_when_key_not_found_or_cannot_parse_int(self, text: str) -> None: + assert sut.github_issue_from_pr_body(text, "zzyzx") is None diff --git a/firebase-dataconnect/ci/logcat_error_report.py b/firebase-dataconnect/ci/logcat_error_report.py new file mode 100644 index 00000000000..fe316fd8a11 --- /dev/null +++ b/firebase-dataconnect/ci/logcat_error_report.py @@ -0,0 +1,175 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import argparse +import dataclasses +import logging +import pathlib +import re +import tempfile +import typing + +if typing.TYPE_CHECKING: + from _typeshed import SupportsWrite + +TEST_STARTED_TOKEN = "TestRunner: started:" # noqa: S105 +TEST_STARTED_PATTERN = r"(\W|^)" + re.escape(TEST_STARTED_TOKEN) + r"\s+(?P.*\S)" +TEST_FAILED_TOKEN = "TestRunner: failed:" # noqa: S105 +TEST_FAILED_PATTERN = r"(\W|^)" + re.escape(TEST_FAILED_TOKEN) + r"\s+(?P.*\S)" +TEST_FINISHED_TOKEN = "TestRunner: finished:" # noqa: S105 +TEST_FINISHED_PATTERN = r"(\W|^)" + re.escape(TEST_FINISHED_TOKEN) + r"\s+(?P.*\S)" + + +@dataclasses.dataclass +class TestResult: + test_name: str + output_file: pathlib.Path + passed: bool + + +def main() -> None: + args = parse_args() + logging.basicConfig(format="%(message)s", level=args.log_level) + + if args.work_dir is None: + work_temp_dir = tempfile.TemporaryDirectory("dd9rh9apdf") + work_dir = pathlib.Path(work_temp_dir.name) + logging.debug("Using temporary directory as work directory: %s", work_dir) + else: + work_temp_dir = None + work_dir = args.work_dir + logging.debug("Using specified directory as work directory: %s", work_dir) + work_dir.mkdir(parents=True, exist_ok=True) + + logging.info("Extracting test failures from %s", args.logcat_file) + test_results: list[TestResult] = [] + cur_test_result: TestResult | None = None + cur_test_result_output_file: SupportsWrite[str] | None = None + + with args.logcat_file.open("rt", encoding="utf8", errors="ignore") as logcat_file_handle: + for line in logcat_file_handle: + test_started_match = TEST_STARTED_TOKEN in line and re.search(TEST_STARTED_PATTERN, line) + if test_started_match: + test_name = test_started_match.group("name") + logging.debug('Found "Test Started" logcat line for test: %s', test_name) + if cur_test_result_output_file is not None: + cur_test_result_output_file.close() + test_output_file = work_dir / f"{len(test_results)}.txt" + cur_test_result = TestResult(test_name=test_name, output_file=test_output_file, passed=True) + test_results.append(cur_test_result) + cur_test_result_output_file = test_output_file.open("wt", encoding="utf8", errors="replace") + + if cur_test_result_output_file is not None: + cur_test_result_output_file.write(line) + + test_failed_match = TEST_FAILED_TOKEN in line and re.search(TEST_FAILED_PATTERN, line) + if test_failed_match: + test_name = test_failed_match.group("name") + logging.warning("FAILED TEST: %s", test_name) + if cur_test_result is None: + logging.warning( + "WARNING: failed test reported without matching test started: %s", test_name + ) + else: + cur_test_result.passed = False + + test_finished_match = TEST_FINISHED_TOKEN in line and re.search(TEST_FINISHED_PATTERN, line) + if test_finished_match: + test_name = test_finished_match.group("name") + logging.debug('Found "Test Finished" logcat line for test: %s', test_name) + if cur_test_result_output_file is not None: + cur_test_result_output_file.close() + cur_test_result_output_file = None + cur_test_result = None + + if cur_test_result_output_file is not None: + cur_test_result_output_file.close() + del cur_test_result_output_file + + passed_tests = [test_result for test_result in test_results if test_result.passed] + failed_tests = [test_result for test_result in test_results if not test_result.passed] + print_line( + f"Found results for {len(test_results)} tests: " + f"{len(passed_tests)} passed, {len(failed_tests)} failed" + ) + + if len(failed_tests) > 0: + fail_number = 0 + for failed_test_result in failed_tests: + fail_number += 1 + print_line("") + print_line(f"Failure {fail_number}/{len(failed_tests)}: {failed_test_result.test_name}:") + try: + with failed_test_result.output_file.open( + "rt", encoding="utf8", errors="ignore" + ) as test_output_file: + for line in test_output_file: + print_line(line.rstrip()) + except OSError: + logging.warning("WARNING: reading file failed: %s", failed_test_result.output_file) + continue + + if work_temp_dir is not None: + logging.debug("Cleaning up temporary directory: %s", work_dir) + del work_dir + del work_temp_dir + + +def print_line(line: str) -> None: + print(line) # noqa: T201 + + +class ParsedArgs(typing.Protocol): + logcat_file: pathlib.Path + log_level: int + work_dir: pathlib.Path | None + + +def parse_args() -> ParsedArgs: + arg_parser = argparse.ArgumentParser() + arg_parser.add_argument( + "--logcat-file", + required=True, + help="The text file containing the logcat logs to scan.", + ) + arg_parser.add_argument( + "--work-dir", + default=None, + help="The directory into which to write temporary files; " + "if not specified, use a temporary directory that is deleted " + "when this script completes; this is primarily intended for " + "developers of this script to use in testing and debugging", + ) + arg_parser.add_argument( + "--verbose", + action="store_const", + dest="log_level", + default=logging.INFO, + const=logging.DEBUG, + help="Include debug logging output", + ) + + parse_result = arg_parser.parse_args() + + parse_result.logcat_file = pathlib.Path(parse_result.logcat_file) + parse_result.work_dir = ( + None if parse_result.work_dir is None else pathlib.Path(parse_result.work_dir) + ) + return typing.cast("ParsedArgs", parse_result) + + +if __name__ == "__main__": + main() diff --git a/firebase-dataconnect/ci/logcat_error_report_test.py b/firebase-dataconnect/ci/logcat_error_report_test.py new file mode 100644 index 00000000000..59030ec7c4e --- /dev/null +++ b/firebase-dataconnect/ci/logcat_error_report_test.py @@ -0,0 +1,149 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import re + +import pytest + +import logcat_error_report as sut + + +class TestRegularExpressionPatterns: + @pytest.mark.parametrize( + "string", + [ + "", + "XTestRunner: started: fooTest1234", + "TestRunner: started:fooTest1234", + pytest.param( + "TestRunner: started: fooTest1234", + marks=pytest.mark.xfail( + reason="make sure that the test would otherwise pass on match", + strict=True, + ), + ), + ], + ) + def test_test_started_pattern_no_match(self, string: str) -> None: + assert re.search(sut.TEST_STARTED_PATTERN, string) is None + + @pytest.mark.parametrize( + ("string", "expected_name"), + [ + ("TestRunner: started: fooTest1234", "fooTest1234"), + (" TestRunner: started: fooTest1234", "fooTest1234"), + ("TestRunner: started: fooTest1234", "fooTest1234"), + ("TestRunner: started: fooTest1234 ", "fooTest1234"), + ("TestRunner: started: fooTest1234(abc.123)", "fooTest1234(abc.123)"), + ("TestRunner: started: a $ 2 ^ %% . ", "a $ 2 ^ %% ."), + pytest.param( + "i do not match the pattern", + None, + marks=pytest.mark.xfail( + reason="make sure that the test would otherwise pass on match", + strict=True, + ), + ), + ], + ) + def test_test_started_pattern_match(self, string: str, expected_name: str) -> None: + match = re.search(sut.TEST_STARTED_PATTERN, string) + assert match is not None + assert match.group("name") == expected_name + + @pytest.mark.parametrize( + "string", + [ + "", + "XTestRunner: finished: fooTest1234", + "TestRunner: finished:fooTest1234", + pytest.param( + "TestRunner: finished: fooTest1234", + marks=pytest.mark.xfail( + reason="make sure that the test would otherwise pass on match", + strict=True, + ), + ), + ], + ) + def test_test_finished_pattern_no_match(self, string: str) -> None: + assert re.search(sut.TEST_FINISHED_PATTERN, string) is None + + @pytest.mark.parametrize( + ("string", "expected_name"), + [ + ("TestRunner: finished: fooTest1234", "fooTest1234"), + (" TestRunner: finished: fooTest1234", "fooTest1234"), + ("TestRunner: finished: fooTest1234", "fooTest1234"), + ("TestRunner: finished: fooTest1234 ", "fooTest1234"), + ("TestRunner: finished: fooTest1234(abc.123)", "fooTest1234(abc.123)"), + ("TestRunner: finished: a $ 2 ^ %% . ", "a $ 2 ^ %% ."), + pytest.param( + "i do not match the pattern", + None, + marks=pytest.mark.xfail( + reason="make sure that the test would otherwise pass on match", + strict=True, + ), + ), + ], + ) + def test_test_finished_pattern_match(self, string: str, expected_name: str) -> None: + match = re.search(sut.TEST_FINISHED_PATTERN, string) + assert match is not None + assert match.group("name") == expected_name + + @pytest.mark.parametrize( + "string", + [ + "", + "XTestRunner: failed: fooTest1234", + "TestRunner: failed:fooTest1234", + pytest.param( + "TestRunner: failed: fooTest1234", + marks=pytest.mark.xfail( + reason="make sure that the test would otherwise pass on match", + strict=True, + ), + ), + ], + ) + def test_test_failed_pattern_no_match(self, string: str) -> None: + assert re.search(sut.TEST_FAILED_PATTERN, string) is None + + @pytest.mark.parametrize( + ("string", "expected_name"), + [ + ("TestRunner: failed: fooTest1234", "fooTest1234"), + (" TestRunner: failed: fooTest1234", "fooTest1234"), + ("TestRunner: failed: fooTest1234", "fooTest1234"), + ("TestRunner: failed: fooTest1234 ", "fooTest1234"), + ("TestRunner: failed: fooTest1234(abc.123)", "fooTest1234(abc.123)"), + ("TestRunner: failed: a $ 2 ^ %% . ", "a $ 2 ^ %% ."), + pytest.param( + "i do not match the pattern", + None, + marks=pytest.mark.xfail( + reason="make sure that the test would otherwise pass on match", + strict=True, + ), + ), + ], + ) + def test_test_failed_pattern_match(self, string: str, expected_name: str) -> None: + match = re.search(sut.TEST_FAILED_PATTERN, string) + assert match is not None + assert match.group("name") == expected_name diff --git a/firebase-dataconnect/ci/post_comment_for_job_results.py b/firebase-dataconnect/ci/post_comment_for_job_results.py new file mode 100644 index 00000000000..738ac2c9b9d --- /dev/null +++ b/firebase-dataconnect/ci/post_comment_for_job_results.py @@ -0,0 +1,229 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import argparse +import dataclasses +import logging +import pathlib +import subprocess +import tempfile +import typing + +from util import fetch_pr_info, pr_number_from_github_ref + +if typing.TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + +def main() -> None: + args = parse_args() + logging.basicConfig(format="%(message)s", level=logging.INFO) + + message_lines = tuple(generate_message_lines(args)) + + issue_url = f"{args.github_repository_html_url}/issues/{args.github_issue}" + logging.info("Posting the following comment to GitHub Issue %s", issue_url) + for line in message_lines: + logging.info(line) + + message_bytes = "\n".join(message_lines).encode("utf8", errors="replace") + with tempfile.TemporaryDirectory() as tempdir_path: + message_file = pathlib.Path(tempdir_path) / "message_text.txt" + message_file.write_bytes(message_bytes) + post_github_issue_comment( + issue_number=args.github_issue, + body_file=message_file, + github_repository=args.github_repository, + ) + + +def generate_message_lines(data: ParsedArgs) -> Iterable[str]: + logging.info("Extracting PR number from string: %s", data.github_ref) + pr_number = pr_number_from_github_ref(data.github_ref) + pr_title: str | None + if pr_number is None: + logging.info("No PR number extracted") + pr_title = None + else: + pr_info = fetch_pr_info( + pr_number=pr_number, + github_repository=data.github_repository, + ) + pr_title = pr_info.title + + if pr_number is not None: + yield ( + f"Posting from Pull Request {data.github_repository_html_url}/pull/{pr_number} ({pr_title})" + ) + + yield f"Result of workflow '{data.github_workflow}' at {data.github_sha}:" + + for job_result in data.job_results: + result_symbol = "✅" if job_result.result == "success" else "❌" + yield f" - {job_result.job_id}: {result_symbol} {job_result.result}" + + yield "" + yield f"{data.github_repository_html_url}/actions/runs/{data.github_run_id}" + + yield "" + yield ( + f"event_name=`{data.github_event_name}` " + f"run_id=`{data.github_run_id}` " + f"run_number=`{data.github_run_number}` " + f"run_attempt=`{data.github_run_attempt}`" + ) + + +def post_github_issue_comment( + issue_number: int, body_file: pathlib.Path, github_repository: str +) -> None: + gh_args = post_issue_comment_gh_args( + issue_number=issue_number, body_file=body_file, github_repository=github_repository + ) + gh_args = tuple(gh_args) + logging.info("Running command: %s", subprocess.list2cmdline(gh_args)) + subprocess.check_call(gh_args) # noqa: S603 + + +def post_issue_comment_gh_args( + issue_number: int, body_file: pathlib.Path, github_repository: str +) -> Iterable[str]: + yield "gh" + yield "issue" + + yield "comment" + yield str(issue_number) + yield "--body-file" + yield str(body_file) + yield "-R" + yield github_repository + + +@dataclasses.dataclass(frozen=True) +class JobResult: + job_id: str + result: str + + @classmethod + def parse(cls, s: str) -> JobResult: + colon_index = s.find(":") + if colon_index < 0: + raise ParseError( + "no colon (:) character found in job result specification, " + "which is required to delimit the job ID from the job result" + ) + job_id = s[:colon_index] + job_result = s[colon_index + 1 :] + return cls(job_id=job_id, result=job_result) + + +class ParsedArgs(typing.Protocol): + job_results: Sequence[JobResult] + github_issue: int + github_repository: str + github_event_name: str + github_ref: str + github_workflow: str + github_sha: str + github_repository_html_url: str + github_run_id: str + github_run_number: str + github_run_attempt: str + + +class ParseError(Exception): + pass + + +def parse_args() -> ParsedArgs: + arg_parser = argparse.ArgumentParser() + arg_parser.add_argument( + "job_results", + nargs="+", + help="The results of the jobs in question, of the form " + "'job-id:${{ needs.job-id.result }}' where 'job-id' is the id of the corresponding job " + "in the 'needs' section of the job.", + ) + arg_parser.add_argument( + "--github-issue", + type=int, + required=True, + help="The GitHub Issue number to which to post a comment", + ) + arg_parser.add_argument( + "--github-repository", + required=True, + help="The value of ${{ github.repository }} in the workflow", + ) + arg_parser.add_argument( + "--github-event-name", + required=True, + help="The value of ${{ github.event_name }} in the workflow", + ) + arg_parser.add_argument( + "--github-ref", + required=True, + help="The value of ${{ github.ref }} in the workflow", + ) + arg_parser.add_argument( + "--github-workflow", + required=True, + help="The value of ${{ github.workflow }} in the workflow", + ) + arg_parser.add_argument( + "--github-sha", + required=True, + help="The value of ${{ github.sha }} in the workflow", + ) + arg_parser.add_argument( + "--github-repository-html-url", + required=True, + help="The value of ${{ github.event.repository.html_url }} in the workflow", + ) + arg_parser.add_argument( + "--github-run-id", + required=True, + help="The value of ${{ github.run_id }} in the workflow", + ) + arg_parser.add_argument( + "--github-run-number", + required=True, + help="The value of ${{ github.run_number }} in the workflow", + ) + arg_parser.add_argument( + "--github-run-attempt", + required=True, + help="The value of ${{ github.run_attempt }} in the workflow", + ) + + parse_result = arg_parser.parse_args() + + job_results: list[JobResult] = [] + for job_result_str in parse_result.job_results: + try: + job_result = JobResult.parse(job_result_str) + except ParseError as e: + arg_parser.error(f"invalid job result specification: {job_result_str} ({e})") + typing.assert_never("the line above should have raised an exception") + else: + job_results.append(job_result) + parse_result.job_results = tuple(job_results) + + return typing.cast("ParsedArgs", parse_result) + + +if __name__ == "__main__": + main() diff --git a/firebase-dataconnect/ci/pyproject.toml b/firebase-dataconnect/ci/pyproject.toml new file mode 100644 index 00000000000..71800e3a086 --- /dev/null +++ b/firebase-dataconnect/ci/pyproject.toml @@ -0,0 +1,48 @@ +[project] +name = "Firebase Data Connect Android SDK Continuous Integration Tools" +requires-python = ">= 3.13" + +[tool.pytest.ini_options] +addopts = "--strict-markers" + +[tool.pyright] +include = ["**/*.py"] +typeCheckingMode = "strict" + +[tool.ruff] +line-length = 100 +indent-width = 2 + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "C901", # function is too complex + "COM812", # missing-trailing-comma + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D106", # Missing docstring in public nested class + "D107", # Missing docstring in `__init__` + "D203", # incorrect-blank-line-before-class + "D211", # no-blank-line-before-class + "D212", # multi-line-summary-second-line + "E501", # Line too long (will be fixed by the formatter) + "EM101", # Exception must not use a string literal, assign to variable first + "LOG015", # root-logger-call + "PLR0912", # Too many branches + "PLR0915", # Too many statements + "TRY003", # Avoid specifying long messages outside the exception class +] + +[tool.ruff.lint.per-file-ignores] +"*_test.py" = [ + "N801", # invalid-class-name + "S101", # Use of `assert` detected +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +docstring-code-format = true diff --git a/firebase-dataconnect/ci/requirements.txt b/firebase-dataconnect/ci/requirements.txt new file mode 100644 index 00000000000..3440d3b19f8 --- /dev/null +++ b/firebase-dataconnect/ci/requirements.txt @@ -0,0 +1,11 @@ +attrs==25.3.0 +hypothesis==6.131.0 +iniconfig==2.1.0 +nodeenv==1.9.1 +packaging==24.2 +pluggy==1.5.0 +pyright==1.1.399 +pytest==8.3.5 +ruff==0.11.5 +sortedcontainers==2.4.0 +typing_extensions==4.13.2 diff --git a/firebase-dataconnect/ci/util.py b/firebase-dataconnect/ci/util.py new file mode 100644 index 00000000000..9f71bdcb226 --- /dev/null +++ b/firebase-dataconnect/ci/util.py @@ -0,0 +1,60 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses +import json +import logging +import re +import subprocess +import typing + +if typing.TYPE_CHECKING: + from collections.abc import Iterable + + +@dataclasses.dataclass(frozen=True) +class GitHubPrInfo: + title: str + body: str + + +def fetch_pr_info(pr_number: int, github_repository: str) -> GitHubPrInfo: + gh_args = _fetch_pr_gh_args(pr_number=pr_number, github_repository=github_repository) + gh_args = tuple(gh_args) + logging.info("Running command: %s", subprocess.list2cmdline(gh_args)) + output_str = subprocess.check_output(gh_args, encoding="utf8", errors="replace") # noqa: S603 + logging.info("%s", output_str.strip()) + output = json.loads(output_str) + return GitHubPrInfo( + title=output["title"], + body=output["body"], + ) + + +def _fetch_pr_gh_args(pr_number: int, github_repository: str) -> Iterable[str]: + yield "gh" + yield "issue" + yield "view" + yield str(pr_number) + yield "--json" + yield "title,body" + yield "-R" + yield github_repository + + +def pr_number_from_github_ref(github_ref: str) -> int | None: + match = re.fullmatch("refs/pull/([0-9]+)/merge", github_ref) + return int(match.group(1)) if match else None diff --git a/firebase-dataconnect/demo/build.gradle.kts b/firebase-dataconnect/demo/build.gradle.kts index 78465a0df41..7c00d7d0445 100644 --- a/firebase-dataconnect/demo/build.gradle.kts +++ b/firebase-dataconnect/demo/build.gradle.kts @@ -19,12 +19,12 @@ import java.nio.charset.StandardCharsets plugins { // Use whichever versions of these dependencies suit your application. - // The versions shown here were the latest versions as of December 03, 2024. + // The versions shown here were the latest versions as of May 09, 2025. // Note, however, that the version of kotlin("plugin.serialization") _must_, // in general, match the version of kotlin("android"). - id("com.android.application") version "8.7.3" + id("com.android.application") version "8.9.2" id("com.google.gms.google-services") version "4.4.2" - val kotlinVersion = "2.1.0" + val kotlinVersion = "2.1.10" kotlin("android") version kotlinVersion kotlin("plugin.serialization") version kotlinVersion @@ -35,19 +35,19 @@ plugins { dependencies { // Use whichever versions of these dependencies suit your application. - // The versions shown here were the latest versions as of December 03, 2024. - implementation("com.google.firebase:firebase-dataconnect:16.0.0-beta03") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3") + // The versions shown here were the latest versions as of May 09, 2025. + implementation("com.google.firebase:firebase-dataconnect:16.0.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.0") implementation("androidx.appcompat:appcompat:1.7.0") - implementation("androidx.activity:activity-ktx:1.9.3") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7") + implementation("androidx.activity:activity-ktx:1.10.1") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.0") implementation("com.google.android.material:material:1.12.0") // The following code in this "dependencies" block can be omitted from customer // facing documentation as it is an implementation detail of this application. - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.3") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") implementation("io.kotest:kotest-property:5.9.1") implementation("io.kotest.extensions:kotest-property-arbs:2.1.2") } diff --git a/firebase-dataconnect/demo/firebase/dataconnect/dataconnect.yaml b/firebase-dataconnect/demo/firebase/dataconnect/dataconnect.yaml index 341a3fc587a..3a718496328 100644 --- a/firebase-dataconnect/demo/firebase/dataconnect/dataconnect.yaml +++ b/firebase-dataconnect/demo/firebase/dataconnect/dataconnect.yaml @@ -1,4 +1,4 @@ -specVersion: v1beta +specVersion: v1 serviceId: srv3ar8skbsza location: us-central1 schema: diff --git a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MyApplication.kt b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MyApplication.kt index eb70e8af475..1b6360efb58 100644 --- a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MyApplication.kt +++ b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MyApplication.kt @@ -61,7 +61,7 @@ class MyApplication : Application() { } ) - private val initialLogLevel = FirebaseDataConnect.logLevel + private val initialLogLevel = FirebaseDataConnect.logLevel.value private val connectorMutex = Mutex() private var connector: Ctry3q3tp6kzxConnector? = null @@ -70,7 +70,7 @@ class MyApplication : Application() { coroutineScope.launch { if (getDataConnectDebugLoggingEnabled()) { - FirebaseDataConnect.logLevel = LogLevel.DEBUG + FirebaseDataConnect.logLevel.value = LogLevel.DEBUG } } } @@ -102,7 +102,7 @@ class MyApplication : Application() { getSharedPreferences().all[SharedPrefsKeys.IS_DATA_CONNECT_LOGGING_ENABLED] as? Boolean ?: false suspend fun setDataConnectDebugLoggingEnabled(enabled: Boolean) { - FirebaseDataConnect.logLevel = if (enabled) LogLevel.DEBUG else initialLogLevel + FirebaseDataConnect.logLevel.value = if (enabled) LogLevel.DEBUG else initialLogLevel editSharedPreferences { putBoolean(SharedPrefsKeys.IS_DATA_CONNECT_LOGGING_ENABLED, enabled) } } diff --git a/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql b/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql index ffc8281e0fd..db53ca04dd2 100644 --- a/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql +++ b/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql @@ -88,3 +88,20 @@ query getPersonAuth($id: String!) @auth(level: USER_ANON) { age } } + +query getPersonWithPartialFailure($id: String!) @auth(level: PUBLIC) { + person1: person(id: $id) { name } + person2: person(id: $id) @check(expr: "false", message: "c8azjdwz2x") { name } +} + +mutation createPersonWithPartialFailure($id: String!, $name: String!) @auth(level: PUBLIC) { + person1: person_insert(data: { id: $id, name: $name }) + query @redact { person(id: $id) { id @check(expr: "false", message: "ecxpjy4qfy") } } + person2: person_insert(data: { id_expr: "uuidV4()", name: $name }) +} + +mutation createPersonWithPartialFailureInTransaction($id: String!, $name: String!) @auth(level: PUBLIC) @transaction { + person1: person_insert(data: { id: $id, name: $name }) + person2: person_insert(data: { id_expr: "uuidV4()", name: $name }) @check(expr: "false", message: "te36b3zkvn") +} + diff --git a/firebase-dataconnect/emulator/dataconnect/dataconnect.yaml b/firebase-dataconnect/emulator/dataconnect/dataconnect.yaml index a17c5213bc0..e66af00a793 100644 --- a/firebase-dataconnect/emulator/dataconnect/dataconnect.yaml +++ b/firebase-dataconnect/emulator/dataconnect/dataconnect.yaml @@ -1,4 +1,4 @@ -specVersion: "v1beta" +specVersion: "v1" serviceId: "sid2ehn9ct8te" location: "us-central1" schema: diff --git a/firebase-dataconnect/emulator/emulator.sh b/firebase-dataconnect/emulator/emulator.sh index 68ccf3331a7..c534aaacd12 100755 --- a/firebase-dataconnect/emulator/emulator.sh +++ b/firebase-dataconnect/emulator/emulator.sh @@ -16,9 +16,8 @@ set -euo pipefail -echo "[$0] PID=$$" - -readonly SELF_DIR="$(dirname "$0")" +export FIREBASE_DATACONNECT_POSTGRESQL_STRING='postgresql://postgres:postgres@localhost:5432?sslmode=disable' +echo "[$0] export FIREBASE_DATACONNECT_POSTGRESQL_STRING='$FIREBASE_DATACONNECT_POSTGRESQL_STRING'" readonly FIREBASE_ARGS=( firebase diff --git a/firebase-dataconnect/firebase-dataconnect.gradle.kts b/firebase-dataconnect/firebase-dataconnect.gradle.kts index 87ffe572916..914985ac18d 100644 --- a/firebase-dataconnect/firebase-dataconnect.gradle.kts +++ b/firebase-dataconnect/firebase-dataconnect.gradle.kts @@ -28,7 +28,6 @@ firebaseLibrary { libraryGroup = "dataconnect" testLab.enabled = false publishJavadoc = false - previewMode = "beta" releaseNotes { name.set("{{data_connect_short}}") versionName.set("data-connect") diff --git a/firebase-dataconnect/gradle.properties b/firebase-dataconnect/gradle.properties index 6cf883fd07e..0e34974b3c7 100644 --- a/firebase-dataconnect/gradle.properties +++ b/firebase-dataconnect/gradle.properties @@ -1,2 +1,2 @@ -version=16.0.0-beta05 -latestReleasedVersion=16.0.0-beta04 +version=16.0.3 +latestReleasedVersion=16.0.2 diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/resources/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableVersions.json b/firebase-dataconnect/gradleplugin/plugin/src/main/resources/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableVersions.json index 1854796df5e..81b6c26a579 100644 --- a/firebase-dataconnect/gradleplugin/plugin/src/main/resources/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableVersions.json +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/resources/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableVersions.json @@ -1,5 +1,5 @@ { - "defaultVersion": "1.7.7", + "defaultVersion": "2.6.2", "versions": [ { "version": "1.3.4", @@ -414,6 +414,366 @@ "os": "linux", "size": 25268376, "sha512DigestHex": "f55feb1ce670b4728bb30be138ab427545f77f63f9e11ee458096091c075699c647d5b768c642a1ef6b3569a2db87dbbed6f2fdaf64febd1154d1a730fda4a9c" + }, + { + "version": "1.8.0", + "os": "windows", + "size": 25903616, + "sha512DigestHex": "753a5e4be35c544317bcdbaaa860f079a9c9d8a24ca3db17fed601d30b64f083a9203fbb76718d23f3ad77f1556adfb5a4226751ec48c202bd227479c57d1ae9" + }, + { + "version": "1.8.0", + "os": "macos", + "size": 25469696, + "sha512DigestHex": "23c1e405b196799a7c84b9783ca110459bba3aa86405d2fc03d83f90530642d590b02cd06588a8428e0e7bb7d1c59e6d03113bbc5c41e12cff7a7c46674fc430" + }, + { + "version": "1.8.0", + "os": "linux", + "size": 25383064, + "sha512DigestHex": "9546bb62d54b67086847d3e129397f4cfceb5b715d64f0a1cc0a053b5dfe918e8372142b7e9bacd11dede77ddd17840058efb8ed6a7073e99fd5a684fdc57bea" + }, + { + "version": "1.8.1", + "os": "windows", + "size": 25904128, + "sha512DigestHex": "26dc987e38d5d07a910da647920cc2fe990f1da0db56206def71a9833f8eeb66272d8f32ba091b0d4d6e065a3d5cd950cd835a891895c6a55d735a6f240bf4b7" + }, + { + "version": "1.8.1", + "os": "macos", + "size": 25469696, + "sha512DigestHex": "d7bcb01912b1949a003fd0a7ebbc1bb42e79e97b7fd880ba9164b62e05d1ffb634662d97fd4664343e28780e69953aadecd5fb799a8f51229a4c0fbf552936ac" + }, + { + "version": "1.8.1", + "os": "linux", + "size": 25383064, + "sha512DigestHex": "2a28ba7947f84ede9062b5f5efa145b29862be0a8724ac6b6a4210f6823024d33363bd3379a6474965fbd60376baae9103ce7e4509db9d52c2b13886bca5df92" + }, + { + "version": "1.8.2", + "os": "windows", + "size": 25936384, + "sha512DigestHex": "f2aed75baaeed388d8fcd8a3d18e629f9ed012f60de0401bc365227094688f130ce7aa02db565002fe7b06a339b1cb133a7c87da365d480fb10cdb47d55c7dfa" + }, + { + "version": "1.8.2", + "os": "macos", + "size": 25506560, + "sha512DigestHex": "d4ac9e15f5a42fed28fd2f3cb2c80bc3f4def60f76517661323c502fa7a4b085bda3d26eb62cdcb630a13999e2fb0428ee45d335e20641229a9439cc60a9e798" + }, + { + "version": "1.8.2", + "os": "linux", + "size": 25415832, + "sha512DigestHex": "fec0fb97fb3ad30bdd9d0e3b65095e2dfdcfccd15e7c6ae9fe827ec1c3b5b9b592c80c59cadb3540e387d4adcf3560922094399c5ca3d162288a33403308104d" + }, + { + "version": "1.8.3", + "os": "windows", + "size": 25965568, + "sha512DigestHex": "9b6ded9ddac61d5f137ac65944409003906d621bb3a03ba6bf037b1aeddabf23f9410de6fbc05b8ea0c9afa2a8328bb02a57ed225f8ebaa3c8d6921755ad715c" + }, + { + "version": "1.8.3", + "os": "macos", + "size": 25535232, + "sha512DigestHex": "0c88a14ae64308e68957f5e79f9e20b4b946977187132dcc24193370c81b9117487fb0ee1c5be4e8f2368945add7ed37d6d97b015c3ea8232e09664458c8e802" + }, + { + "version": "1.8.3", + "os": "linux", + "size": 25448600, + "sha512DigestHex": "6734188ed2dc41fdf9922e152848d46a4bd6a30083c918ac0de5197e1f998f8dc2b4e190c47b02c176f68b93591132c29be142b4b61a36c81aec2358a81864c6" + }, + { + "version": "1.8.4", + "os": "windows", + "size": 26020352, + "sha512DigestHex": "a93277e32a3da54e9b6f9153fa056398567a659d0e5e23422c98bea4b480db6c8d49049135575031bad5e109fb06f82cb65d9131dc8b1ecf3a89039854aacc03" + }, + { + "version": "1.8.4", + "os": "macos", + "size": 25588480, + "sha512DigestHex": "7b8d4e605b6c31b0fa82dab74ac215cbe1745f84c83cb7fc71f7d8e0e697e449d50b91f2bc02a0e20eda870169a6f4ab0d65bfda088801f5245d853fc005e98e" + }, + { + "version": "1.8.4", + "os": "linux", + "size": 25501848, + "sha512DigestHex": "be03f18228074e584d8e4b758ad75d22f71b1f6222c4a3c858f89fd081a138dd27dc03bfb43bd85ac21fb0eba6aae464d429c5f3e166a7d86b9daa0a5f3e8644" + }, + { + "version": "1.8.5", + "os": "windows", + "size": 26031616, + "sha512DigestHex": "14a1f69ee9062bfd460573722f8315781ed12e16734f3d09d635881850e33b159930fee403d5d2bd5ec3644fef3d3d869fe003d3760a9b50223de7676a95502c" + }, + { + "version": "1.8.5", + "os": "macos", + "size": 25600768, + "sha512DigestHex": "ecf07c8ab3295e70c15128d5682269efcd89517dd9d068711a028097efd7bb7995611f7e4ee8312107d9b6e0a82b5565557bac658911f2f6ade8363356007183" + }, + { + "version": "1.8.5", + "os": "linux", + "size": 25514136, + "sha512DigestHex": "628fe32575e6caac56130ae8d286156a15b220d4365bef1c99e71b7cbcd7fa76022cca41e1670816723c9ae24c9db32ee41921075293b7a6500ea58c87e08a60" + }, + { + "version": "1.9.0", + "os": "windows", + "size": 26838016, + "sha512DigestHex": "2680b28d4aec2c401974f0f8cf4110b36974acb52fd7afd8bb23d9d9b619308f66352ed4c1e7b3fc492af29ab1e490b7cee879e9f22eaf7dbf96b4d8fa978b55" + }, + { + "version": "1.9.0", + "os": "macos", + "size": 26395392, + "sha512DigestHex": "39e214d639a747f7af7cdad4aff0ed2af0bb6c3544b3c2daf95f43706039e47a3f0492e8a1c13c352e873f3f9c747af1cb4fb80470007c817bd11431905428e7" + }, + { + "version": "1.9.0", + "os": "linux", + "size": 26308760, + "sha512DigestHex": "02f3fa7c1b98876073c909b259c199ed60c6e1d89cf832d41585cc04a7314a1692cb66709c37fde45be680da6ddc14cd11619d9f80350f0eb180a0de9b8ef2da" + }, + { + "version": "1.9.1", + "os": "windows", + "size": 26846720, + "sha512DigestHex": "ef4014f58df5a9ab6e4c5d1a33a384d93affc7b9bb971a99a2672c05147d0cb64005ecda241a96a37984a9b6657ab900c3b26f2c7a5cfb32a24a2591afc9b94d" + }, + { + "version": "1.9.1", + "os": "macos", + "size": 26403584, + "sha512DigestHex": "ec90bd0c21feb5310f528e80b6415ad028a4f09a2ea99e2a1eca135d27a533ceda8f778c007f06e5ecbc5be0e32a2b13b4d8460ac5ad073e4216e7eb643f0b5d" + }, + { + "version": "1.9.1", + "os": "linux", + "size": 26316952, + "sha512DigestHex": "631cb41c1bf8ab18563180112e9f114d96a525884cf96914b69fdcfd861d32aa852b06b1c675f911148d238a4cd4c3d574ef8f73e66444bf5b8f1199da059e13" + }, + { + "version": "1.9.2", + "os": "windows", + "size": 26846208, + "sha512DigestHex": "1faedad0979fe1228b51f8a3b23f97468e2718cee08dfb65ec6b21e2a3cec99eee060cf9ee6560e80c7a5059437378a7e02f1afa77a8f4e931ef1a3294951fd2" + }, + { + "version": "1.9.2", + "os": "macos", + "size": 26403584, + "sha512DigestHex": "4be6adab666688334879a72519337b851b494d63b7059e71f4c23b443d31f442c50bd69837af3897a9a38ac7aae1aebb8d97d33111e520110dac24c2a7f29a1d" + }, + { + "version": "1.9.2", + "os": "linux", + "size": 26316952, + "sha512DigestHex": "0bd4fcb4bdb66aab502000c19df824fc8df192906e712edd0000192beeb0ba3d29f2a627fc3097735dbab2d9bcbc3715e12fb8afb26e17c2e3e40103357b49ae" + }, + { + "version": "2.0.0", + "os": "windows", + "size": 26884096, + "sha512DigestHex": "720fc3b4be8da10e684eae252f962eed8d30f370068414642ab8e7974f9a370bc1b5038f47d2acc77f266ca224c0c22afc1177ae4510dca7d249ba423844abae" + }, + { + "version": "2.0.0", + "os": "macos", + "size": 26440448, + "sha512DigestHex": "bf812823aedd709c88e7c757412fe7dedfe1e05f8526863d714637f5adcf3e894c9780b759b919a6f96ab5a5ef579d60e57969cbc3b4f0ff7bc91697311c85fd" + }, + { + "version": "2.0.0", + "os": "linux", + "size": 26353816, + "sha512DigestHex": "37a3b7f4fca4a71c5c336fd7edebd4472bb51b31ff4abac80b31f8ef55831726b439f9f7ef1152cee6006019cd22cef51572874e5e6680962826fbc4c6166530" + }, + { + "version": "2.1.0", + "os": "windows", + "size": 26884096, + "sha512DigestHex": "299525effb3d645868aadd82cbeac28a528d0ecbbbf78d0e830478d03618de9e7356fdfd5599d9cd29fa86250c3543d656f9fe10e2d35c771cb42b31e904e534" + }, + { + "version": "2.1.0", + "os": "macos", + "size": 26440448, + "sha512DigestHex": "fac00b743d08eb9f0fbb4adceac63633a5364152551916ad2c787f4e5a3f8f51a5cd50350374279e04776cbf9e8d9b89d289c6f9e06f153820f9345b0a32733b" + }, + { + "version": "2.1.0", + "os": "linux", + "size": 26357912, + "sha512DigestHex": "75661fd65f8fcb78b8ac3585816c18e821fe0b993a94c5fa7f8f928f5827da3112ecee0de246c07bffcead4291172beeccf27708f1fcf19720bae9352885768b" + }, + { + "version": "2.2.0", + "os": "windows", + "size": 26982912, + "sha512DigestHex": "6e861a3603300474536c314318826d6609aeda04476996f1695ebe0dd4b508653dabee91935fcc2054d8c40965a630618241c7fbc55f490a3c109f240e69684d" + }, + { + "version": "2.2.0", + "os": "macos", + "size": 26538752, + "sha512DigestHex": "018d73ae8c4bdc9032125bd01715a181253e411e6b0b507324a897b549efab25d9aaea11d3fe7b9e9dab38592b3967cd46df3650a5a76fb7ea80e9bea3225812" + }, + { + "version": "2.2.0", + "os": "linux", + "size": 26452120, + "sha512DigestHex": "aab441e47115489b968f90d588b08f7a5848cef79849653bde84ffea5612404ec142c3bc87c6a466036a7e3e4228eff667a56ba633d3af93be0082ef4819c25f" + }, + { + "version": "2.3.0", + "os": "windows", + "size": 27728384, + "sha512DigestHex": "c822af6c3096923c8448619e1196effcd41f3c4ef1c073743529cc661ca7360aab7e4f09802178d704464dd46d84a66e8a1302d2afe33cc593eebede5d4d08da" + }, + { + "version": "2.3.0", + "os": "macos", + "size": 27271936, + "sha512DigestHex": "0e38ba4ec5ac2ad7e4f35f0898006ac465b727ec0342353eb93272422ffa947d63cf41fe0734b427cc6d3101c52aefa1ed4e6309c0dbfb7c4df67c46dfab9bcd" + }, + { + "version": "2.3.0", + "os": "linux", + "size": 27185304, + "sha512DigestHex": "206058ffc632139a725ccbb4673edc309f18cef9ffb1c207fa4d38b8f8ada333b710e5360e931022d98a101b06ab9b0e03c8212f7f50c27687e105c97ea5401a" + }, + { + "version": "2.3.1", + "os": "windows", + "size": 27729408, + "sha512DigestHex": "86b7f8ebfe786827937bd57d51d1187deae3d910e3146e5fb0e8504cc651a1b984ed529efb6d3741e07e92d43d9605968c8b503c912d22d28d765d9ffddb78c2" + }, + { + "version": "2.3.1", + "os": "macos", + "size": 27271936, + "sha512DigestHex": "42a047df82f0cbd89ff266abd460ed2899d92e77f7e5a9a8c4746d478cb1358c7ee91d2dff60eb1873fd59c3647c8f6808ac410a6c952b654f45df50b74ba6e7" + }, + { + "version": "2.3.1", + "os": "linux", + "size": 27185304, + "sha512DigestHex": "1f7be86d576bbd7e562c612d36ad49b896618003bc94604e4809a07773a1bcde9b7383ed0dc39e6c2f540f0f49b107867c72cd1333fecc339a82e72bb821af8a" + }, + { + "version": "2.4.0", + "os": "windows", + "size": 27774464, + "sha512DigestHex": "0d3b7570daea7b4f5879ed6d8ca0e1e8e4c55b5e617dbf571aa2b479fad3a1ba207d40bdd4ceba5ab557fdb101b9b7149bdeac2c4cb0d9d7885b4afc22df16a2" + }, + { + "version": "2.4.0", + "os": "macos", + "size": 27316992, + "sha512DigestHex": "c0fd2654d874528af6dc5296d191bf6d7edca56695b2604e4fe6bff553f81a2871e348686914af22378e3fbfe10bf9c66a41d031521a617fe6c0f6728bf0ba69" + }, + { + "version": "2.4.0", + "os": "linux", + "size": 27230360, + "sha512DigestHex": "1edce919f3c2496a1c837b2b84360cca12dfc3b725bdc694a503bea17faacf2f8e32a955eab540d479180f4aab1c46ee82b7c0e752595ed1aeeabc9c01dd5c67" + }, + { + "version": "2.4.1", + "os": "windows", + "size": 27808768, + "sha512DigestHex": "bbb903abcb1648a2cbab252389a488d370cd2604222176e0fa1465117fb0b84a9f96638608c465f3fe04a10c3cd90446c5f10ddd596c22ec7157642bf2cbab3a" + }, + { + "version": "2.4.1", + "os": "macos", + "size": 27349760, + "sha512DigestHex": "0be315b5c4301386eb5fa4b227cc8fb11c5987c7408f9296895d2fdb850b56cf0e28f42af1266c627f6548eb8e8c90374bfb725bc8becd57d379161e84e059cd" + }, + { + "version": "2.4.1", + "os": "linux", + "size": 27267224, + "sha512DigestHex": "a5385cdfc5f5463208660be674ffe02f69c9ae65d1d91c060b2a9f81b3a9313978f2616fe34371fd02cc9e4cb3449eb28b32d359c9dd121c5a3ea0a1e89b746a" + }, + { + "version": "2.5.0", + "os": "windows", + "size": 27836416, + "sha512DigestHex": "e0cc3f99361cc7d561742975642fd8b071ceca7c47a6951181d76c6ec087348dbd95bcac6fbacf255d16396bc739f47ec914e18297b3f66d66233f812c7692bc" + }, + { + "version": "2.5.0", + "os": "macos", + "size": 27378432, + "sha512DigestHex": "1b1f66a22147b71f1d3ca29f895ddd4763555279d6dffb720044697cfa9e33c202471dd9d0db14e7a95f89f6084d4a35e30d7be53c3cdd9cb1b56dce763e7425" + }, + { + "version": "2.5.0", + "os": "linux", + "size": 27295896, + "sha512DigestHex": "b1f25d94af56e1e774df336cddf3bcb54dc855935d131bf3fb8dc69aa9ab814f6e6dfefb684a5637c0b6f84f57586162b32b7402d87522c19fb66e717905825a" + }, + { + "version": "2.6.0", + "os": "windows", + "size": 27875328, + "sha512DigestHex": "8f7079028b3c86ad9b2ef33b6840f7ab4c900d1468e32d6e26109f49838703b41ede4726141479dc053ec81fd82f8fbe33f94d4b865c5a5a798f36b211b0bf21" + }, + { + "version": "2.6.0", + "os": "macos", + "size": 27415296, + "sha512DigestHex": "80b5d3199f78034b65bc9ab05977d91999dccb29528c3e9658a7e36b1a22bf5ed05b5d90d08ad1793baa74994ec8685386a22f9e407b3c0a4e06c5968738663c" + }, + { + "version": "2.6.0", + "os": "linux", + "size": 27332760, + "sha512DigestHex": "c90d691c798466b3f820cc0738b96e030ab2683fe9ad1019692aec58f282e1cd6835c645d229f1a32d899713c97e2451d5e3b29e9468060711f3d08e2312afec" + }, + { + "version": "2.6.1", + "os": "windows", + "size": 27875328, + "sha512DigestHex": "06f07a5d8cfdd942393959c21e1511a3d0de6f26d67be78b4dbfdacf021938db089ad4d4945cdeb130e6af4d4a87a93a579954d36a64dac7b58f6ade99c79de3" + }, + { + "version": "2.6.1", + "os": "macos", + "size": 27415296, + "sha512DigestHex": "63461d8ef2c41a85b9ebb52fc566e44db8209d2ae750b7137d50529ff1c3861889e2a75ec380e5eaf5e4bf8094594b881d66fe572885bec2456bb33190fe653b" + }, + { + "version": "2.6.1", + "os": "linux", + "size": 27332760, + "sha512DigestHex": "668cb4261010ac6bad7136c6a92fdd19c7064a0baf7adf1d11221240a8f3f3c595e74690f048d35ac7f0b5fb9d2753f11dd12a8821dbb39a93763f2332ec44c6" + }, + { + "version": "2.6.2", + "os": "windows", + "size": 27961856, + "sha512DigestHex": "e087e9c3adb0be169d0b7bc352132075326e6af2bbea501c0c152c54f07b4dddd9813590a8d7c8b1d4eb058241127ce995f7dd0441095398abbd0c366a345e28" + }, + { + "version": "2.6.2", + "os": "macos", + "size": 27501312, + "sha512DigestHex": "02dcd439ba1b6cf1562487478a1de68bafee6e7ff3a21248f20ad336cbe6e06b6e73e250d87a76c1384d003a5f50f85b350d9e91688b5810e65d1b45d56807ea" + }, + { + "version": "2.6.2", + "os": "linux", + "size": 27414680, + "sha512DigestHex": "b57c031bda4a6de5d16f6262c9016ee1117ce84e30b439f68325fcdef58a5ee85e1f60b8d3172e498ea52cf3c0dba4d4ff5ff1bf92e8e99e331a6602d5f62e35" } ] } \ No newline at end of file diff --git a/firebase-dataconnect/scripts/generateApiTxtFile.sh b/firebase-dataconnect/scripts/generateApiTxtFile.sh new file mode 100755 index 00000000000..cb572c07140 --- /dev/null +++ b/firebase-dataconnect/scripts/generateApiTxtFile.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +readonly PROJECT_ROOT_DIR="$(dirname "$0")/../.." + +readonly args=( + "${PROJECT_ROOT_DIR}/gradlew" + "-p" + "${PROJECT_ROOT_DIR}" + "--configure-on-demand" + "$@" + ":firebase-dataconnect:generateApiTxtFile" +) + +echo "${args[*]}" +exec "${args[@]}" diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AuthIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AuthIntegrationTest.kt index ba314d0f9db..280d5209065 100644 --- a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AuthIntegrationTest.kt +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AuthIntegrationTest.kt @@ -21,6 +21,7 @@ import com.google.firebase.dataconnect.core.FirebaseDataConnectInternal import com.google.firebase.dataconnect.testutil.DataConnectBackend import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase import com.google.firebase.dataconnect.testutil.InProcessDataConnectGrpcServer +import com.google.firebase.dataconnect.testutil.awaitAuthReady import com.google.firebase.dataconnect.testutil.newInstance import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect import com.google.firebase.dataconnect.testutil.schemas.PersonSchema @@ -127,6 +128,7 @@ class AuthIntegrationTest : DataConnectIntegrationTestBase() { grpcServer.metadatas.map { it.get(firebaseAuthTokenHeader) }.toCollection(authTokens) } val dataConnect = dataConnectFactory.newInstance(auth.app, grpcServer) + (dataConnect as FirebaseDataConnectInternal).awaitAuthReady() val operationName = Arb.dataConnect.operationName().next(rs) val queryRef = dataConnect.query(operationName, Unit, serializer(), serializer()) @@ -155,6 +157,7 @@ class AuthIntegrationTest : DataConnectIntegrationTestBase() { grpcServer.metadatas.map { it.get(firebaseAuthTokenHeader) }.toCollection(authTokens) } val dataConnect = dataConnectFactory.newInstance(auth.app, grpcServer) + (dataConnect as FirebaseDataConnectInternal).awaitAuthReady() val operationName = Arb.dataConnect.operationName().next(rs) val mutationRef = dataConnect.mutation(operationName, Unit, serializer(), serializer()) @@ -202,7 +205,7 @@ class AuthIntegrationTest : DataConnectIntegrationTestBase() { } private suspend fun signIn() { - (personSchema.dataConnect as FirebaseDataConnectInternal).awaitAuthReady() + personSchema.dataConnect.awaitAuthReady() val authResult = auth.run { signInAnonymously().await() } withClue("authResult.user returned from signInAnonymously()") { authResult.user.shouldNotBeNull() diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/GrpcMetadataIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/GrpcMetadataIntegrationTest.kt index 8ff7715ddb9..394c2195332 100644 --- a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/GrpcMetadataIntegrationTest.kt +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/GrpcMetadataIntegrationTest.kt @@ -23,7 +23,6 @@ import com.google.android.gms.tasks.Tasks import com.google.firebase.appcheck.AppCheckProvider import com.google.firebase.appcheck.AppCheckProviderFactory import com.google.firebase.appcheck.FirebaseAppCheck -import com.google.firebase.dataconnect.core.FirebaseDataConnectInternal import com.google.firebase.dataconnect.generated.GeneratedConnector import com.google.firebase.dataconnect.generated.GeneratedMutation import com.google.firebase.dataconnect.generated.GeneratedQuery @@ -32,6 +31,8 @@ import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase import com.google.firebase.dataconnect.testutil.DataConnectTestAppCheckToken import com.google.firebase.dataconnect.testutil.FirebaseAuthBackend import com.google.firebase.dataconnect.testutil.InProcessDataConnectGrpcServer +import com.google.firebase.dataconnect.testutil.awaitAppCheckReady +import com.google.firebase.dataconnect.testutil.awaitAuthReady import com.google.firebase.dataconnect.testutil.getFirebaseAppIdFromStrings import com.google.firebase.dataconnect.testutil.newInstance import com.google.firebase.dataconnect.util.SuspendingLazy @@ -138,7 +139,7 @@ class GrpcMetadataIntegrationTest : DataConnectIntegrationTestBase() { fun executeQueryShouldNotSendAuthMetadataWhenNotLoggedIn() = runTest { val grpcServer = inProcessDataConnectGrpcServer.newInstance() val dataConnect = dataConnectFactory.newInstance(grpcServer) - (dataConnect as FirebaseDataConnectInternal).awaitAuthReady() + dataConnect.awaitAuthReady() val queryRef = dataConnect.query("qryfyk7yfppfe", Unit, serializer(), serializer()) val metadatasJob = async { grpcServer.metadatas.first() } @@ -151,7 +152,7 @@ class GrpcMetadataIntegrationTest : DataConnectIntegrationTestBase() { fun executeMutationShouldNotSendAuthMetadataWhenNotLoggedIn() = runTest { val grpcServer = inProcessDataConnectGrpcServer.newInstance() val dataConnect = dataConnectFactory.newInstance(grpcServer) - (dataConnect as FirebaseDataConnectInternal).awaitAuthReady() + dataConnect.awaitAuthReady() val mutationRef = dataConnect.mutation("mutckjpte9v9j", Unit, serializer(), serializer()) val metadatasJob = async { grpcServer.metadatas.first() } @@ -165,7 +166,7 @@ class GrpcMetadataIntegrationTest : DataConnectIntegrationTestBase() { fun executeQueryShouldSendAuthMetadataWhenLoggedIn() = runTest { val grpcServer = inProcessDataConnectGrpcServer.newInstance() val dataConnect = dataConnectFactory.newInstance(grpcServer) - (dataConnect as FirebaseDataConnectInternal).awaitAuthReady() + dataConnect.awaitAuthReady() val queryRef = dataConnect.query("qryyarwrxe2fv", Unit, serializer(), serializer()) val metadatasJob = async { grpcServer.metadatas.first() } firebaseAuthSignIn(dataConnect) @@ -179,7 +180,7 @@ class GrpcMetadataIntegrationTest : DataConnectIntegrationTestBase() { fun executeMutationShouldSendAuthMetadataWhenLoggedIn() = runTest { val grpcServer = inProcessDataConnectGrpcServer.newInstance() val dataConnect = dataConnectFactory.newInstance(grpcServer) - (dataConnect as FirebaseDataConnectInternal).awaitAuthReady() + dataConnect.awaitAuthReady() val mutationRef = dataConnect.mutation("mutayn7as5k7d", Unit, serializer(), serializer()) val metadatasJob = async { grpcServer.metadatas.first() } @@ -194,7 +195,7 @@ class GrpcMetadataIntegrationTest : DataConnectIntegrationTestBase() { fun executeQueryShouldNotSendAuthMetadataAfterLogout() = runTest { val grpcServer = inProcessDataConnectGrpcServer.newInstance() val dataConnect = dataConnectFactory.newInstance(grpcServer) - (dataConnect as FirebaseDataConnectInternal).awaitAuthReady() + dataConnect.awaitAuthReady() val queryRef = dataConnect.query("qryyarwrxe2fv", Unit, serializer(), serializer()) val metadatasJob1 = async { grpcServer.metadatas.first() } val metadatasJob2 = async { grpcServer.metadatas.take(2).last() } @@ -212,7 +213,7 @@ class GrpcMetadataIntegrationTest : DataConnectIntegrationTestBase() { fun executeMutationShouldNotSendAuthMetadataAfterLogout() = runTest { val grpcServer = inProcessDataConnectGrpcServer.newInstance() val dataConnect = dataConnectFactory.newInstance(grpcServer) - (dataConnect as FirebaseDataConnectInternal).awaitAuthReady() + dataConnect.awaitAuthReady() val mutationRef = dataConnect.mutation("mutvw945ag3vv", Unit, serializer(), serializer()) val metadatasJob1 = async { grpcServer.metadatas.first() } @@ -233,7 +234,7 @@ class GrpcMetadataIntegrationTest : DataConnectIntegrationTestBase() { // appcheck token is sent at all. val grpcServer = inProcessDataConnectGrpcServer.newInstance() val dataConnect = dataConnectFactory.newInstance(grpcServer) - (dataConnect as FirebaseDataConnectInternal).awaitAppCheckReady() + dataConnect.awaitAppCheckReady() val queryRef = dataConnect.query("qrybbeekpkkck", Unit, serializer(), serializer()) val metadatasJob = async { grpcServer.metadatas.first() } @@ -248,7 +249,7 @@ class GrpcMetadataIntegrationTest : DataConnectIntegrationTestBase() { // appcheck token is sent at all. val grpcServer = inProcessDataConnectGrpcServer.newInstance() val dataConnect = dataConnectFactory.newInstance(grpcServer) - (dataConnect as FirebaseDataConnectInternal).awaitAppCheckReady() + dataConnect.awaitAppCheckReady() val mutationRef = dataConnect.mutation("mutbs7hhxk39c", Unit, serializer(), serializer()) val metadatasJob = async { grpcServer.metadatas.first() } @@ -262,7 +263,7 @@ class GrpcMetadataIntegrationTest : DataConnectIntegrationTestBase() { fun executeQueryShouldSendAppCheckMetadataWhenAppCheckIsEnabled() = runTest { val grpcServer = inProcessDataConnectGrpcServer.newInstance() val dataConnect = dataConnectFactory.newInstance(grpcServer) - (dataConnect as FirebaseDataConnectInternal).awaitAppCheckReady() + dataConnect.awaitAppCheckReady() val queryRef = dataConnect.query("qryyarwrxe2fv", Unit, serializer(), serializer()) val metadatasJob = async { grpcServer.metadatas.first() } val appCheck = FirebaseAppCheck.getInstance(dataConnect.app) @@ -277,7 +278,7 @@ class GrpcMetadataIntegrationTest : DataConnectIntegrationTestBase() { fun executeMutationShouldSendAppCheckMetadataWhenAppCheckIsEnabled() = runTest { val grpcServer = inProcessDataConnectGrpcServer.newInstance() val dataConnect = dataConnectFactory.newInstance(grpcServer) - (dataConnect as FirebaseDataConnectInternal).awaitAppCheckReady() + dataConnect.awaitAppCheckReady() val mutationRef = dataConnect.mutation("mutz4hzqzpgb4", Unit, serializer(), serializer()) val metadatasJob = async { grpcServer.metadatas.first() } diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/OperationExecutionErrorsIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/OperationExecutionErrorsIntegrationTest.kt new file mode 100644 index 00000000000..f592f58800a --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/OperationExecutionErrorsIntegrationTest.kt @@ -0,0 +1,287 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema.CreatePersonMutation +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema.GetPersonQuery +import com.google.firebase.dataconnect.testutil.shouldSatisfy +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldHaveAtLeastSize +import io.kotest.property.Arb +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.next +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.serializer +import org.junit.Test + +class OperationExecutionErrorsIntegrationTest : DataConnectIntegrationTestBase() { + + private val personSchema: PersonSchema by lazy { PersonSchema(dataConnectFactory) } + private val dataConnect: FirebaseDataConnect by lazy { personSchema.dataConnect } + + @Test + fun executeQueryFailsWithNullDataNonEmptyErrors() = runTest { + val queryRef = + dataConnect.query( + operationName = GetPersonQuery.operationName, + variables = Arb.incompatibleVariables().next(rs), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { queryRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = "jwdbzka4k5", + expectedCause = null, + expectedRawData = null, + expectedData = null, + errorsValidator = { it.shouldHaveAtLeastSize(1) }, + ) + } + + @Test + fun executeMutationFailsWithNullDataNonEmptyErrors() = runTest { + val mutationRef = + dataConnect.mutation( + operationName = CreatePersonMutation.operationName, + variables = Arb.incompatibleVariables().next(rs), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { mutationRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedCause = null, + expectedRawData = null, + expectedData = null, + errorsValidator = { it.shouldHaveAtLeastSize(1) }, + ) + } + + @Test + fun executeQueryFailsWithNonNullDataEmptyErrorsButDecodingResponseDataFails() = runTest { + val id = Arb.alphanumericString().next() + val queryRef = + dataConnect.query( + operationName = GetPersonQuery.operationName, + variables = GetPersonQuery.Variables(id), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { queryRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "decoding data from the server's response failed", + expectedCause = SerializationException::class, + expectedRawData = mapOf("person" to null), + expectedData = null, + expectedErrors = emptyList(), + ) + } + + @Test + fun executeMutationFailsWithNonNullDataEmptyErrorsButDecodingResponseDataFails() = runTest { + val id = Arb.alphanumericString().next() + val name = Arb.alphanumericString().next() + val mutationRef = + dataConnect.mutation( + operationName = CreatePersonMutation.operationName, + variables = CreatePersonMutation.Variables(id, name), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { mutationRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "decoding data from the server's response failed", + expectedCause = SerializationException::class, + expectedRawData = mapOf("person_insert" to mapOf("id" to id)), + expectedData = null, + expectedErrors = emptyList(), + ) + } + + @Test + fun executeQueryFailsWithNonNullDataNonEmptyErrorsDecodingSucceeds() = runTest { + val id = Arb.alphanumericString().next() + val name = Arb.alphanumericString().next() + personSchema.createPerson(CreatePersonMutation.Variables(id, name)).execute() + val queryRef = + dataConnect.query( + operationName = "getPersonWithPartialFailure", + variables = GetPersonWithPartialFailureVariables(id), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { queryRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = "c8azjdwz2x", + expectedCause = null, + expectedRawData = mapOf("person1" to mapOf("name" to name), "person2" to null), + expectedData = GetPersonWithPartialFailureData(name), + errorsValidator = { it.shouldHaveAtLeastSize(1) }, + ) + } + + @Test + fun executeMutationFailsWithNonNullDataNonEmptyErrorsDecodingSucceeds() = runTest { + val id = Arb.alphanumericString().next() + val name = Arb.alphanumericString().next() + val mutationRef = + dataConnect.mutation( + operationName = "createPersonWithPartialFailure", + variables = CreatePersonWithPartialFailureVariables(id = id, name = name), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { mutationRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = "ecxpjy4qfy", + expectedCause = null, + expectedRawData = mapOf("person1" to mapOf("id" to id), "person2" to null), + expectedData = CreatePersonWithPartialFailureData(id), + errorsValidator = { it.shouldHaveAtLeastSize(1) }, + ) + } + + @Test + fun executeQueryFailsWithNonNullDataNonEmptyErrorsDecodingFails() = runTest { + val id = Arb.alphanumericString().next() + val name = Arb.alphanumericString().next() + personSchema.createPerson(CreatePersonMutation.Variables(id, name)).execute() + val queryRef = + dataConnect.query( + operationName = "getPersonWithPartialFailure", + variables = GetPersonWithPartialFailureVariables(id), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { queryRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = "c8azjdwz2x", + expectedCause = null, + expectedRawData = mapOf("person1" to mapOf("name" to name), "person2" to null), + expectedData = null, + errorsValidator = { it.shouldHaveAtLeastSize(1) }, + ) + } + + @Test + fun executeMutationFailsWithNonNullDataNonEmptyErrorsDecodingFails() = runTest { + val id = Arb.alphanumericString().next() + val name = Arb.alphanumericString().next() + val mutationRef = + dataConnect.mutation( + operationName = "createPersonWithPartialFailure", + variables = CreatePersonWithPartialFailureVariables(id = id, name = name), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { mutationRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = "ecxpjy4qfy", + expectedCause = null, + expectedRawData = mapOf("person1" to mapOf("id" to id), "person2" to null), + expectedData = null, + errorsValidator = { it.shouldHaveAtLeastSize(1) }, + ) + } + + @Test + fun executeMutationFailsWithNonNullDataNonEmptyErrorsDecodingFailsInTransaction() = runTest { + val id = Arb.alphanumericString().next() + val name = Arb.alphanumericString().next() + val mutationRef = + dataConnect.mutation( + operationName = "createPersonWithPartialFailureInTransaction", + variables = CreatePersonWithPartialFailureVariables(id = id, name = name), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { mutationRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = "te36b3zkvn", + expectedCause = null, + expectedRawData = mapOf("person1" to null, "person2" to null), + expectedData = null, + errorsValidator = { it.shouldHaveAtLeastSize(1) }, + ) + } + + @Serializable private data class IncompatibleVariables(val jwdbzka4k5: String) + + @Serializable private data class IncompatibleData(val btzjhbfz7h: String) + + private fun Arb.Companion.incompatibleVariables(string: Arb = Arb.alphanumericString()) = + string.map { IncompatibleVariables(it) } + + @Serializable private data class GetPersonWithPartialFailureVariables(val id: String) + + @Serializable + private data class GetPersonWithPartialFailureData(val person1: Person, val person2: Nothing?) { + constructor(person1Name: String) : this(Person(person1Name), null) + + @Serializable private data class Person(val name: String) + } + + @Serializable + private data class CreatePersonWithPartialFailureVariables(val id: String, val name: String) + + @Serializable + private data class CreatePersonWithPartialFailureData( + val person1: Person, + val person2: Nothing? + ) { + constructor(person1Id: String) : this(Person(person1Id), null) + + @Serializable private data class Person(val id: String) + } +} diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/QuerySubscriptionIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/QuerySubscriptionIntegrationTest.kt index 6b0f0c4ff47..ad0a0e73e96 100644 --- a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/QuerySubscriptionIntegrationTest.kt +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/QuerySubscriptionIntegrationTest.kt @@ -483,9 +483,7 @@ class QuerySubscriptionIntegrationTest : DataConnectIntegrationTestBase() { val noName1Query = schema.getPerson(personId).withDataDeserializer(serializer()) - backgroundScope.launch { noName1Query.subscribe().flow.collect() } - - noName1Query.execute() + keepCacheAlive(noName1Query) schema.updatePerson(id = personId, name = "Name1").execute() @@ -571,7 +569,7 @@ class QuerySubscriptionIntegrationTest : DataConnectIntegrationTestBase() { */ private suspend fun TestScope.keepCacheAlive(query: QueryRef<*, *>) { val cachePrimed = SuspendingFlag() - backgroundScope.launch { query.subscribe().flow.onEach { cachePrimed.set() }.collect() } + backgroundScope.launch { query.subscribe().flow.collect { cachePrimed.set() } } cachePrimed.await() } diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/FirebaseDataConnectInternalExts.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/FirebaseDataConnectInternalExts.kt new file mode 100644 index 00000000000..f73f1d8b50d --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/FirebaseDataConnectInternalExts.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.core.FirebaseDataConnectInternal + +suspend fun FirebaseDataConnect.awaitAuthReady() = + (this as FirebaseDataConnectInternal).awaitAuthReady() + +suspend fun FirebaseDataConnect.awaitAppCheckReady() = + (this as FirebaseDataConnectInternal).awaitAppCheckReady() diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchema.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchema.kt index 21d9dc9d4dc..a6ed2cfd47a 100644 --- a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchema.kt +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchema.kt @@ -54,6 +54,8 @@ class PersonSchema(val dataConnect: FirebaseDataConnect) { ) object CreatePersonMutation { + const val operationName = "createPerson" + @Serializable data class Data(val person_insert: PersonKey) { @Serializable data class PersonKey(val id: String) @@ -63,7 +65,7 @@ class PersonSchema(val dataConnect: FirebaseDataConnect) { fun createPerson(variables: CreatePersonMutation.Variables) = dataConnect.mutation( - operationName = "createPerson", + operationName = CreatePersonMutation.operationName, variables = variables, dataDeserializer = serializer(), variablesSerializer = serializer(), @@ -141,6 +143,8 @@ class PersonSchema(val dataConnect: FirebaseDataConnect) { fun deletePerson(id: String) = deletePerson(DeletePersonMutation.Variables(id = id)) object GetPersonQuery { + const val operationName = "getPerson" + @Serializable data class Data(val person: Person?) { @Serializable data class Person(val name: String, val age: Int? = null) @@ -151,7 +155,7 @@ class PersonSchema(val dataConnect: FirebaseDataConnect) { fun getPerson(variables: GetPersonQuery.Variables) = dataConnect.query( - operationName = "getPerson", + operationName = GetPersonQuery.operationName, variables = variables, dataDeserializer = serializer(), variablesSerializer = serializer(), diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectError.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectError.kt deleted file mode 100644 index 07e87c212a8..00000000000 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectError.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.dataconnect - -import java.util.Objects - -// See https://spec.graphql.org/draft/#sec-Errors -internal class DataConnectError( - val message: String, - val path: List, - val locations: List, -) { - - override fun hashCode(): Int = Objects.hash(message, path, locations) - - override fun equals(other: Any?): Boolean = - (other is DataConnectError) && - other.message == message && - other.path == path && - other.locations == locations - - override fun toString(): String = - StringBuilder() - .also { sb -> - path.forEachIndexed { segmentIndex, segment -> - when (segment) { - is PathSegment.Field -> { - if (segmentIndex != 0) { - sb.append('.') - } - sb.append(segment.field) - } - is PathSegment.ListIndex -> { - sb.append('[') - sb.append(segment.index) - sb.append(']') - } - } - } - - if (locations.isNotEmpty()) { - if (sb.isNotEmpty()) { - sb.append(' ') - } - sb.append("at ") - sb.append(locations.joinToString(", ")) - } - - if (path.isNotEmpty() || locations.isNotEmpty()) { - sb.append(": ") - } - - sb.append(message) - } - .toString() - - sealed interface PathSegment { - @JvmInline - value class Field(val field: String) : PathSegment { - override fun toString(): String = field - } - - @JvmInline - value class ListIndex(val index: Int) : PathSegment { - override fun toString(): String = index.toString() - } - } - - class SourceLocation(val line: Int, val column: Int) { - override fun hashCode(): Int = Objects.hash(line, column) - override fun equals(other: Any?): Boolean = - other is SourceLocation && other.line == line && other.column == column - override fun toString(): String = "$line:$column" - } -} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectOperationException.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectOperationException.kt new file mode 100644 index 00000000000..cb1ef1b8526 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectOperationException.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +/** + * The exception thrown when an error occurs in the execution of a Firebase Data Connect operation + * (that is, a query or mutation). This exception means that a response was, indeed, received from + * the backend but either the response included one or more errors or the client could not + * successfully process the result (for example, decoding the response data failed). + */ +public open class DataConnectOperationException( + message: String, + cause: Throwable? = null, + public val response: DataConnectOperationFailureResponse<*>, +) : DataConnectException(message, cause) diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectOperationFailureResponse.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectOperationFailureResponse.kt new file mode 100644 index 00000000000..386fb8bce9d --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectOperationFailureResponse.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +// Googlers see go/dataconnect:sdk:partial-errors for design details. + +/** The data and errors provided by the backend in the response message. */ +public interface DataConnectOperationFailureResponse { + + /** + * The raw, un-decoded data provided by the backend in the response message. Will be `null` if, + * and only if, the backend explicitly sent null for the data or if the data was not present in + * the response. + * + * Otherwise, the values in the map will be one of the following: + * * `null` + * * [String] + * * [Boolean] + * * [Double] + * * [List] containing any of the types in this list of types + * * [Map] with [String] keys and values of of the types in this list of types + * + * Consider using [toJson] to get a higher-level object. + */ + public val rawData: Map? + + /** + * The list of errors provided by the backend in the response message; may be empty. + * + * See https://spec.graphql.org/draft/#sec-Errors for details. + */ + public val errors: List + + /** + * The successfully-decoded [rawData], if any. + * + * Will be `null` if [rawData] is `null`, or if decoding the [rawData] failed. + */ + public val data: Data? + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at any + * time. Therefore, the only recommended usage of the returned string is debugging and/or logging. + * Namely, parsing the returned string or storing the returned string in non-volatile storage + * should generally be avoided in order to be robust in case that the string representation + * changes. + * + * @return a string representation of this object, which includes the class name and the values of + * all public properties. + */ + override fun toString(): String + + /** + * Information about the error, as provided in the response payload from the backend. + * + * See https://spec.graphql.org/draft/#sec-Errors for details. + */ + public interface ErrorInfo { + /** The error's message. */ + public val message: String + + /** The path of the field in the response data to which this error relates. */ + public val path: List + + /** + * Compares this object with another object for equality. + * + * @param other The object to compare to this for equality. + * @return true if, and only if, the other object is an instance of the same implementation of + * [ErrorInfo] whose public properties compare equal using the `==` operator to the + * corresponding properties of this object. + */ + override fun equals(other: Any?): Boolean + + /** + * Calculates and returns the hash code for this object. + * + * The hash code is _not_ guaranteed to be stable across application restarts. + * + * @return the hash code for this object, that incorporates the values of this object's public + * properties. + */ + override fun hashCode(): Int + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at + * any time. Therefore, the only recommended usage of the returned string is debugging and/or + * logging. Namely, parsing the returned string or storing the returned string in non-volatile + * storage should generally be avoided in order to be robust in case that the string + * representation changes. + * + * @return a string representation of this object, suitable for logging the error indicated by + * this object; it will include the path formatted into a human-readable string (if the path is + * not empty), and the message. + */ + override fun toString(): String + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectPathSegment.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectPathSegment.kt new file mode 100644 index 00000000000..3bae99ef78f --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectPathSegment.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +/** The "segment" of a path to a field in the response data. */ +public sealed interface DataConnectPathSegment { + + /** A named field in a path to a field in the response data. */ + @JvmInline + public value class Field(public val field: String) : DataConnectPathSegment { + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at + * any time. Therefore, the only recommended usage of the returned string is debugging and/or + * logging. Namely, parsing the returned string or storing the returned string in non-volatile + * storage should generally be avoided in order to be robust in case that the string + * representation changes. + * + * @return returns simply [field]. + */ + override fun toString(): String = field + } + + /** An index of a list in a path to a field in the response data. */ + @JvmInline + public value class ListIndex(public val index: Int) : DataConnectPathSegment { + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at + * any time. Therefore, the only recommended usage of the returned string is debugging and/or + * logging. Namely, parsing the returned string or storing the returned string in non-volatile + * storage should generally be avoided in order to be robust in case that the string + * representation changes. + * + * @return returns simply the string representation of [index]. + */ + override fun toString(): String = index.toString() + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedData.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedData.kt index 638fffb913e..332cd5251e4 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedData.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedData.kt @@ -22,7 +22,7 @@ import kotlinx.serialization.encoding.Decoder internal class DataConnectUntypedData( val data: Map?, - val errors: List + val errors: List ) { override fun equals(other: Any?) = diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectCredentialsTokenManager.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectCredentialsTokenManager.kt index e6f4049c359..b67db33b36c 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectCredentialsTokenManager.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectCredentialsTokenManager.kt @@ -30,7 +30,6 @@ import com.google.firebase.inject.Provider import com.google.firebase.internal.api.FirebaseNoSignedInUserException import com.google.firebase.util.nextAlphanumericString import java.lang.ref.WeakReference -import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.coroutineContext import kotlin.random.Random import kotlinx.coroutines.CancellationException @@ -46,24 +45,22 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.yield /** Base class that shares logic for managing the Auth token and AppCheck token. */ internal sealed class DataConnectCredentialsTokenManager( private val deferredProvider: com.google.firebase.inject.Deferred, parentCoroutineScope: CoroutineScope, - blockingDispatcher: CoroutineDispatcher, + private val blockingDispatcher: CoroutineDispatcher, protected val logger: Logger, ) { val instanceId: String get() = logger.nameWithId - private val _providerAvailable = MutableStateFlow(false) - val providerAvailable: StateFlow = _providerAvailable.asStateFlow() - @Suppress("LeakingThis") private val weakThis = WeakReference(this) private val coroutineScope = @@ -78,58 +75,51 @@ internal sealed class DataConnectCredentialsTokenManager( } ) - init { - // Call `whenAvailable()` on a non-main thread because it accesses SharedPreferences, which - // performs disk i/o, violating the StrictMode policy android.os.strictmode.DiskReadViolation. - val coroutineName = CoroutineName("k6rwgqg9gh $instanceId whenAvailable") - coroutineScope.launch(coroutineName + blockingDispatcher) { - deferredProvider.whenAvailable(DeferredProviderHandlerImpl(weakThis)) - } - } - - private interface ProviderProvider { - val provider: T? - } - private sealed interface State { + /** + * State indicating that the object has just been created and [initialize] has not yet been + * called. + */ + object New : State + + /** + * State indicating that [initialize] has been invoked but the token provider is not (yet?) + * available. + */ + data class Initialized(override val forceTokenRefresh: Boolean) : + StateWithForceTokenRefresh { + constructor() : this(false) + } + /** State indicating that [close] has been invoked. */ object Closed : State - /** State indicating that there is no outstanding "get token" request. */ - class Idle( - - /** - * The [InternalAuthProvider] or [InteropAppCheckTokenProvider]; may be null if the deferred - * has not yet given us a provider. - */ - override val provider: T?, - + sealed interface StateWithForceTokenRefresh : State { /** The value to specify for `forceRefresh` on the next invocation of [getToken]. */ val forceTokenRefresh: Boolean - ) : State, ProviderProvider + } - /** State indicating that there _is_ an outstanding "get token" request. */ - class Active( + sealed interface StateWithProvider : State { + /** The token provider, [InternalAuthProvider] or [InteropAppCheckTokenProvider] */ + val provider: T + } - /** - * The [InternalAuthProvider] or [InteropAppCheckTokenProvider] that is performing the "get - * token" request. - */ + /** State indicating that there is no outstanding "get token" request. */ + data class Idle(override val provider: T, override val forceTokenRefresh: Boolean) : + StateWithProvider, StateWithForceTokenRefresh + + /** State indicating that there _is_ an outstanding "get token" request. */ + data class Active( override val provider: T, /** The job that is performing the "get token" request. */ val job: Deferred>> - ) : State, ProviderProvider + ) : StateWithProvider } - /** - * The current state of this object. The value should only be changed in a compare-and-swap loop - * in order to be thread-safe. Such a loop should call `yield()` on each iteration to allow other - * coroutines to run on the thread. - */ - private val state = - AtomicReference>(State.Idle(provider = null, forceTokenRefresh = false)) + /** The current state of this object. */ + private val state = MutableStateFlow>(State.New) /** * Adds the token listener to the given provider. @@ -151,6 +141,34 @@ internal sealed class DataConnectCredentialsTokenManager( */ protected abstract suspend fun getToken(provider: T, forceRefresh: Boolean): GetTokenResult + /** + * Initializes this object. + * + * Before calling this method, the _only_ other methods that are allowed to be called on this + * object are [awaitTokenProvider] and [close]. + * + * This method may only be called once; subsequent calls result in an exception. + */ + fun initialize() { + logger.debug { "initialize()" } + + state.update { currentState -> + when (currentState) { + is State.New -> State.Initialized() + is State.Closed -> + throw IllegalStateException("initialize() cannot be called after close()") + else -> throw IllegalStateException("initialize() has already been called") + } + } + + // Call `whenAvailable()` on a non-main thread because it accesses SharedPreferences, which + // performs disk i/o, violating the StrictMode policy android.os.strictmode.DiskReadViolation. + val coroutineName = CoroutineName("k6rwgqg9gh $instanceId whenAvailable") + coroutineScope.launch(coroutineName + blockingDispatcher) { + deferredProvider.whenAvailable(DeferredProviderHandlerImpl(weakThis)) + } + } + /** * Closes this object, releasing its resources, unregistering any registered listeners, and * cancelling any in-flight token requests. @@ -163,55 +181,92 @@ internal sealed class DataConnectCredentialsTokenManager( */ fun close() { logger.debug { "close()" } + weakThis.clear() coroutineScope.cancel() - setClosedState() - } - - // This function must ONLY be called from close(). - private fun setClosedState() { - while (true) { - val oldState = state.get() - val providerProvider: ProviderProvider = - when (oldState) { - is State.Closed -> return - is State.Idle -> oldState - is State.Active -> oldState - } - if (state.compareAndSet(oldState, State.Closed)) { - providerProvider.provider?.let { removeTokenListener(it) } - break + val oldState = state.getAndUpdate { State.Closed } + when (oldState) { + is State.New -> {} + is State.Initialized -> {} + is State.Closed -> {} + is State.StateWithProvider -> { + removeTokenListener(oldState.provider) } } } + /** + * Suspends until the token provider becomes available to this object. + * + * This method _may_ be called before [initialize], which is the method that asynchronously gets + * the token provider. + * + * If [close] has been invoked, or is invoked _before_ a token provider becomes available, then + * this method returns normally, as if a token provider _had_ become available. + */ + suspend fun awaitTokenProvider() { + logger.debug { "awaitTokenProvider() start" } + val currentState = + state + .filter { + when (it) { + State.Closed -> true + is State.New -> false + is State.Initialized -> false + is State.Idle -> true + is State.Active -> true + } + } + .first() + logger.debug { "awaitTokenProvider() done: currentState=$currentState" } + } + /** * Sets a flag to force-refresh the token upon the next call to [getToken]. * * If [close] has been called, this method does nothing. */ - suspend fun forceRefresh() { + fun forceRefresh() { logger.debug { "forceRefresh()" } - while (true) { - val oldState = state.get() - val oldStateProviderProvider = - when (oldState) { - is State.Closed -> return - is State.Idle -> oldState - is State.Active -> { - val message = "needs token refresh (wgrwbrvjxt)" - oldState.job.cancel(message, ForceRefresh(message)) - oldState + val oldState = + state.getAndUpdate { currentState -> + val newState = + when (currentState) { + is State.Closed -> State.Closed + is State.New -> currentState + is State.Initialized -> currentState.copy(forceTokenRefresh = true) + is State.Idle -> currentState.copy(forceTokenRefresh = true) + is State.Active -> State.Idle(currentState.provider, forceTokenRefresh = true) + } + + check( + newState is State.New || + newState is State.Closed || + newState is State.StateWithForceTokenRefresh + ) { + "internal error gbazc7qr66: newState should have been Closed or " + + "StateWithForceTokenRefresh, but got: $newState" + } + if (newState is State.StateWithForceTokenRefresh) { + check(newState.forceTokenRefresh) { + "internal error fnzwyrsez2: newState.forceTokenRefresh should have been true" } } - val newState = State.Idle(oldStateProviderProvider.provider, forceTokenRefresh = true) - if (state.compareAndSet(oldState, newState)) { - break + newState } - yield() + when (oldState) { + is State.Closed -> {} + is State.New -> + throw IllegalStateException("initialize() must be called before forceRefresh()") + is State.Initialized -> {} + is State.Idle -> {} + is State.Active -> { + val message = "needs token refresh (wgrwbrvjxt)" + oldState.job.cancel(message, ForceRefresh(message)) + } } } @@ -246,10 +301,12 @@ internal sealed class DataConnectCredentialsTokenManager( logger.debug { "$invocationId getToken(requestId=$requestId)" } while (true) { val attemptSequenceNumber = nextSequenceNumber() - val oldState = state.get() + val oldState = state.value val newState: State.Active = when (oldState) { + is State.New -> + throw IllegalStateException("initialize() must be called before getToken()") is State.Closed -> { logger.debug { "$invocationId getToken() throws CredentialsTokenManagerClosedException" + @@ -257,13 +314,13 @@ internal sealed class DataConnectCredentialsTokenManager( } throw CredentialsTokenManagerClosedException(this) } - is State.Idle -> { - if (oldState.provider === null) { - logger.debug { - "$invocationId getToken() returns null (token provider is not (yet?) available)" - } - return null + is State.Initialized -> { + logger.debug { + "$invocationId getToken() returns null (token provider is not (yet?) available)" } + return null + } + is State.Idle -> { newActiveState(invocationId, oldState.provider, oldState.forceTokenRefresh) } is State.Active -> { @@ -341,33 +398,38 @@ internal sealed class DataConnectCredentialsTokenManager( logger.debug { "onProviderAvailable(newProvider=$newProvider)" } addTokenListener(newProvider) - while (true) { - val oldState = state.get() - val newState = - when (oldState) { - is State.Closed -> { - logger.debug { - "onProviderAvailable(newProvider=$newProvider)" + - " unregistering token listener that was just added" - } - removeTokenListener(newProvider) - break - } - is State.Idle -> State.Idle(newProvider, oldState.forceTokenRefresh) - is State.Active -> { - val newProviderClassName = newProvider::class.qualifiedName - val message = "a new provider $newProviderClassName is available (symhxtmazy)" - oldState.job.cancel(message, NewProvider(message)) - State.Idle(newProvider, forceTokenRefresh = false) - } + val oldState = + state.getAndUpdate { currentState -> + when (currentState) { + is State.New -> currentState + is State.Closed -> State.Closed + is State.Initialized -> State.Idle(newProvider, currentState.forceTokenRefresh) + is State.Idle -> State.Idle(newProvider, currentState.forceTokenRefresh) + is State.Active -> State.Idle(newProvider, forceTokenRefresh = false) } + } - if (state.compareAndSet(oldState, newState)) { - break + when (oldState) { + is State.New -> + throw IllegalStateException( + "internal error sdpzwhmhd3: " + + "initialize() should have been called before onProviderAvailable()" + ) + is State.Closed -> { + logger.debug { + "onProviderAvailable(newProvider=$newProvider)" + + " unregistering token listener that was just added" + } + removeTokenListener(newProvider) + } + is State.Initialized -> {} + is State.Idle -> {} + is State.Active -> { + val newProviderClassName = newProvider::class.qualifiedName + val message = "a new provider $newProviderClassName is available (symhxtmazy)" + oldState.job.cancel(message, NewProvider(message)) } } - - _providerAvailable.value = true } /** diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClient.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClient.kt index b2d2270056b..d0ebe3f84c3 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClient.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClient.kt @@ -17,15 +17,16 @@ package com.google.firebase.dataconnect.core import com.google.firebase.dataconnect.* -import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.toDataConnectError +import com.google.firebase.dataconnect.DataConnectPathSegment +import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.toErrorInfoImpl import com.google.firebase.dataconnect.core.LoggerGlobals.warn import com.google.firebase.dataconnect.util.ProtoUtil.decodeFromStruct +import com.google.firebase.dataconnect.util.ProtoUtil.toCompactString import com.google.firebase.dataconnect.util.ProtoUtil.toMap import com.google.protobuf.ListValue import com.google.protobuf.Struct import com.google.protobuf.Value import google.firebase.dataconnect.proto.GraphqlError -import google.firebase.dataconnect.proto.SourceLocation import google.firebase.dataconnect.proto.executeMutationRequest import google.firebase.dataconnect.proto.executeQueryRequest import io.grpc.Status @@ -52,7 +53,7 @@ internal class DataConnectGrpcClient( data class OperationResult( val data: Struct?, - val errors: List, + val errors: List, ) suspend fun executeQuery( @@ -74,7 +75,7 @@ internal class DataConnectGrpcClient( return OperationResult( data = if (response.hasData()) response.data else null, - errors = response.errorsList.map { it.toDataConnectError() } + errors = response.errorsList.map { it.toErrorInfoImpl() } ) } @@ -97,11 +98,11 @@ internal class DataConnectGrpcClient( return OperationResult( data = if (response.hasData()) response.data else null, - errors = response.errorsList.map { it.toDataConnectError() } + errors = response.errorsList.map { it.toErrorInfoImpl() } ) } - private suspend inline fun T.retryOnGrpcUnauthenticatedError( + private inline fun T.retryOnGrpcUnauthenticatedError( requestId: String, kotlinMethodName: String, block: T.() -> R @@ -138,50 +139,72 @@ internal class DataConnectGrpcClient( internal object DataConnectGrpcClientGlobals { private fun ListValue.toPathSegment() = valuesList.map { - when (val kind = it.kindCase) { - Value.KindCase.STRING_VALUE -> DataConnectError.PathSegment.Field(it.stringValue) - Value.KindCase.NUMBER_VALUE -> - DataConnectError.PathSegment.ListIndex(it.numberValue.toInt()) - else -> DataConnectError.PathSegment.Field("invalid PathSegment kind: $kind") + when (it.kindCase) { + Value.KindCase.STRING_VALUE -> DataConnectPathSegment.Field(it.stringValue) + Value.KindCase.NUMBER_VALUE -> DataConnectPathSegment.ListIndex(it.numberValue.toInt()) + // The cases below are expected to never occur; however, implement some logic for them + // to avoid things like throwing exceptions in those cases. + Value.KindCase.NULL_VALUE -> DataConnectPathSegment.Field("null") + Value.KindCase.BOOL_VALUE -> DataConnectPathSegment.Field(it.boolValue.toString()) + Value.KindCase.LIST_VALUE -> DataConnectPathSegment.Field(it.listValue.toCompactString()) + Value.KindCase.STRUCT_VALUE -> + DataConnectPathSegment.Field(it.structValue.toCompactString()) + else -> DataConnectPathSegment.Field(it.toString()) } } - private fun List.toSourceLocations(): List = - buildList { - this@toSourceLocations.forEach { - add(DataConnectError.SourceLocation(line = it.line, column = it.column)) - } - } - - fun GraphqlError.toDataConnectError() = - DataConnectError( + fun GraphqlError.toErrorInfoImpl() = + DataConnectOperationFailureResponseImpl.ErrorInfoImpl( message = message, path = path.toPathSegment(), - this.locationsList.toSourceLocations() ) fun DataConnectGrpcClient.OperationResult.deserialize( deserializer: DeserializationStrategy, serializersModule: SerializersModule?, - ): T = + ): T { if (deserializer === DataConnectUntypedData) { - @Suppress("UNCHECKED_CAST") - DataConnectUntypedData(data?.toMap(), errors) as T - } else if (data === null) { - if (errors.isNotEmpty()) { - throw DataConnectException("operation failed: errors=$errors") - } else { - throw DataConnectException("no data included in result") - } - } else if (errors.isNotEmpty()) { - throw DataConnectException("operation failed: errors=$errors (data=$data)") - } else { - try { - decodeFromStruct(data, deserializer, serializersModule) - } catch (dataConnectException: DataConnectException) { - throw dataConnectException - } catch (throwable: Throwable) { - throw DataConnectException("decoding response data failed: $throwable", throwable) - } + @Suppress("UNCHECKED_CAST") return DataConnectUntypedData(data?.toMap(), errors) as T + } + + val decodedData: Result? = + data?.let { data -> runCatching { decodeFromStruct(data, deserializer, serializersModule) } } + + if (errors.isNotEmpty()) { + throw DataConnectOperationException( + "operation encountered errors during execution: $errors", + response = + DataConnectOperationFailureResponseImpl( + rawData = data?.toMap(), + data = decodedData?.getOrNull(), + errors = errors, + ) + ) + } + + if (decodedData == null) { + throw DataConnectOperationException( + "no data was included in the response from the server", + response = + DataConnectOperationFailureResponseImpl( + rawData = null, + data = null, + errors = emptyList(), + ) + ) } + + return decodedData.getOrElse { exception -> + throw DataConnectOperationException( + "decoding data from the server's response failed: ${exception.message}", + cause = exception, + response = + DataConnectOperationFailureResponseImpl( + rawData = data?.toMap(), + data = null, + errors = emptyList(), + ) + ) + } + } } diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImpl.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImpl.kt new file mode 100644 index 00000000000..85434a64b47 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImpl.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.firebase.dataconnect.DataConnectOperationFailureResponse +import com.google.firebase.dataconnect.DataConnectOperationFailureResponse.ErrorInfo +import com.google.firebase.dataconnect.DataConnectPathSegment +import java.util.Objects + +internal class DataConnectOperationFailureResponseImpl( + override val rawData: Map?, + override val data: Data?, + override val errors: List +) : DataConnectOperationFailureResponse { + + override fun toString(): String = + "DataConnectOperationFailureResponseImpl(rawData=$rawData, data=$data, errors=$errors)" + + internal class ErrorInfoImpl( + override val message: String, + override val path: List, + ) : ErrorInfo { + + override fun equals(other: Any?): Boolean = + other is ErrorInfoImpl && other.message == message && other.path == path + + override fun hashCode(): Int = Objects.hash("ErrorInfoImpl", message, path) + + override fun toString(): String = buildString { + path.forEachIndexed { segmentIndex, segment -> + when (segment) { + is DataConnectPathSegment.Field -> { + if (segmentIndex != 0) { + append('.') + } + append(segment.field) + } + is DataConnectPathSegment.ListIndex -> { + append('[').append(segment.index).append(']') + } + } + } + + if (path.isNotEmpty()) { + append(": ") + } + + append(message) + } + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectImpl.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectImpl.kt index d9bee50b89d..b422167950d 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectImpl.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectImpl.kt @@ -33,8 +33,6 @@ import com.google.firebase.dataconnect.querymgr.LiveQueries import com.google.firebase.dataconnect.querymgr.LiveQuery import com.google.firebase.dataconnect.querymgr.QueryManager import com.google.firebase.dataconnect.querymgr.RegisteredDataDeserializer -import com.google.firebase.dataconnect.util.NullableReference -import com.google.firebase.dataconnect.util.SuspendingLazy import com.google.firebase.util.nextAlphanumericString import com.google.protobuf.Struct import java.util.concurrent.Executor @@ -54,11 +52,9 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.SerializationStrategy import kotlinx.serialization.modules.SerializersModule @@ -72,8 +68,8 @@ internal interface FirebaseDataConnectInternal : FirebaseDataConnect { val nonBlockingExecutor: Executor val nonBlockingDispatcher: CoroutineDispatcher - val lazyGrpcClient: SuspendingLazy - val lazyQueryManager: SuspendingLazy + val grpcClient: DataConnectGrpcClient + val queryManager: QueryManager suspend fun awaitAuthReady() suspend fun awaitAppCheckReady() @@ -120,183 +116,197 @@ internal class FirebaseDataConnectImpl( } ) - private val authProviderAvailable = MutableStateFlow(false) - private val appCheckProviderAvailable = MutableStateFlow(false) - - // Protects `closed`, `grpcClient`, `emulatorSettings`, and `queryManager`. - private val mutex = Mutex() - - // All accesses to this variable _must_ have locked `mutex`. - private var emulatorSettings: EmulatedServiceSettings? = null - - // All accesses to this variable _must_ have locked `mutex`. - private var closed = false - private val dataConnectAuth: DataConnectAuth = DataConnectAuth( - deferredAuthProvider = deferredAuthProvider, - parentCoroutineScope = coroutineScope, - blockingDispatcher = blockingDispatcher, - logger = Logger("DataConnectAuth").apply { debug { "created by $instanceId" } }, - ) + deferredAuthProvider = deferredAuthProvider, + parentCoroutineScope = coroutineScope, + blockingDispatcher = blockingDispatcher, + logger = Logger("DataConnectAuth").apply { debug { "created by $instanceId" } }, + ) + .apply { initialize() } override suspend fun awaitAuthReady() { - authProviderAvailable.first { it } - } - - init { - val name = CoroutineName("DataConnectAuth isProviderAvailable pipe for $instanceId") - coroutineScope.launch(name) { - dataConnectAuth.providerAvailable.collect { isProviderAvailable -> - logger.debug { "authProviderAvailable=$isProviderAvailable" } - authProviderAvailable.value = isProviderAvailable - } - } + dataConnectAuth.awaitTokenProvider() } private val dataConnectAppCheck: DataConnectAppCheck = DataConnectAppCheck( - deferredAppCheckTokenProvider = deferredAppCheckProvider, - parentCoroutineScope = coroutineScope, - blockingDispatcher = blockingDispatcher, - logger = Logger("DataConnectAppCheck").apply { debug { "created by $instanceId" } }, - ) + deferredAppCheckTokenProvider = deferredAppCheckProvider, + parentCoroutineScope = coroutineScope, + blockingDispatcher = blockingDispatcher, + logger = Logger("DataConnectAppCheck").apply { debug { "created by $instanceId" } }, + ) + .apply { initialize() } override suspend fun awaitAppCheckReady() { - appCheckProviderAvailable.first { it } + dataConnectAppCheck.awaitTokenProvider() } - init { - val name = CoroutineName("DataConnectAppCheck isProviderAvailable pipe for $instanceId") - coroutineScope.launch(name) { - dataConnectAppCheck.providerAvailable.collect { isProviderAvailable -> - logger.debug { "appCheckProviderAvailable=$isProviderAvailable" } - appCheckProviderAvailable.value = isProviderAvailable - } + private sealed interface State { + data class New(val emulatorSettings: EmulatedServiceSettings?) : State { + constructor() : this(null) } + data class Initialized( + val grpcRPCs: DataConnectGrpcRPCs, + val grpcClient: DataConnectGrpcClient, + val queryManager: QueryManager + ) : State + data class Closing(val grpcRPCs: DataConnectGrpcRPCs, val closeJob: Deferred) : State + object Closed : State } - private val lazyGrpcRPCs = - SuspendingLazy(mutex) { - if (closed) throw IllegalStateException("FirebaseDataConnect instance has been closed") - - data class DataConnectBackendInfo( - val host: String, - val sslEnabled: Boolean, - val isEmulator: Boolean - ) - val backendInfoFromSettings = - DataConnectBackendInfo( - host = settings.host, - sslEnabled = settings.sslEnabled, - isEmulator = false - ) - val backendInfoFromEmulatorSettings = - emulatorSettings?.run { - DataConnectBackendInfo(host = "$host:$port", sslEnabled = false, isEmulator = true) - } - val backendInfo = - if (backendInfoFromEmulatorSettings == null) { - backendInfoFromSettings - } else { - if (!settings.isDefaultHost()) { - logger.warn( - "Host has been set in DataConnectSettings and useEmulator, " + - "emulator host will be used." - ) + private val state = MutableStateFlow(State.New()) + + override val grpcClient: DataConnectGrpcClient + get() = initialize().grpcClient + override val queryManager: QueryManager + get() = initialize().queryManager + + private fun initialize(): State.Initialized { + val newState = + state.updateAndGet { currentState -> + when (currentState) { + is State.New -> { + val grpcRPCs = createDataConnectGrpcRPCs(currentState.emulatorSettings) + val grpcClient = createDataConnectGrpcClient(grpcRPCs) + val queryManager = createQueryManager(grpcClient) + State.Initialized(grpcRPCs, grpcClient, queryManager) } - backendInfoFromEmulatorSettings + is State.Initialized -> currentState + is State.Closing -> currentState + is State.Closed -> currentState } + } - logger.debug { "connecting to Data Connect backend: $backendInfo" } - val grpcMetadata = - DataConnectGrpcMetadata.forSystemVersions( - firebaseApp = app, - dataConnectAuth = dataConnectAuth, - dataConnectAppCheck = dataConnectAppCheck, - connectorLocation = config.location, - parentLogger = logger, - ) - val dataConnectGrpcRPCs = - DataConnectGrpcRPCs( - context = context, - host = backendInfo.host, - sslEnabled = backendInfo.sslEnabled, - blockingCoroutineDispatcher = blockingDispatcher, - grpcMetadata = grpcMetadata, - parentLogger = logger, + return when (newState) { + is State.New -> + throw IllegalStateException( + "newState should be Initialized, but got New (error code sh2rf4wwjx)" ) + is State.Initialized -> newState + is State.Closing, + State.Closed -> throw IllegalStateException("FirebaseDataConnect instance has been closed") + } + } - if (backendInfo.isEmulator) { - logEmulatorVersion(dataConnectGrpcRPCs) - streamEmulatorErrors(dataConnectGrpcRPCs) + private fun createDataConnectGrpcRPCs( + emulatorSettings: EmulatedServiceSettings? + ): DataConnectGrpcRPCs { + data class DataConnectBackendInfo( + val host: String, + val sslEnabled: Boolean, + val isEmulator: Boolean + ) + val backendInfoFromSettings = + DataConnectBackendInfo( + host = settings.host, + sslEnabled = settings.sslEnabled, + isEmulator = false + ) + val backendInfoFromEmulatorSettings = + emulatorSettings?.run { + DataConnectBackendInfo(host = "$host:$port", sslEnabled = false, isEmulator = true) + } + val backendInfo = + if (backendInfoFromEmulatorSettings == null) { + backendInfoFromSettings + } else { + if (!settings.isDefaultHost()) { + logger.warn( + "Host has been set in DataConnectSettings and useEmulator, " + + "emulator host will be used." + ) + } + backendInfoFromEmulatorSettings } - dataConnectGrpcRPCs - } - - override val lazyGrpcClient = - SuspendingLazy(mutex) { - DataConnectGrpcClient( - projectId = projectId, - connector = config, - grpcRPCs = lazyGrpcRPCs.getLocked(), + logger.debug { "connecting to Data Connect backend: $backendInfo" } + val grpcMetadata = + DataConnectGrpcMetadata.forSystemVersions( + firebaseApp = app, dataConnectAuth = dataConnectAuth, dataConnectAppCheck = dataConnectAppCheck, - logger = Logger("DataConnectGrpcClient").apply { debug { "created by $instanceId" } }, + connectorLocation = config.location, + parentLogger = logger, + ) + val dataConnectGrpcRPCs = + DataConnectGrpcRPCs( + context = context, + host = backendInfo.host, + sslEnabled = backendInfo.sslEnabled, + blockingCoroutineDispatcher = blockingDispatcher, + grpcMetadata = grpcMetadata, + parentLogger = logger, ) - } - override val lazyQueryManager = - SuspendingLazy(mutex) { - if (closed) throw IllegalStateException("FirebaseDataConnect instance has been closed") - val grpcClient = lazyGrpcClient.getLocked() - - val registeredDataDeserializerFactory = - object : LiveQuery.RegisteredDataDeserializerFactory { - override fun newInstance( - dataDeserializer: DeserializationStrategy, - dataSerializersModule: SerializersModule?, - parentLogger: Logger - ) = - RegisteredDataDeserializer( - dataDeserializer = dataDeserializer, - dataSerializersModule = dataSerializersModule, - blockingCoroutineDispatcher = blockingDispatcher, - parentLogger = parentLogger, - ) - } - val liveQueryFactory = - object : LiveQueries.LiveQueryFactory { - override fun newLiveQuery( - key: LiveQuery.Key, - operationName: String, - variables: Struct, - parentLogger: Logger - ) = - LiveQuery( - key = key, - operationName = operationName, - variables = variables, - parentCoroutineScope = coroutineScope, - nonBlockingCoroutineDispatcher = nonBlockingDispatcher, - grpcClient = grpcClient, - registeredDataDeserializerFactory = registeredDataDeserializerFactory, - parentLogger = parentLogger, - ) - } - val liveQueries = LiveQueries(liveQueryFactory, blockingDispatcher, parentLogger = logger) - QueryManager(liveQueries) + if (backendInfo.isEmulator) { + logEmulatorVersion(dataConnectGrpcRPCs) + streamEmulatorErrors(dataConnectGrpcRPCs) } + return dataConnectGrpcRPCs + } + + private fun createDataConnectGrpcClient(grpcRPCs: DataConnectGrpcRPCs): DataConnectGrpcClient = + DataConnectGrpcClient( + projectId = projectId, + connector = config, + grpcRPCs = grpcRPCs, + dataConnectAuth = dataConnectAuth, + dataConnectAppCheck = dataConnectAppCheck, + logger = Logger("DataConnectGrpcClient").apply { debug { "created by $instanceId" } }, + ) + + private fun createQueryManager(grpcClient: DataConnectGrpcClient): QueryManager { + val registeredDataDeserializerFactory = + object : LiveQuery.RegisteredDataDeserializerFactory { + override fun newInstance( + dataDeserializer: DeserializationStrategy, + dataSerializersModule: SerializersModule?, + parentLogger: Logger + ) = + RegisteredDataDeserializer( + dataDeserializer = dataDeserializer, + dataSerializersModule = dataSerializersModule, + blockingCoroutineDispatcher = blockingDispatcher, + parentLogger = parentLogger, + ) + } + val liveQueryFactory = + object : LiveQueries.LiveQueryFactory { + override fun newLiveQuery( + key: LiveQuery.Key, + operationName: String, + variables: Struct, + parentLogger: Logger + ) = + LiveQuery( + key = key, + operationName = operationName, + variables = variables, + parentCoroutineScope = coroutineScope, + nonBlockingCoroutineDispatcher = nonBlockingDispatcher, + grpcClient = grpcClient, + registeredDataDeserializerFactory = registeredDataDeserializerFactory, + parentLogger = parentLogger, + ) + } + val liveQueries = LiveQueries(liveQueryFactory, blockingDispatcher, parentLogger = logger) + return QueryManager(liveQueries) + } + override fun useEmulator(host: String, port: Int): Unit = runBlocking { - mutex.withLock { - if (lazyGrpcClient.initializedValueOrNull != null) { - throw IllegalStateException( - "Cannot call useEmulator() after instance has already been initialized." - ) + state.update { currentState -> + when (currentState) { + is State.New -> + currentState.copy(emulatorSettings = EmulatedServiceSettings(host = host, port = port)) + is State.Initialized -> + throw IllegalStateException( + "Cannot call useEmulator() after instance has already been initialized." + ) + is State.Closing -> currentState + is State.Closed -> currentState } - emulatorSettings = EmulatedServiceSettings(host = host, port = port) } } @@ -404,19 +414,17 @@ internal class FirebaseDataConnectImpl( ) } - private val closeJob = MutableStateFlow(NullableReference>(null)) - override fun close() { logger.debug { "close() called" } - @Suppress("DeferredResultUnused") runBlocking { nonBlockingClose() } + @Suppress("DeferredResultUnused") closeInternal() } override suspend fun suspendingClose() { logger.debug { "suspendingClose() called" } - nonBlockingClose().await() + closeInternal()?.await() } - private suspend fun nonBlockingClose(): Deferred { + private fun closeInternal(): Deferred? { coroutineScope.cancel() // Remove the reference to this `FirebaseDataConnect` instance from the @@ -424,41 +432,59 @@ internal class FirebaseDataConnectImpl( // called with the same arguments that a new instance of `FirebaseDataConnect` will be created. creator.remove(this) - mutex.withLock { closed = true } - // Close Auth and AppCheck synchronously to avoid race conditions with auth callbacks. // Since close() is re-entrant, this is safe even if they have already been closed. dataConnectAuth.close() dataConnectAppCheck.close() - // Start the job to asynchronously close the gRPC client. - while (true) { - val oldCloseJob = closeJob.value - - oldCloseJob.ref?.let { - if (!it.isCancelled) { - return it - } - } - + fun createCloseJob(grpcRPCs: DataConnectGrpcRPCs): Deferred { @OptIn(DelicateCoroutinesApi::class) - val newCloseJob = - GlobalScope.async(start = CoroutineStart.LAZY) { - lazyGrpcRPCs.initializedValueOrNull?.close() - } - - newCloseJob.invokeOnCompletion { exception -> - if (exception === null) { - logger.debug { "close() completed successfully" } - } else { + val closeJob = GlobalScope.async(start = CoroutineStart.LAZY) { grpcRPCs.close() } + closeJob.invokeOnCompletion { exception -> + if (exception !== null) { logger.warn(exception) { "close() failed" } + } else { + logger.debug { "close() completed successfully" } + state.update { currentState -> + check(currentState is State.Closing) { + "currentState is ${currentState}, but expected Closing (error code hsee7gfxvz)" + } + check(currentState.closeJob === closeJob) { + "currentState.closeJob is ${currentState.closeJob}, but expected $closeJob " + + "(error code n3x86pr6qn)" + } + State.Closed + } } } + return closeJob + } - if (closeJob.compareAndSet(oldCloseJob, NullableReference(newCloseJob))) { - newCloseJob.start() - return newCloseJob + val newState = + state.updateAndGet { currentState -> + when (currentState) { + is State.New -> State.Closed + is State.Initialized -> + State.Closing(currentState.grpcRPCs, createCloseJob(currentState.grpcRPCs)) + is State.Closing -> + if (currentState.closeJob.isCancelled) { + currentState.copy(closeJob = createCloseJob(currentState.grpcRPCs)) + } else { + currentState + } + is State.Closed -> State.Closed + } } + + return when (newState) { + is State.Initialized, + is State.New -> + throw IllegalStateException( + "internal error: newState is $newState, but expected Closing or Closed " + + "(error code n3x86pr6qn)" + ) + is State.Closing -> newState.closeJob.apply { start() } + is State.Closed -> null } } diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/MutationRefImpl.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/MutationRefImpl.kt index edd978a2ec9..8e5684c2fe6 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/MutationRefImpl.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/MutationRefImpl.kt @@ -60,8 +60,7 @@ internal class MutationRefImpl( override suspend fun execute(): MutationResultImpl { val requestId = "mut" + Random.nextAlphanumericString(length = 10) - return dataConnect.lazyGrpcClient - .get() + return dataConnect.grpcClient .executeMutation( requestId = requestId, operationName = operationName, diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QueryRefImpl.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QueryRefImpl.kt index 53e247e3d14..1b630a12cfd 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QueryRefImpl.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QueryRefImpl.kt @@ -49,7 +49,7 @@ internal class QueryRefImpl( variablesSerializersModule = variablesSerializersModule, ) { override suspend fun execute(): QueryResultImpl = - dataConnect.lazyQueryManager.get().execute(this).let { QueryResultImpl(it.ref.getOrThrow()) } + dataConnect.queryManager.execute(this).let { QueryResultImpl(it.ref.getOrThrow()) } override fun subscribe(): QuerySubscription = QuerySubscriptionImpl(this) diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QuerySubscriptionImpl.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QuerySubscriptionImpl.kt index ceeb861cab8..7ec870e93ce 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QuerySubscriptionImpl.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QuerySubscriptionImpl.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch internal class QuerySubscriptionImpl(query: QueryRefImpl) : @@ -57,7 +58,7 @@ internal class QuerySubscriptionImpl(query: QueryRefImpl val querySubscriptionResult = QuerySubscriptionResultImpl(query, sequencedResult) send(querySubscriptionResult) @@ -69,7 +70,7 @@ internal class QuerySubscriptionImpl(query: QueryRefImpl(query: QueryRefImpl= prospectiveSequenceNumber) { - return - } - } - - if (_lastResult.compareAndSet(currentLastResult, NullableReference(prospectiveLastResult))) { - return + _lastResult.update { currentLastResult -> + if ( + currentLastResult.ref != null && + currentLastResult.ref.sequencedResult.sequenceNumber >= + prospectiveLastResult.sequencedResult.sequenceNumber + ) { + currentLastResult + } else { + NullableReference(prospectiveLastResult) } } } diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/RegisteredDataDeserialzer.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/RegisteredDataDeserialzer.kt index 3f94a7f95a0..1fa6d94eae4 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/RegisteredDataDeserialzer.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/RegisteredDataDeserialzer.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.modules.SerializersModule @@ -84,17 +85,14 @@ internal class RegisteredDataDeserializer( lazyDeserialize(requestId, sequencedResult) ) - // Use a compare-and-swap ("CAS") loop to ensure that an old update never clobbers a newer one. - while (true) { - val currentUpdate = latestUpdate.value + latestUpdate.update { currentUpdate -> if ( currentUpdate.ref !== null && currentUpdate.ref.sequenceNumber > sequencedResult.sequenceNumber ) { - break // don't clobber a newer update with an older one - } - if (latestUpdate.compareAndSet(currentUpdate, NullableReference(newUpdate))) { - break + currentUpdate // don't clobber a newer update with an older one + } else { + NullableReference(newUpdate) } } diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt index 0ba6a34b34a..94a3a63a68d 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt @@ -16,7 +16,7 @@ package com.google.firebase.dataconnect.util -import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.toDataConnectError +import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.toErrorInfoImpl import com.google.firebase.dataconnect.util.ProtoUtil.nullProtoValue import com.google.firebase.dataconnect.util.ProtoUtil.toValueProto import com.google.protobuf.ListValue @@ -136,6 +136,10 @@ internal object ProtoUtil { fun Struct.toCompactString(keySortSelector: ((String) -> String)? = null): String = Value.newBuilder().setStructValue(this).build().toCompactString(keySortSelector) + /** Generates and returns a string similar to [Struct.toString] but more compact. */ + fun ListValue.toCompactString(keySortSelector: ((String) -> String)? = null): String = + Value.newBuilder().setListValue(this).build().toCompactString(keySortSelector) + /** Generates and returns a string similar to [Value.toString] but more compact. */ fun Value.toCompactString(keySortSelector: ((String) -> String)? = null): String { val charArrayWriter = CharArrayWriter() @@ -204,7 +208,7 @@ internal object ProtoUtil { fun ExecuteQueryResponse.toStructProto(): Struct = buildStructProto { if (hasData()) put("data", data) - putList("errors") { errorsList.forEach { add(it.toDataConnectError().toString()) } } + putList("errors") { errorsList.forEach { add(it.toErrorInfoImpl().toString()) } } } fun ExecuteMutationRequest.toCompactString(): String = toStructProto().toCompactString() @@ -219,7 +223,7 @@ internal object ProtoUtil { fun ExecuteMutationResponse.toStructProto(): Struct = buildStructProto { if (hasData()) put("data", data) - putList("errors") { errorsList.forEach { add(it.toDataConnectError().toString()) } } + putList("errors") { errorsList.forEach { add(it.toErrorInfoImpl().toString()) } } } fun EmulatorInfo.toStructProto(): Struct = buildStructProto { diff --git a/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/connector_service.proto b/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/connector_service.proto index 918227ef686..bb4bc986769 100644 --- a/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/connector_service.proto +++ b/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/connector_service.proto @@ -18,7 +18,7 @@ syntax = "proto3"; -package google.firebase.dataconnect.v1beta; +package google.firebase.dataconnect.v1; import "google/firebase/dataconnect/proto/graphql_error.proto"; import "google/protobuf/struct.proto"; diff --git a/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/graphql_error.proto b/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/graphql_error.proto index f2ca45e9f66..be19dcbfa35 100644 --- a/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/graphql_error.proto +++ b/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/graphql_error.proto @@ -18,7 +18,7 @@ syntax = "proto3"; -package google.firebase.dataconnect.v1beta; +package google.firebase.dataconnect.v1; import "google/protobuf/struct.proto"; diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectErrorUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectErrorUnitTest.kt deleted file mode 100644 index 204b6f1fc48..00000000000 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectErrorUnitTest.kt +++ /dev/null @@ -1,305 +0,0 @@ -/* - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:Suppress("ReplaceCallWithBinaryOperator") -@file:OptIn(ExperimentalKotest::class) - -package com.google.firebase.dataconnect - -import com.google.firebase.dataconnect.DataConnectError.PathSegment -import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnectError -import com.google.firebase.dataconnect.testutil.property.arbitrary.fieldPathSegment -import com.google.firebase.dataconnect.testutil.property.arbitrary.listIndexPathSegment -import com.google.firebase.dataconnect.testutil.property.arbitrary.pathSegment -import com.google.firebase.dataconnect.testutil.property.arbitrary.sourceLocation -import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingText -import io.kotest.assertions.assertSoftly -import io.kotest.common.ExperimentalKotest -import io.kotest.matchers.shouldBe -import io.kotest.matchers.shouldNotBe -import io.kotest.matchers.types.shouldBeSameInstanceAs -import io.kotest.property.Arb -import io.kotest.property.PropTestConfig -import io.kotest.property.arbitrary.Codepoint -import io.kotest.property.arbitrary.az -import io.kotest.property.arbitrary.choice -import io.kotest.property.arbitrary.constant -import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.list -import io.kotest.property.arbitrary.next -import io.kotest.property.arbitrary.string -import io.kotest.property.assume -import io.kotest.property.checkAll -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class DataConnectErrorUnitTest { - - @Test - fun `properties should be the same objects given to the constructor`() = runTest { - val messages = Arb.dataConnect.string() - val paths = Arb.list(Arb.dataConnect.pathSegment(), 0..5) - val sourceLocations = Arb.list(Arb.dataConnect.sourceLocation(), 0..5) - checkAll(propTestConfig, messages, paths, sourceLocations) { message, path, locations -> - val dataConnectError = DataConnectError(message = message, path = path, locations = locations) - assertSoftly { - dataConnectError.message shouldBeSameInstanceAs message - dataConnectError.path shouldBeSameInstanceAs path - dataConnectError.locations shouldBeSameInstanceAs locations - } - } - } - - @Test - fun `toString() should incorporate the message`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError()) { dataConnectError -> - dataConnectError.toString() shouldContainWithNonAbuttingText dataConnectError.message - } - } - - @Test - fun `toString() should incorporate the fields from the path separated by dots`() = runTest { - val paths = Arb.list(Arb.dataConnect.fieldPathSegment(), 0..5) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(path = paths)) { dataConnectError -> - val expectedSubstring = dataConnectError.path.joinToString(".") - dataConnectError.toString() shouldContainWithNonAbuttingText expectedSubstring - } - } - - @Test - fun `toString() should incorporate the list indexes from the path surround by square brackets`() = - runTest { - val paths = Arb.list(Arb.dataConnect.listIndexPathSegment(), 1..5) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(path = paths)) { dataConnectError -> - val expectedSubstring = dataConnectError.path.joinToString(separator = "") { "[$it]" } - dataConnectError.toString() shouldContainWithNonAbuttingText expectedSubstring - } - } - - @Test - fun `toString() should incorporate the fields and list indexes from the path`() { - // Use an example instead of Arb here because using Arb would essentially be re-writing the - // logic that is implemented in DataConnectError.toString(). - val path = - listOf( - PathSegment.Field("foo"), - PathSegment.ListIndex(99), - PathSegment.Field("bar"), - PathSegment.ListIndex(22), - PathSegment.ListIndex(33) - ) - val dataConnectError = Arb.dataConnect.dataConnectError(path = Arb.constant(path)).next() - - dataConnectError.toString() shouldContainWithNonAbuttingText "foo[99].bar[22][33]" - } - - @Test - fun `toString() should incorporate the locations`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError()) { dataConnectError -> - assertSoftly { - dataConnectError.locations.forEach { - dataConnectError.toString() shouldContainWithNonAbuttingText "${it.line}:${it.column}" - } - } - } - } - - @Test - fun `equals() should return true for the exact same instance`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError()) { dataConnectError -> - dataConnectError.equals(dataConnectError) shouldBe true - } - } - - @Test - fun `equals() should return true for an equal instance`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError()) { dataConnectError1 -> - val dataConnectError2 = - DataConnectError( - message = dataConnectError1.message, - path = List(dataConnectError1.path.size) { dataConnectError1.path[it] }, - locations = List(dataConnectError1.locations.size) { dataConnectError1.locations[it] }, - ) - dataConnectError1.equals(dataConnectError2) shouldBe true - dataConnectError2.equals(dataConnectError1) shouldBe true - } - } - - @Test - fun `equals() should return false for null`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError()) { dataConnectError -> - dataConnectError.equals(null) shouldBe false - } - } - - @Test - fun `equals() should return false for a different type`() = runTest { - val otherTypes = Arb.choice(Arb.string(), Arb.int(), Arb.dataConnect.sourceLocation()) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(), otherTypes) { - dataConnectError, - other -> - dataConnectError.equals(other) shouldBe false - } - } - - @Test - fun `equals() should return false when only message differs`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(), Arb.string()) { - dataConnectError1, - newMessage -> - assume(dataConnectError1.message != newMessage) - val dataConnectError2 = - DataConnectError( - message = newMessage, - path = dataConnectError1.path, - locations = dataConnectError1.locations, - ) - dataConnectError1.equals(dataConnectError2) shouldBe false - } - } - - @Test - fun `equals() should return false when message differs only in character case`() = runTest { - val message = Arb.string(1..100, Codepoint.az()) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(message = message)) { dataConnectError - -> - val dataConnectError1 = - DataConnectError( - message = dataConnectError.message.uppercase(), - path = dataConnectError.path, - locations = dataConnectError.locations, - ) - val dataConnectError2 = - DataConnectError( - message = dataConnectError.message.lowercase(), - path = dataConnectError.path, - locations = dataConnectError.locations, - ) - dataConnectError1.equals(dataConnectError2) shouldBe false - } - } - - @Test - fun `equals() should return false when path differs`() = runTest { - val paths = Arb.list(Arb.dataConnect.pathSegment(), 0..5) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(), paths) { - dataConnectError1, - otherPath -> - assume(dataConnectError1.path != otherPath) - val dataConnectError2 = - DataConnectError( - message = dataConnectError1.message, - path = otherPath, - locations = dataConnectError1.locations, - ) - dataConnectError1.equals(dataConnectError2) shouldBe false - } - } - - @Test - fun `equals() should return false when locations differ`() = runTest { - val location = Arb.list(Arb.dataConnect.sourceLocation(), 0..5) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(), location) { - dataConnectError1, - otherLocations -> - assume(dataConnectError1.locations != otherLocations) - val dataConnectError2 = - DataConnectError( - message = dataConnectError1.message, - path = dataConnectError1.path, - locations = otherLocations, - ) - dataConnectError1.equals(dataConnectError2) shouldBe false - } - } - - @Test - fun `hashCode() should return the same value each time it is invoked on a given object`() = - runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError()) { dataConnectError -> - val hashCode1 = dataConnectError.hashCode() - dataConnectError.hashCode() shouldBe hashCode1 - dataConnectError.hashCode() shouldBe hashCode1 - } - } - - @Test - fun `hashCode() should return the same value on equal objects`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError()) { dataConnectError1 -> - val dataConnectError2 = - DataConnectError( - message = dataConnectError1.message, - path = dataConnectError1.path, - locations = dataConnectError1.locations, - ) - dataConnectError1.hashCode() shouldBe dataConnectError2.hashCode() - } - } - - @Test - fun `hashCode() should return a different value if message is different`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(), Arb.string()) { - dataConnectError1, - newMessage -> - assume(dataConnectError1.message.hashCode() != newMessage.hashCode()) - val dataConnectError2 = - DataConnectError( - message = newMessage, - path = dataConnectError1.path, - locations = dataConnectError1.locations, - ) - dataConnectError1.hashCode() shouldNotBe dataConnectError2.hashCode() - } - } - - @Test - fun `hashCode() should return a different value if path is different`() = runTest { - val paths = Arb.list(Arb.dataConnect.pathSegment(), 0..5) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(), paths) { dataConnectError1, newPath - -> - assume(dataConnectError1.path.hashCode() != newPath.hashCode()) - val dataConnectError2 = - DataConnectError( - message = dataConnectError1.message, - path = newPath, - locations = dataConnectError1.locations, - ) - dataConnectError1.hashCode() shouldNotBe dataConnectError2.hashCode() - } - } - - @Test - fun `hashCode() should return a different value if locations is different`() = runTest { - val locations = Arb.list(Arb.dataConnect.sourceLocation(), 0..5) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(), locations) { - dataConnectError1, - newLocations -> - assume(dataConnectError1.locations.hashCode() != newLocations.hashCode()) - val dataConnectError2 = - DataConnectError( - message = dataConnectError1.message, - path = dataConnectError1.path, - locations = newLocations, - ) - dataConnectError1.hashCode() shouldNotBe dataConnectError2.hashCode() - } - } - - private companion object { - val propTestConfig = PropTestConfig(iterations = 20) - } -} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectPathSegmentUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectPathSegmentUnitTest.kt new file mode 100644 index 00000000000..d4029102a80 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectPathSegmentUnitTest.kt @@ -0,0 +1,226 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalKotest::class) +@file:Suppress("ReplaceCallWithBinaryOperator") + +package com.google.firebase.dataconnect + +import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.fieldPathSegment as fieldPathSegmentArb +import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.listIndexPathSegment as listIndexPathSegmentArb +import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect +import io.kotest.common.ExperimentalKotest +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.choice +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.string +import io.kotest.property.assume +import io.kotest.property.checkAll +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private val propTestConfig = + PropTestConfig(iterations = 20, edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.25)) + +/** Unit tests for [DataConnectPathSegment.Field] */ +class DataConnectPathSegmentFieldUnitTest { + + @Test + fun `constructor should set field property`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string()) { field -> + val pathSegment = DataConnectPathSegment.Field(field) + pathSegment.field shouldBeSameInstanceAs field + } + } + + @Test + fun `toString() should return a string equal to the field property`() = runTest { + checkAll(propTestConfig, fieldPathSegmentArb()) { pathSegment: DataConnectPathSegment.Field -> + pathSegment.toString() shouldBeSameInstanceAs pathSegment.field + } + } + + @Test + fun `equals() should return true for the exact same instance`() = runTest { + checkAll(propTestConfig, fieldPathSegmentArb()) { pathSegment: DataConnectPathSegment.Field -> + pathSegment.equals(pathSegment) shouldBe true + } + } + + @Test + fun `equals() should return true for an equal instance`() = runTest { + checkAll(propTestConfig, fieldPathSegmentArb()) { pathSegment1: DataConnectPathSegment.Field -> + val pathSegment2 = DataConnectPathSegment.Field(pathSegment1.field) + pathSegment1.equals(pathSegment2) shouldBe true + pathSegment2.equals(pathSegment1) shouldBe true + } + } + + @Test + fun `equals() should return false for null`() = runTest { + checkAll(propTestConfig, fieldPathSegmentArb()) { pathSegment: DataConnectPathSegment.Field -> + pathSegment.equals(null) shouldBe false + } + } + + @Test + fun `equals() should return false for a different type`() = runTest { + val otherTypes = Arb.choice(Arb.string(), Arb.int(), listIndexPathSegmentArb()) + checkAll(propTestConfig, fieldPathSegmentArb(), otherTypes) { + pathSegment: DataConnectPathSegment.Field, + other -> + pathSegment.equals(other) shouldBe false + } + } + + @Test + fun `equals() should return false when field differs`() = runTest { + checkAll(propTestConfig, fieldPathSegmentArb(), fieldPathSegmentArb()) { + pathSegment1: DataConnectPathSegment.Field, + pathSegment2: DataConnectPathSegment.Field -> + assume(pathSegment1.field != pathSegment2.field) + pathSegment1.equals(pathSegment2) shouldBe false + } + } + + @Test + fun `hashCode() should return the same value each time it is invoked on a given object`() = + runTest { + checkAll(propTestConfig, fieldPathSegmentArb()) { pathSegment: DataConnectPathSegment.Field -> + val hashCode1 = pathSegment.hashCode() + pathSegment.hashCode() shouldBe hashCode1 + pathSegment.hashCode() shouldBe hashCode1 + } + } + + @Test + fun `hashCode() should return the same value on equal objects`() = runTest { + checkAll(propTestConfig, fieldPathSegmentArb()) { pathSegment1: DataConnectPathSegment.Field -> + val pathSegment2 = DataConnectPathSegment.Field(pathSegment1.field) + pathSegment1.hashCode() shouldBe pathSegment2.hashCode() + } + } + + @Test + fun `hashCode() should return a different value if field is different`() = runTest { + checkAll(propTestConfig, fieldPathSegmentArb(), fieldPathSegmentArb()) { + pathSegment1: DataConnectPathSegment.Field, + pathSegment2: DataConnectPathSegment.Field -> + assume(pathSegment1.field.hashCode() != pathSegment2.field.hashCode()) + pathSegment1.hashCode() shouldNotBe pathSegment2.hashCode() + } + } +} + +/** Unit tests for [DataConnectPathSegment.ListIndex] */ +class DataConnectPathSegmentListIndexUnitTest { + + @Test + fun `constructor should set index property`() = runTest { + checkAll(propTestConfig, Arb.int()) { listIndex -> + val pathSegment = DataConnectPathSegment.ListIndex(listIndex) + pathSegment.index shouldBe listIndex + } + } + + @Test + fun `toString() should return a string equal to the index property`() = runTest { + checkAll(propTestConfig, listIndexPathSegmentArb()) { + pathSegment: DataConnectPathSegment.ListIndex -> + pathSegment.toString() shouldBe "${pathSegment.index}" + } + } + + @Test + fun `equals() should return true for the exact same instance`() = runTest { + checkAll(propTestConfig, listIndexPathSegmentArb()) { + pathSegment: DataConnectPathSegment.ListIndex -> + pathSegment.equals(pathSegment) shouldBe true + } + } + + @Test + fun `equals() should return true for an equal instance`() = runTest { + checkAll(propTestConfig, listIndexPathSegmentArb()) { + pathSegment1: DataConnectPathSegment.ListIndex -> + val pathSegment2 = DataConnectPathSegment.ListIndex(pathSegment1.index) + pathSegment1.equals(pathSegment2) shouldBe true + pathSegment2.equals(pathSegment1) shouldBe true + } + } + + @Test + fun `equals() should return false for null`() = runTest { + checkAll(propTestConfig, listIndexPathSegmentArb()) { + pathSegment: DataConnectPathSegment.ListIndex -> + pathSegment.equals(null) shouldBe false + } + } + + @Test + fun `equals() should return false for a different type`() = runTest { + val otherTypes = Arb.choice(Arb.string(), Arb.int(), fieldPathSegmentArb()) + checkAll(propTestConfig, listIndexPathSegmentArb(), otherTypes) { + pathSegment: DataConnectPathSegment.ListIndex, + other -> + pathSegment.equals(other) shouldBe false + } + } + + @Test + fun `equals() should return false when field differs`() = runTest { + checkAll(propTestConfig, listIndexPathSegmentArb(), listIndexPathSegmentArb()) { + pathSegment1: DataConnectPathSegment.ListIndex, + pathSegment2: DataConnectPathSegment.ListIndex -> + assume(pathSegment1.index != pathSegment2.index) + pathSegment1.equals(pathSegment2) shouldBe false + } + } + + @Test + fun `hashCode() should return the same value each time it is invoked on a given object`() = + runTest { + checkAll(propTestConfig, listIndexPathSegmentArb()) { + pathSegment: DataConnectPathSegment.ListIndex -> + val hashCode1 = pathSegment.hashCode() + pathSegment.hashCode() shouldBe hashCode1 + pathSegment.hashCode() shouldBe hashCode1 + } + } + + @Test + fun `hashCode() should return the same value on equal objects`() = runTest { + checkAll(propTestConfig, listIndexPathSegmentArb()) { + pathSegment1: DataConnectPathSegment.ListIndex -> + val pathSegment2 = DataConnectPathSegment.ListIndex(pathSegment1.index) + pathSegment1.hashCode() shouldBe pathSegment2.hashCode() + } + } + + @Test + fun `hashCode() should return a different value if index is different`() = runTest { + checkAll(propTestConfig, listIndexPathSegmentArb(), listIndexPathSegmentArb()) { + pathSegment1: DataConnectPathSegment.ListIndex, + pathSegment2: DataConnectPathSegment.ListIndex -> + assume(pathSegment1.index.hashCode() != pathSegment2.index.hashCode()) + pathSegment1.hashCode() shouldNotBe pathSegment2.hashCode() + } + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt index 98c5d441433..48c5a12f878 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt @@ -20,7 +20,6 @@ package com.google.firebase.dataconnect import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.sourceLocation import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingText import io.kotest.assertions.assertSoftly import io.kotest.common.ExperimentalKotest @@ -99,7 +98,7 @@ class DataConnectSettingsUnitTest { @Test fun `equals() should return false for a different type`() = runTest { - val otherTypes = Arb.choice(Arb.string(), Arb.int(), Arb.dataConnect.sourceLocation()) + val otherTypes = Arb.choice(Arb.string(), Arb.int(), Arb.dataConnect.errorPath()) checkAll(propTestConfig, Arb.dataConnect.dataConnectSettings(), otherTypes) { settings, other -> settings.equals(other) shouldBe false } diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentFieldUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentFieldUnitTest.kt deleted file mode 100644 index 75cf78107b3..00000000000 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentFieldUnitTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:Suppress("ReplaceCallWithBinaryOperator") -@file:OptIn(ExperimentalKotest::class) - -package com.google.firebase.dataconnect - -import com.google.firebase.dataconnect.DataConnectError.PathSegment -import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.fieldPathSegment -import io.kotest.common.ExperimentalKotest -import io.kotest.matchers.shouldBe -import io.kotest.property.Arb -import io.kotest.property.PropTestConfig -import io.kotest.property.arbitrary.choice -import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.string -import io.kotest.property.assume -import io.kotest.property.checkAll -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class PathSegmentFieldUnitTest { - - @Test - fun `field should equal the value given to the constructor`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string()) { field -> - val segment = PathSegment.Field(field) - segment.field shouldBe field - } - } - - @Test - fun `toString() should equal the field`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string()) { field -> - val segment = PathSegment.Field(field) - segment.toString() shouldBe field - } - } - - @Test - fun `equals() should return true for the same instance`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.fieldPathSegment()) { segment -> - segment.equals(segment) shouldBe true - } - } - - @Test - fun `equals() should return true for an equal field`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string()) { field -> - val segment1 = PathSegment.Field(field) - val segment2 = PathSegment.Field(field) - segment1.equals(segment2) shouldBe true - } - } - - @Test - fun `equals() should return false for null`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.fieldPathSegment()) { segment -> - segment.equals(null) shouldBe false - } - } - - @Test - fun `equals() should return false for a different type`() = runTest { - val others = Arb.choice(Arb.string(), Arb.int(), Arb.dataConnect.dataConnectSettings()) - checkAll(propTestConfig, Arb.dataConnect.fieldPathSegment(), others) { segment, other -> - segment.equals(other) shouldBe false - } - } - - @Test - fun `equals() should return false for a different field`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string(), Arb.dataConnect.string()) { field1, field2 -> - assume(field1 != field2) - val segment1 = PathSegment.Field(field1) - val segment2 = PathSegment.Field(field2) - segment1.equals(segment2) shouldBe false - } - } - - @Test - fun `hashCode() should return the same value as the field's hashCode() method`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string()) { field -> - val segment = PathSegment.Field(field) - segment.hashCode() shouldBe field.hashCode() - } - } - - private companion object { - val propTestConfig = PropTestConfig(iterations = 20) - } -} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentListIndexUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentListIndexUnitTest.kt deleted file mode 100644 index e8a3046d212..00000000000 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentListIndexUnitTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:Suppress("ReplaceCallWithBinaryOperator") -@file:OptIn(ExperimentalKotest::class) - -package com.google.firebase.dataconnect - -import com.google.firebase.dataconnect.DataConnectError.PathSegment -import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.listIndexPathSegment -import io.kotest.common.ExperimentalKotest -import io.kotest.matchers.shouldBe -import io.kotest.property.Arb -import io.kotest.property.PropTestConfig -import io.kotest.property.arbitrary.choice -import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.string -import io.kotest.property.assume -import io.kotest.property.checkAll -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class PathSegmentListIndexUnitTest { - - @Test - fun `index should equal the value given to the constructor`() = runTest { - checkAll(propTestConfig, Arb.int()) { index -> - val segment = PathSegment.ListIndex(index) - segment.index shouldBe index - } - } - - @Test - fun `toString() should equal the index`() = runTest { - checkAll(propTestConfig, Arb.int()) { index -> - val segment = PathSegment.ListIndex(index) - segment.toString() shouldBe "$index" - } - } - - @Test - fun `equals() should return true for the same instance`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.listIndexPathSegment()) { segment -> - segment.equals(segment) shouldBe true - } - } - - @Test - fun `equals() should return true for an equal field`() = runTest { - checkAll(propTestConfig, Arb.int()) { index -> - val segment1 = PathSegment.ListIndex(index) - val segment2 = PathSegment.ListIndex(index) - segment1.equals(segment2) shouldBe true - } - } - - @Test - fun `equals() should return false for null`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.listIndexPathSegment()) { segment -> - segment.equals(null) shouldBe false - } - } - - @Test - fun `equals() should return false for a different type`() = runTest { - val others = Arb.choice(Arb.string(), Arb.int(), Arb.dataConnect.dataConnectSettings()) - checkAll(propTestConfig, Arb.dataConnect.listIndexPathSegment(), others) { segment, other -> - segment.equals(other) shouldBe false - } - } - - @Test - fun `equals() should return false for a different index`() = runTest { - checkAll(propTestConfig, Arb.int(), Arb.int()) { index1, index2 -> - assume(index1 != index2) - val segment1 = PathSegment.ListIndex(index1) - val segment2 = PathSegment.ListIndex(index2) - segment1.equals(segment2) shouldBe false - } - } - - @Test - fun `hashCode() should return the same value as the index's hashCode() method`() = runTest { - checkAll(propTestConfig, Arb.int()) { index -> - val segment = PathSegment.ListIndex(index) - segment.hashCode() shouldBe index.hashCode() - } - } - - private companion object { - val propTestConfig = PropTestConfig(iterations = 20) - } -} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectAuthUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectAuthUnitTest.kt index a30121a707f..b35b3c0a402 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectAuthUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectAuthUnitTest.kt @@ -32,6 +32,7 @@ import com.google.firebase.dataconnect.testutil.UnavailableDeferred import com.google.firebase.dataconnect.testutil.newBackgroundScopeThatAdvancesLikeForeground import com.google.firebase.dataconnect.testutil.newMockLogger import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect +import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingText import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingTextIgnoringCase import com.google.firebase.dataconnect.testutil.shouldHaveLoggedAtLeastOneMessageContaining import com.google.firebase.dataconnect.testutil.shouldHaveLoggedExactlyOneMessageContaining @@ -39,6 +40,7 @@ import com.google.firebase.dataconnect.testutil.shouldNotHaveLoggedAnyMessagesCo import com.google.firebase.inject.Deferred.DeferredHandler import com.google.firebase.internal.api.FirebaseNoSignedInUserException import io.kotest.assertions.asClue +import io.kotest.assertions.assertSoftly import io.kotest.assertions.nondeterministic.continually import io.kotest.assertions.nondeterministic.eventually import io.kotest.assertions.nondeterministic.eventuallyConfig @@ -93,11 +95,57 @@ class DataConnectAuthUnitTest { private val mockLogger = newMockLogger("ecvqkga56c") @Test - fun `close() should succeed if called on a brand new instance()`() = runTest { + fun `initialize() should succeed if called on a brand new instance`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + } + + @Test + fun `initialize() should log a message`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("initialize()") + } + + @Test + fun `initialize() should throw if called twice`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + + val exception = shouldThrow { dataConnectAuth.initialize() } + + assertSoftly { + exception.message shouldContainWithNonAbuttingText "initialize()" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "already been called" + } + } + + @Test + fun `initialize() should throw if called after close()`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.close() + + val exception = shouldThrow { dataConnectAuth.initialize() } + + assertSoftly { + exception.message shouldContainWithNonAbuttingText "initialize()" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "called after close()" + } + } + + @Test + fun `close() should succeed if called on a brand new instance`() = runTest { val dataConnectAuth = newDataConnectAuth() dataConnectAuth.close() } + @Test + fun `close() should succeed if called immediately after initialize()`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + dataConnectAuth.close() + } + @Test fun `close() should log a message`() = runTest { val dataConnectAuth = newDataConnectAuth() @@ -110,6 +158,7 @@ class DataConnectAuthUnitTest { @Test fun `close() should cancel in-flight requests to get a token`() = runTest { val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() advanceUntilIdle() coEvery { mockInternalAuthProvider.getAccessToken(any()) } coAnswers @@ -132,6 +181,7 @@ class DataConnectAuthUnitTest { @Test fun `close() should remove the IdTokenListener`() = runTest { val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() advanceUntilIdle() val idTokenListenerSlot = slot() @@ -146,6 +196,7 @@ class DataConnectAuthUnitTest { @Test fun `close() should be callable multiple times, from multiple threads`() = runTest { val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() advanceUntilIdle() val latch = SuspendingCountDownLatch(100) @@ -172,9 +223,34 @@ class DataConnectAuthUnitTest { dataConnectAuth.forceRefresh() } + @Test + fun `forceRefresh() should throw if invoked before initialize() close()`() = runTest { + val dataConnectAuth = newDataConnectAuth() + + val exception = shouldThrow { dataConnectAuth.forceRefresh() } + + assertSoftly { + exception.message shouldContainWithNonAbuttingText "forceRefresh()" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "initialize() must be called" + } + } + + @Test + fun `getToken() should throw if invoked before initialize() close()`() = runTest { + val dataConnectAuth = newDataConnectAuth() + + val exception = shouldThrow { dataConnectAuth.getToken(requestId) } + + assertSoftly { + exception.message shouldContainWithNonAbuttingText "getToken()" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "initialize() must be called" + } + } + @Test fun `getToken() should return null if InternalAuthProvider is not available`() = runTest { val dataConnectAuth = newDataConnectAuth(deferredInternalAuthProvider = UnavailableDeferred()) + dataConnectAuth.initialize() advanceUntilIdle() val result = dataConnectAuth.getToken(requestId) @@ -204,6 +280,7 @@ class DataConnectAuthUnitTest { @Test fun `getToken() should return null if no user is signed in`() = runTest { val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() advanceUntilIdle() coEvery { mockInternalAuthProvider.getAccessToken(any()) } returns Tasks.forException(FirebaseNoSignedInUserException("j8rkghbcnz")) @@ -219,6 +296,7 @@ class DataConnectAuthUnitTest { @Test fun `getToken() should return the token returned from FirebaseAuth`() = runTest { val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() advanceUntilIdle() coEvery { mockInternalAuthProvider.getAccessToken(any()) } returns taskForToken(accessToken) @@ -239,6 +317,7 @@ class DataConnectAuthUnitTest { val exception = TestException("xqtbckcn6w") val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() advanceUntilIdle() coEvery { mockInternalAuthProvider.getAccessToken(any()) } returns Tasks.forException(exception) @@ -260,6 +339,7 @@ class DataConnectAuthUnitTest { val exception = TestException("s4c4xr9z4p") val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() advanceUntilIdle() coEvery { mockInternalAuthProvider.getAccessToken(any()) } answers { throw exception } @@ -276,6 +356,7 @@ class DataConnectAuthUnitTest { @Test fun `getToken() should force refresh the access token after calling forceRefresh()`() = runTest { val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() advanceUntilIdle() coEvery { mockInternalAuthProvider.getAccessToken(any()) } returns taskForToken(accessToken) @@ -297,6 +378,7 @@ class DataConnectAuthUnitTest { fun `getToken() should NOT force refresh the access token without calling forceRefresh()`() = runTest { val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() advanceUntilIdle() coEvery { mockInternalAuthProvider.getAccessToken(any()) } returns taskForToken(accessToken) @@ -311,6 +393,7 @@ class DataConnectAuthUnitTest { fun `getToken() should NOT force refresh the access token after it is force refreshed`() = runTest { val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() advanceUntilIdle() coEvery { mockInternalAuthProvider.getAccessToken(any()) } returns taskForToken(accessToken) @@ -328,6 +411,7 @@ class DataConnectAuthUnitTest { @Test fun `getToken() should ask for a token from FirebaseAuth on every invocation`() = runTest { val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() advanceUntilIdle() val tokens = CopyOnWriteArrayList() coEvery { mockInternalAuthProvider.getAccessToken(any()) } answers @@ -343,6 +427,7 @@ class DataConnectAuthUnitTest { @Test fun `getToken() should conflate concurrent requests`() = runTest { val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() advanceUntilIdle() val tokens = CopyOnWriteArrayList() coEvery { mockInternalAuthProvider.getAccessToken(any()) } answers @@ -372,6 +457,7 @@ class DataConnectAuthUnitTest { @Test fun `getToken() should re-fetch token if invalidated concurrently`() = runTest { val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() advanceUntilIdle() val invocationCount = AtomicInteger(0) val tokens = CopyOnWriteArrayList().apply { add(accessToken) } @@ -406,6 +492,7 @@ class DataConnectAuthUnitTest { @Test fun `getToken() should ignore results with lower sequence number`() = runTest { val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() advanceUntilIdle() val invocationCount = AtomicInteger(0) val tokens = CopyOnWriteArrayList() @@ -447,6 +534,7 @@ class DataConnectAuthUnitTest { } val dataConnectAuth = newDataConnectAuth(deferredInternalAuthProvider = deferredInternalAuthProvider) + dataConnectAuth.initialize() advanceUntilIdle() val result = dataConnectAuth.getToken(requestId) @@ -467,6 +555,7 @@ class DataConnectAuthUnitTest { val deferredInternalAuthProvider: DeferredInternalAuthProvider = mockk(relaxed = true) val dataConnectAuth = newDataConnectAuth(deferredInternalAuthProvider = deferredInternalAuthProvider) + dataConnectAuth.initialize() advanceUntilIdle() dataConnectAuth.close() val deferredInternalAuthProviderHandlerSlot = slot>() @@ -488,6 +577,7 @@ class DataConnectAuthUnitTest { val deferredInternalAuthProvider = DelayedDeferred(mockInternalAuthProvider) val dataConnectAuth = newDataConnectAuth(deferredInternalAuthProvider = deferredInternalAuthProvider) + dataConnectAuth.initialize() advanceUntilIdle() every { mockInternalAuthProvider.addIdTokenListener(any()) } answers { diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt index 61273ec0d24..5cce39e1c3c 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt @@ -13,27 +13,33 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:OptIn(ExperimentalKotest::class) + package com.google.firebase.dataconnect.core -import com.google.firebase.dataconnect.DataConnectError -import com.google.firebase.dataconnect.DataConnectException +import com.google.firebase.dataconnect.DataConnectOperationException +import com.google.firebase.dataconnect.DataConnectOperationFailureResponse.ErrorInfo +import com.google.firebase.dataconnect.DataConnectPathSegment import com.google.firebase.dataconnect.DataConnectUntypedData import com.google.firebase.dataconnect.FirebaseDataConnect import com.google.firebase.dataconnect.core.DataConnectGrpcClient.OperationResult import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.deserialize +import com.google.firebase.dataconnect.core.DataConnectOperationFailureResponseImpl.ErrorInfoImpl import com.google.firebase.dataconnect.testutil.DataConnectLogLevelRule +import com.google.firebase.dataconnect.testutil.RandomSeedTestRule import com.google.firebase.dataconnect.testutil.newMockLogger import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnectError import com.google.firebase.dataconnect.testutil.property.arbitrary.iterator -import com.google.firebase.dataconnect.testutil.property.arbitrary.operationResult +import com.google.firebase.dataconnect.testutil.property.arbitrary.operationErrors import com.google.firebase.dataconnect.testutil.property.arbitrary.proto import com.google.firebase.dataconnect.testutil.property.arbitrary.struct import com.google.firebase.dataconnect.testutil.shouldHaveLoggedExactlyOneMessageContaining +import com.google.firebase.dataconnect.testutil.shouldSatisfy import com.google.firebase.dataconnect.util.ProtoUtil.buildStructProto import com.google.firebase.dataconnect.util.ProtoUtil.encodeToStruct import com.google.firebase.dataconnect.util.ProtoUtil.toMap import com.google.protobuf.ListValue +import com.google.protobuf.Struct import com.google.protobuf.Value import google.firebase.dataconnect.proto.ExecuteMutationRequest import google.firebase.dataconnect.proto.ExecuteMutationResponse @@ -43,50 +49,57 @@ import google.firebase.dataconnect.proto.GraphqlError import google.firebase.dataconnect.proto.SourceLocation import io.grpc.Status import io.grpc.StatusException -import io.kotest.assertions.asClue +import io.kotest.assertions.assertSoftly import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.withClue +import io.kotest.common.ExperimentalKotest import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.maps.shouldContainExactly import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe -import io.kotest.matchers.string.shouldContain import io.kotest.matchers.types.shouldBeSameInstanceAs import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig import io.kotest.property.RandomSource import io.kotest.property.arbitrary.Codepoint import io.kotest.property.arbitrary.alphanumeric import io.kotest.property.arbitrary.egyptianHieroglyphs import io.kotest.property.arbitrary.enum -import io.kotest.property.arbitrary.filter import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.map import io.kotest.property.arbitrary.merge import io.kotest.property.arbitrary.next import io.kotest.property.arbitrary.string -import io.kotest.property.arbs.firstName -import io.kotest.property.arbs.travel.airline +import io.kotest.property.assume import io.kotest.property.checkAll import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.mockk import io.mockk.slot import io.mockk.spyk import io.mockk.verify import java.util.concurrent.atomic.AtomicBoolean +import kotlin.reflect.KClass import kotlinx.coroutines.test.runTest import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.serializer import org.junit.Rule import org.junit.Test +private val propTestConfig = + PropTestConfig(iterations = 20, edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.25)) + class DataConnectGrpcClientUnitTest { @get:Rule val dataConnectLogLevelRule = DataConnectLogLevelRule() + @get:Rule val randomSeedTestRule = RandomSeedTestRule() - private val rs = RandomSource.default() + private val rs: RandomSource by randomSeedTestRule.rs private val projectId = Arb.dataConnect.projectId().next(rs) private val connectorConfig = Arb.dataConnect.connectorConfig().next(rs) private val requestId = Arb.dataConnect.requestId().next(rs) @@ -192,7 +205,7 @@ class DataConnectGrpcClientUnitTest { dataConnectGrpcClient.executeQuery(requestId, operationName, variables, callerSdkType) operationResult shouldBe - OperationResult(data = responseData, errors = responseErrors.map { it.dataConnectError }) + OperationResult(data = responseData, errors = responseErrors.map { it.errorInfo }) } @Test @@ -209,7 +222,7 @@ class DataConnectGrpcClientUnitTest { dataConnectGrpcClient.executeMutation(requestId, operationName, variables, callerSdkType) operationResult shouldBe - OperationResult(data = responseData, errors = responseErrors.map { it.dataConnectError }) + OperationResult(data = responseData, errors = responseErrors.map { it.errorInfo }) } @Test @@ -492,7 +505,7 @@ class DataConnectGrpcClientUnitTest { private data class GraphqlErrorInfo( val graphqlError: GraphqlError, - val dataConnectError: DataConnectError, + val errorInfo: ErrorInfoImpl, ) { companion object { private val randomPathComponents = @@ -510,28 +523,24 @@ class DataConnectGrpcClientUnitTest { fun random(rs: RandomSource): GraphqlErrorInfo { - val dataConnectErrorPath = mutableListOf() + val dataConnectErrorPath = mutableListOf() val graphqlErrorPath = ListValue.newBuilder() repeat(6) { if (rs.random.nextFloat() < 0.33f) { val pathComponent = randomInts.next(rs) - dataConnectErrorPath.add(DataConnectError.PathSegment.ListIndex(pathComponent)) + dataConnectErrorPath.add(DataConnectPathSegment.ListIndex(pathComponent)) graphqlErrorPath.addValues(Value.newBuilder().setNumberValue(pathComponent.toDouble())) } else { val pathComponent = randomPathComponents.next(rs) - dataConnectErrorPath.add(DataConnectError.PathSegment.Field(pathComponent)) + dataConnectErrorPath.add(DataConnectPathSegment.Field(pathComponent)) graphqlErrorPath.addValues(Value.newBuilder().setStringValue(pathComponent)) } } - val dataConnectErrorLocations = mutableListOf() val graphqlErrorLocations = mutableListOf() repeat(3) { val line = randomInts.next(rs) val column = randomInts.next(rs) - dataConnectErrorLocations.add( - DataConnectError.SourceLocation(line = line, column = column) - ) graphqlErrorLocations.add( SourceLocation.newBuilder().setLine(line).setColumn(column).build() ) @@ -547,14 +556,13 @@ class DataConnectGrpcClientUnitTest { } .build() - val dataConnectError = - DataConnectError( + val errorInfo = + ErrorInfoImpl( message = message, path = dataConnectErrorPath.toList(), - locations = dataConnectErrorLocations.toList() ) - return GraphqlErrorInfo(graphqlError, dataConnectError) + return GraphqlErrorInfo(graphqlError, errorInfo) } } } @@ -563,69 +571,147 @@ class DataConnectGrpcClientUnitTest { @Suppress("IMPLICIT_NOTHING_TYPE_ARGUMENT_AGAINST_NOT_NOTHING_EXPECTED_TYPE") class DataConnectGrpcClientOperationResultUnitTest { - private val rs = RandomSource.default() - @Test fun `deserialize() should ignore the module given with DataConnectUntypedData`() { - val errors = listOf(Arb.dataConnect.dataConnectError().next()) - val operationResult = OperationResult(buildStructProto { put("foo", 42.0) }, errors) + val data = buildStructProto { put("foo", 42.0) } + val errors = Arb.dataConnect.operationErrors().next() + val operationResult = OperationResult(data, errors) val result = operationResult.deserialize(DataConnectUntypedData, mockk()) - result shouldBe DataConnectUntypedData(mapOf("foo" to 42.0), errors) + result.shouldHaveDataAndErrors(data, errors) } @Test - fun `deserialize() should treat DataConnectUntypedData specially`() = runTest { - checkAll(iterations = 20, Arb.dataConnect.operationResult()) { operationResult -> + fun `deserialize() with null data should treat DataConnectUntypedData specially`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrors()) { errors -> + val operationResult = OperationResult(null, errors) val result = operationResult.deserialize(DataConnectUntypedData, serializersModule = null) + result.shouldHaveDataAndErrors(null, errors) + } + } - result.asClue { - if (operationResult.data === null) { - it.data.shouldBeNull() - } else { - it.data shouldBe operationResult.data.toMap() - } - it.errors shouldContainExactly operationResult.errors - } + @Test + fun `deserialize() with non-null data should treat DataConnectUntypedData specially`() = runTest { + checkAll(propTestConfig, Arb.proto.struct(), Arb.dataConnect.operationErrors()) { data, errors + -> + val operationResult = OperationResult(data, errors) + val result = operationResult.deserialize(DataConnectUntypedData, serializersModule = null) + result.shouldHaveDataAndErrors(data, errors) } } @Test - fun `deserialize() should throw if one or more errors and data is null`() = runTest { - val arb = - Arb.dataConnect - .operationResult() - .filter { it.errors.isNotEmpty() } - .map { it.copy(data = null) } - checkAll(iterations = 5, arb) { operationResult -> - val exception = - shouldThrow { - operationResult.deserialize(mockk(), serializersModule = null) - } - exception.message shouldContain "${operationResult.errors}" + fun `deserialize() successfully deserializes`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string()) { fooValue -> + val dataStruct = buildStructProto { put("foo", fooValue) } + val operationResult = OperationResult(dataStruct, emptyList()) + + val deserializedData = operationResult.deserialize(serializer(), null) + + deserializedData shouldBe TestData(fooValue) } } @Test - fun `deserialize() should throw if one or more errors and data is _not_ null`() = runTest { - val arb = - Arb.dataConnect.operationResult().filter { it.data !== null && it.errors.isNotEmpty() } - checkAll(iterations = 5, arb) { operationResult -> - val exception = - shouldThrow { + fun `deserialize() should throw if one or more errors and data is null`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrors(range = 1..10)) { errors -> + val operationResult = OperationResult(null, errors) + val exception: DataConnectOperationException = + shouldThrow { operationResult.deserialize(mockk(), serializersModule = null) } - exception.message shouldContain "${operationResult.errors}" + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = errors.toString(), + expectedCause = null, + expectedRawData = null, + expectedData = null, + expectedErrors = errors, + ) } } + @Test + fun `deserialize() should throw if one or more errors, data is NOT null, and decoding fails`() = + runTest { + checkAll( + propTestConfig, + Arb.proto.struct(), + Arb.dataConnect.operationErrors(range = 1..10) + ) { dataStruct, errors -> + val operationResult = OperationResult(dataStruct, errors) + val exception: DataConnectOperationException = + shouldThrow { + operationResult.deserialize(mockk(), serializersModule = null) + } + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = errors.toString(), + expectedCause = null, + expectedRawData = dataStruct, + expectedData = null, + expectedErrors = errors, + ) + } + } + + @Test + fun `deserialize() should throw if one or more errors, data is NOT null, and decoding succeeds`() = + runTest { + checkAll( + propTestConfig, + Arb.dataConnect.string(), + Arb.dataConnect.operationErrors(range = 1..10) + ) { fooValue, errors -> + val dataStruct = buildStructProto { put("foo", fooValue) } + val operationResult = OperationResult(dataStruct, errors) + val exception: DataConnectOperationException = + shouldThrow { + operationResult.deserialize(serializer(), serializersModule = null) + } + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = errors.toString(), + expectedCause = null, + expectedRawData = dataStruct, + expectedData = TestData(fooValue), + expectedErrors = errors, + ) + } + } + @Test fun `deserialize() should throw if data is null and errors is empty`() { - val operationResult = OperationResult(data = null, errors = emptyList()) - val exception = - shouldThrow { - operationResult.deserialize(mockk(), serializersModule = null) + val operationResult = OperationResult(null, emptyList()) + val exception: DataConnectOperationException = + shouldThrow { + operationResult.deserialize(serializer(), serializersModule = null) } - exception.message shouldContain "no data" + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "no data was included", + expectedCause = null, + expectedRawData = null, + expectedData = null, + expectedErrors = emptyList(), + ) + } + + @Test + fun `deserialize() should throw if decoding fails and error list is empty`() = runTest { + checkAll(propTestConfig, Arb.proto.struct()) { dataStruct -> + assume(!dataStruct.containsFields("foo")) + val operationResult = OperationResult(dataStruct, emptyList()) + val exception: DataConnectOperationException = + shouldThrow { + operationResult.deserialize(serializer(), serializersModule = null) + } + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "decoding data from the server's response failed", + expectedCause = SerializationException::class, + expectedRawData = dataStruct, + expectedData = null, + expectedErrors = emptyList(), + ) + } } @Test @@ -642,51 +728,50 @@ class DataConnectGrpcClientOperationResultUnitTest { slot.captured.serializersModule shouldBeSameInstanceAs serializersModule } - @Test - fun `deserialize() successfully deserializes`() = runTest { - val testData = TestData(Arb.firstName().next().name) - val operationResult = OperationResult(encodeToStruct(testData), errors = emptyList()) - - val deserializedData = operationResult.deserialize(serializer(), null) - - deserializedData shouldBe testData - } - - @Test - fun `deserialize() throws if decoding fails`() = runTest { - val data = Arb.proto.struct().next(rs) - val operationResult = OperationResult(data, errors = emptyList()) - shouldThrow { operationResult.deserialize(serializer(), null) } - } - - @Test - fun `deserialize() re-throws DataConnectException`() = runTest { - val data = encodeToStruct(TestData("fe45zhyd3m")) - val operationResult = OperationResult(data = data, errors = emptyList()) - val deserializer: DeserializationStrategy = spyk(serializer()) - val exception = DataConnectException(message = Arb.airline().next().name) - every { deserializer.deserialize(any()) } throws (exception) - - val thrownException = - shouldThrow { operationResult.deserialize(deserializer, null) } - - thrownException shouldBeSameInstanceAs exception - } - - @Test - fun `deserialize() wraps non-DataConnectException in DataConnectException`() = runTest { - val data = encodeToStruct(TestData("rbmkny6b4r")) - val operationResult = OperationResult(data = data, errors = emptyList()) - val deserializer: DeserializationStrategy = spyk(serializer()) - class MyException : Exception("y3cx44q43q") - val exception = MyException() - every { deserializer.deserialize(any()) } throws (exception) + @Serializable data class TestData(val foo: String) - val thrownException = - shouldThrow { operationResult.deserialize(deserializer, null) } + private companion object { + + fun DataConnectOperationException.shouldSatisfy( + expectedMessageSubstringCaseInsensitive: String, + expectedMessageSubstringCaseSensitive: String? = null, + expectedCause: KClass<*>?, + expectedRawData: Struct?, + expectedData: T?, + expectedErrors: List, + ) = + shouldSatisfy( + expectedMessageSubstringCaseInsensitive = expectedMessageSubstringCaseInsensitive, + expectedMessageSubstringCaseSensitive = expectedMessageSubstringCaseSensitive, + expectedCause = expectedCause, + expectedRawData = expectedRawData?.toMap(), + expectedData = expectedData, + expectedErrors = expectedErrors, + ) + + fun DataConnectUntypedData.shouldHaveDataAndErrors( + expectedData: Map, + expectedErrors: List, + ) { + assertSoftly { + withClue("data") { data.shouldNotBeNull().shouldContainExactly(expectedData) } + withClue("errors") { errors shouldContainExactly expectedErrors } + } + } - thrownException.cause shouldBeSameInstanceAs exception + fun DataConnectUntypedData.shouldHaveDataAndErrors( + expectedData: Struct, + expectedErrors: List, + ) = shouldHaveDataAndErrors(expectedData.toMap(), expectedErrors) + + fun DataConnectUntypedData.shouldHaveDataAndErrors( + @Suppress("UNUSED_PARAMETER") expectedData: Nothing?, + expectedErrors: List, + ) { + assertSoftly { + withClue("data") { data.shouldBeNull() } + withClue("errors") { errors shouldContainExactly expectedErrors } + } + } } - - @Serializable data class TestData(val foo: String) } diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImplUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImplUnitTest.kt new file mode 100644 index 00000000000..994d1b405fa --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImplUnitTest.kt @@ -0,0 +1,350 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalKotest::class) +@file:Suppress("ReplaceCallWithBinaryOperator") + +package com.google.firebase.dataconnect.core + +import com.google.firebase.dataconnect.DataConnectPathSegment +import com.google.firebase.dataconnect.core.DataConnectOperationFailureResponseImpl.ErrorInfoImpl +import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.errorPath as errorPathArb +import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.fieldPathSegment as fieldPathSegmentArb +import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.listIndexPathSegment as listIndexPathSegmentArb +import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect +import com.google.firebase.dataconnect.testutil.property.arbitrary.operationData +import com.google.firebase.dataconnect.testutil.property.arbitrary.operationErrorInfo +import com.google.firebase.dataconnect.testutil.property.arbitrary.operationErrors +import com.google.firebase.dataconnect.testutil.property.arbitrary.operationFailureResponseImpl +import com.google.firebase.dataconnect.testutil.property.arbitrary.operationRawData +import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingText +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.withClue +import io.kotest.common.ExperimentalKotest +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldEndWith +import io.kotest.matchers.string.shouldStartWith +import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.bind +import io.kotest.property.arbitrary.choice +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.list +import io.kotest.property.arbitrary.string +import io.kotest.property.assume +import io.kotest.property.checkAll +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private val propTestConfig = + PropTestConfig(iterations = 20, edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.25)) + +/** Unit tests for [DataConnectOperationFailureResponseImpl] */ +class DataConnectOperationFailureResponseImplUnitTest { + + @Test + fun `constructor should set properties to the given values`() = runTest { + checkAll( + propTestConfig, + Arb.dataConnect.operationRawData(), + Arb.dataConnect.operationData(), + Arb.dataConnect.operationErrors() + ) { rawData, data, errors -> + val response = DataConnectOperationFailureResponseImpl(rawData, data, errors) + assertSoftly { + withClue("rawData") { response.rawData shouldBeSameInstanceAs rawData } + withClue("data") { response.data shouldBeSameInstanceAs data } + withClue("errors") { response.errors shouldBeSameInstanceAs errors } + } + } + } + + @Test + fun `toString() should incorporate property values`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationFailureResponseImpl()) { + response: DataConnectOperationFailureResponseImpl<*> -> + val toStringResult = response.toString() + assertSoftly { + toStringResult shouldStartWith "DataConnectOperationFailureResponseImpl(" + toStringResult shouldEndWith ")" + toStringResult shouldContainWithNonAbuttingText "rawData=${response.rawData}" + toStringResult shouldContainWithNonAbuttingText "data=${response.data}" + toStringResult shouldContainWithNonAbuttingText "errors=${response.errors}" + } + } + } +} + +/** Unit tests for [DataConnectOperationFailureResponseImpl.ErrorInfoImpl] */ +class DataConnectOperationFailureResponseImplErrorInfoImplUnitTest { + + @Test + fun `constructor should set properties to the given values`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), errorPathArb()) { message, path -> + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.message shouldBeSameInstanceAs message + errorInfo.path shouldBeSameInstanceAs path + } + } + + @Test + fun `toString() should return an empty string if both message and path are empty`() { + val errorInfo = ErrorInfoImpl("", emptyList()) + errorInfo.toString() shouldBe "" + } + + @Test + fun `toString() should return the message if message is non-empty and path is empty`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string()) { message -> + val errorInfo = ErrorInfoImpl(message, emptyList()) + errorInfo.toString() shouldBe message + } + } + + @Test + fun `toString() should not do anything different with an empty message`() = runTest { + checkAll(propTestConfig, errorPathArb()) { path -> + assume(path.isNotEmpty()) + val errorInfo = ErrorInfoImpl("", path) + val errorInfoToStringResult = errorInfo.toString() + errorInfoToStringResult shouldEndWith ": " + path.forEachIndexed { index, pathSegment -> + withClue("path[$index]") { + errorInfoToStringResult shouldContainWithNonAbuttingText pathSegment.toString() + } + } + } + } + + @Test + fun `toString() should print field path segments separated by dots`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), Arb.list(fieldPathSegmentArb(), 1..10)) { + message, + path -> + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe path.joinToString(".") + ": $message" + } + } + + @Test + fun `toString() should print list index path segments separated by dots`() = runTest { + checkAll( + propTestConfig, + Arb.dataConnect.string(), + Arb.list(listIndexPathSegmentArb(), 1..10) + ) { message, path -> + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe path.joinToString("") { "[${it.index}]" } + ": $message" + } + } + + @Test + fun `toString() for path is field, listIndex`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { + message, + segments -> + val path = listOf(segments.field1, segments.listIndex1) + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe "${segments.field1.field}[${segments.listIndex1}]: $message" + } + } + + @Test + fun `toString() for path is listIndex, field`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { + message, + segments -> + val path = listOf(segments.listIndex1, segments.field1) + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe "[${segments.listIndex1}].${segments.field1.field}: $message" + } + } + + @Test + fun `toString() for path is field, listIndex, field`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { + message, + segments -> + val path = listOf(segments.field1, segments.listIndex1, segments.field2) + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe + "${segments.field1.field}[${segments.listIndex1}].${segments.field2.field}: $message" + } + } + + @Test + fun `toString() for path is field, listIndex, listIndex`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { + message, + segments -> + val path = listOf(segments.field1, segments.listIndex1, segments.listIndex2) + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe + "${segments.field1.field}[${segments.listIndex1}][${segments.listIndex2}]: $message" + } + } + + @Test + fun `toString() for path is field, field, listIndex`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { + message, + segments -> + val path = listOf(segments.field1, segments.field2, segments.listIndex1) + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe + "${segments.field1.field}.${segments.field2.field}[${segments.listIndex1}]: $message" + } + } + + @Test + fun `toString() for path is field, listIndex, field, listIndex`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { + message, + segments -> + val path = listOf(segments.field1, segments.listIndex1, segments.field2, segments.listIndex2) + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe + "${segments.field1.field}[${segments.listIndex1}].${segments.field2.field}[${segments.listIndex2}]: $message" + } + } + + @Test + fun `toString() for path is field, listIndex, listIndex, field`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { + message, + segments -> + val path = listOf(segments.field1, segments.listIndex1, segments.listIndex2, segments.field2) + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe + "${segments.field1.field}[${segments.listIndex1}][${segments.listIndex2}].${segments.field2.field}: $message" + } + } + + @Test + fun `equals() should return true for the exact same instance`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo()) { errorInfo: ErrorInfoImpl -> + errorInfo.equals(errorInfo) shouldBe true + } + } + + @Test + fun `equals() should return true for an equal instance`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo()) { errorInfo1: ErrorInfoImpl -> + val errorInfo2 = ErrorInfoImpl(errorInfo1.message, errorInfo1.path) + errorInfo1.equals(errorInfo2) shouldBe true + errorInfo2.equals(errorInfo1) shouldBe true + } + } + + @Test + fun `equals() should return false for null`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo()) { errorInfo: ErrorInfoImpl -> + errorInfo.equals(null) shouldBe false + } + } + + @Test + fun `equals() should return false for a different type`() = runTest { + val otherTypes = Arb.choice(Arb.string(), Arb.int(), listIndexPathSegmentArb()) + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo(), otherTypes) { + errorInfo: ErrorInfoImpl, + other -> + errorInfo.equals(other) shouldBe false + } + } + + @Test + fun `equals() should return false when message differs`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo(), Arb.dataConnect.string()) { + errorInfo1: ErrorInfoImpl, + otherMessage: String -> + assume(errorInfo1.message != otherMessage) + val errorInfo2 = ErrorInfoImpl(otherMessage, errorInfo1.path) + errorInfo1.equals(errorInfo2) shouldBe false + } + } + + @Test + fun `equals() should return false when path differs`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo(), errorPathArb()) { + errorInfo1: ErrorInfoImpl, + otherPath: List -> + assume(errorInfo1.path != otherPath) + val errorInfo2 = ErrorInfoImpl(errorInfo1.message, otherPath) + errorInfo1.equals(errorInfo2) shouldBe false + } + } + + @Test + fun `hashCode() should return the same value each time it is invoked on a given object`() = + runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo()) { errorInfo: ErrorInfoImpl -> + val hashCode1 = errorInfo.hashCode() + errorInfo.hashCode() shouldBe hashCode1 + errorInfo.hashCode() shouldBe hashCode1 + } + } + + @Test + fun `hashCode() should return the same value on equal objects`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo()) { errorInfo1: ErrorInfoImpl -> + val errorInfo2 = ErrorInfoImpl(errorInfo1.message, errorInfo1.path) + errorInfo1.hashCode() shouldBe errorInfo2.hashCode() + } + } + + @Test + fun `hashCode() should return a different value if message is different`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo(), Arb.dataConnect.string()) { + errorInfo1: ErrorInfoImpl, + otherMessage: String -> + assume(errorInfo1.message.hashCode() != otherMessage.hashCode()) + val errorInfo2 = ErrorInfoImpl(otherMessage, errorInfo1.path) + errorInfo1.equals(errorInfo2) shouldBe false + } + } + + @Test + fun `hashCode() should return a different value if path is different`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo(), errorPathArb()) { + errorInfo1: ErrorInfoImpl, + otherPath: List -> + assume(errorInfo1.path.hashCode() != otherPath.hashCode()) + val errorInfo2 = ErrorInfoImpl(errorInfo1.message, otherPath) + errorInfo1.equals(errorInfo2) shouldBe false + } + } +} + +private object MyArb { + + fun samplePathSegments( + field: Arb = fieldPathSegmentArb(), + listIndex: Arb = listIndexPathSegmentArb(), + ): Arb = + Arb.bind(field, field, listIndex, listIndex) { field1, field2, listIndex1, listIndex2 -> + SamplePathSegments(field1, field2, listIndex1, listIndex2) + } + + data class SamplePathSegments( + val field1: DataConnectPathSegment.Field, + val field2: DataConnectPathSegment.Field, + val listIndex1: DataConnectPathSegment.ListIndex, + val listIndex2: DataConnectPathSegment.ListIndex, + ) +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationRefImplUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationRefImplUnitTest.kt index f61a824630e..ff43c85ad01 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationRefImplUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationRefImplUnitTest.kt @@ -26,9 +26,9 @@ import com.google.firebase.dataconnect.core.DataConnectGrpcClient.OperationResul import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb import com.google.firebase.dataconnect.testutil.property.arbitrary.OperationRefConstructorArguments import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnectError import com.google.firebase.dataconnect.testutil.property.arbitrary.mock import com.google.firebase.dataconnect.testutil.property.arbitrary.mutationRefImpl +import com.google.firebase.dataconnect.testutil.property.arbitrary.operationErrors import com.google.firebase.dataconnect.testutil.property.arbitrary.operationRefConstructorArguments import com.google.firebase.dataconnect.testutil.property.arbitrary.operationRefImpl import com.google.firebase.dataconnect.testutil.property.arbitrary.queryRefImpl @@ -37,7 +37,6 @@ import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingText import com.google.firebase.dataconnect.util.ProtoUtil.buildStructProto import com.google.firebase.dataconnect.util.ProtoUtil.encodeToStruct import com.google.firebase.dataconnect.util.ProtoUtil.toStructProto -import com.google.firebase.dataconnect.util.SuspendingLazy import com.google.protobuf.Struct import io.kotest.assertions.assertSoftly import io.kotest.assertions.throwables.shouldThrow @@ -181,7 +180,7 @@ class MutationRefImplUnitTest { @Test fun `execute() handles DataConnectUntypedVariables and DataConnectUntypedData`() = runTest { val variables = DataConnectUntypedVariables("foo" to 42.0) - val errors = listOf(Arb.dataConnect.dataConnectError().next()) + val errors = Arb.dataConnect.operationErrors().next() val data = DataConnectUntypedData(mapOf("bar" to 24.0), errors) val variablesSlot: CapturingSlot = slot() val operationResult = OperationResult(buildStructProto { put("bar", 24.0) }, errors) @@ -673,18 +672,16 @@ class MutationRefImplUnitTest { ): FirebaseDataConnectInternal = mockk(relaxed = true) { every { blockingDispatcher } returns UnconfinedTestDispatcher(testScheduler) - every { lazyGrpcClient } returns - SuspendingLazy { - mockk { - coEvery { - executeMutation( - capture(requestIdSlot), - capture(operationNameSlot), - capture(variablesSlot), - capture(callerSdkTypeSlot), - ) - } returns result.getOrThrow() - } + every { grpcClient } returns + mockk { + coEvery { + executeMutation( + capture(requestIdSlot), + capture(operationNameSlot), + capture(variablesSlot), + capture(callerSdkTypeSlot), + ) + } returns result.getOrThrow() } } } diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryRefImplUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryRefImplUnitTest.kt index fc6baffe601..904779cb4ce 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryRefImplUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryRefImplUnitTest.kt @@ -29,7 +29,6 @@ import com.google.firebase.dataconnect.testutil.property.arbitrary.queryRefImpl import com.google.firebase.dataconnect.testutil.property.arbitrary.shouldHavePropertiesEqualTo import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingText import com.google.firebase.dataconnect.util.SequencedReference -import com.google.firebase.dataconnect.util.SuspendingLazy import io.kotest.assertions.assertSoftly import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.withClue @@ -577,11 +576,9 @@ class QueryRefImplUnitTest { querySlot: CapturingSlot> ): FirebaseDataConnectInternal = mockk(relaxed = true) { - every { lazyQueryManager } returns - SuspendingLazy { - mockk { - coEvery { execute(capture(querySlot)) } returns SequencedReference(123, result) - } + every { queryManager } returns + mockk { + coEvery { execute(capture(querySlot)) } returns SequencedReference(123, result) } } } diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/LocalDateSerializerUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/LocalDateSerializerUnitTest.kt index 6bc0c45d3bc..2598d176e47 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/LocalDateSerializerUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/LocalDateSerializerUnitTest.kt @@ -205,7 +205,11 @@ class LocalDateSerializerUnitTest { } fun Arb.Companion.unparseableDash(): Arb { - val invalidString = string(1..5, codepoints.filterNot { it.value == '-'.code }) + val invalidString = + string( + 1..5, + codepoints.filterNot { it.value == '-'.code || it.value in '0'.code..'9'.code } + ) return arbitrary { rs -> val flags = Array(3) { rs.random.nextBoolean() } if (!flags[0]) { diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt index 44c2a5a4720..89b2c89bb0f 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt @@ -18,20 +18,22 @@ package com.google.firebase.dataconnect.testutil.property.arbitrary -import com.google.firebase.dataconnect.DataConnectError -import com.google.firebase.dataconnect.DataConnectError.PathSegment +import com.google.firebase.dataconnect.DataConnectPathSegment import com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType import com.google.firebase.dataconnect.OperationRef import com.google.firebase.dataconnect.core.DataConnectAppCheck import com.google.firebase.dataconnect.core.DataConnectAuth import com.google.firebase.dataconnect.core.DataConnectGrpcClient import com.google.firebase.dataconnect.core.DataConnectGrpcMetadata +import com.google.firebase.dataconnect.core.DataConnectOperationFailureResponseImpl +import com.google.firebase.dataconnect.core.DataConnectOperationFailureResponseImpl.ErrorInfoImpl import com.google.firebase.dataconnect.core.FirebaseDataConnectImpl import com.google.firebase.dataconnect.core.FirebaseDataConnectInternal import com.google.firebase.dataconnect.core.MutationRefImpl import com.google.firebase.dataconnect.core.OperationRefImpl import com.google.firebase.dataconnect.core.QueryRefImpl import com.google.firebase.dataconnect.testutil.StubOperationRefImpl +import com.google.firebase.dataconnect.util.ProtoUtil.toMap import com.google.protobuf.Struct import io.kotest.assertions.assertSoftly import io.kotest.assertions.withClue @@ -40,11 +42,12 @@ import io.kotest.property.Arb import io.kotest.property.arbitrary.Codepoint import io.kotest.property.arbitrary.alphanumeric import io.kotest.property.arbitrary.arbitrary -import io.kotest.property.arbitrary.choice +import io.kotest.property.arbitrary.bind import io.kotest.property.arbitrary.constant import io.kotest.property.arbitrary.enum import io.kotest.property.arbitrary.int import io.kotest.property.arbitrary.list +import io.kotest.property.arbitrary.map import io.kotest.property.arbitrary.orNull import io.kotest.property.arbitrary.string import io.mockk.mockk @@ -75,36 +78,39 @@ internal fun DataConnectArb.dataConnectGrpcMetadata( ) } -internal fun DataConnectArb.fieldPathSegment( - string: Arb = string() -): Arb = arbitrary { PathSegment.Field(string.bind()) } +internal fun DataConnectArb.operationErrorInfo( + message: Arb = string(), + path: Arb> = errorPath(), +): Arb = + Arb.bind(message, path) { message0, path0 -> ErrorInfoImpl(message0, path0) } -internal fun DataConnectArb.listIndexPathSegment( - int: Arb = Arb.int() -): Arb = arbitrary { PathSegment.ListIndex(int.bind()) } +internal fun DataConnectArb.operationRawData(): Arb?> = + Arb.proto.struct().map { it.toMap() }.orNull(nullProbability = 0.33) -internal fun DataConnectArb.pathSegment(): Arb = - Arb.choice(fieldPathSegment(), listIndexPathSegment()) +internal data class SampleOperationData(val value: String) -internal fun DataConnectArb.sourceLocation( - line: Arb = Arb.int(), - column: Arb = Arb.int() -): Arb = arbitrary { - DataConnectError.SourceLocation(line = line.bind(), column = column.bind()) -} +internal fun DataConnectArb.operationData(): Arb = + string().map { SampleOperationData(it) }.orNull(nullProbability = 0.33) -internal fun DataConnectArb.dataConnectError( - message: Arb = string(), - path: Arb> = Arb.list(pathSegment(), 0..5), - locations: Arb> = Arb.list(sourceLocation(), 0..5) -): Arb = arbitrary { - DataConnectError(message = message.bind(), path = path.bind(), locations = locations.bind()) -} +internal fun DataConnectArb.operationErrors( + errorInfoImpl: Arb = operationErrorInfo(), + range: IntRange = 0..10, +): Arb> = Arb.list(errorInfoImpl, range) + +internal fun DataConnectArb.operationFailureResponseImpl( + rawData: Arb?> = operationRawData(), + data: Arb = operationData(), + errors: Arb> = operationErrors(), +): Arb> = + Arb.bind(rawData, data, errors) { rawData0, data0, errors0 -> + DataConnectOperationFailureResponseImpl(rawData0, data0, errors0) + } internal fun DataConnectArb.operationResult( data: Arb = Arb.proto.struct().orNull(nullProbability = 0.2), - errors: Arb> = Arb.list(dataConnectError(), 0..3), -) = arbitrary { DataConnectGrpcClient.OperationResult(data.bind(), errors.bind()) } + errors: Arb> = operationErrors(), +) = + Arb.bind(data, errors) { data0, errors0 -> DataConnectGrpcClient.OperationResult(data0, errors0) } internal fun DataConnectArb.queryRefImpl( variables: Arb, diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectOperationExceptionTestUtils.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectOperationExceptionTestUtils.kt new file mode 100644 index 00000000000..ac1ec9de005 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectOperationExceptionTestUtils.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.DataConnectOperationException +import com.google.firebase.dataconnect.DataConnectOperationFailureResponse.ErrorInfo +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.withClue +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlin.reflect.KClass + +fun DataConnectOperationException.shouldSatisfy( + expectedMessageSubstringCaseInsensitive: String, + expectedMessageSubstringCaseSensitive: String? = null, + expectedCause: KClass<*>?, + expectedRawData: Map?, + expectedData: T?, + expectedErrors: List, +): Unit = + shouldSatisfy( + expectedMessageSubstringCaseInsensitive = expectedMessageSubstringCaseInsensitive, + expectedMessageSubstringCaseSensitive = expectedMessageSubstringCaseSensitive, + expectedCause = expectedCause, + expectedRawData = expectedRawData, + expectedData = expectedData, + errorsValidator = { it.shouldContainExactly(expectedErrors) }, + ) + +fun DataConnectOperationException.shouldSatisfy( + expectedMessageSubstringCaseInsensitive: String, + expectedMessageSubstringCaseSensitive: String? = null, + expectedCause: KClass<*>?, + expectedRawData: Map?, + expectedData: T?, + errorsValidator: (List) -> Unit, +): Unit { + assertSoftly { + withClue("exception.message") { + message shouldContainWithNonAbuttingTextIgnoringCase expectedMessageSubstringCaseInsensitive + if (expectedMessageSubstringCaseSensitive != null) { + message shouldContainWithNonAbuttingText expectedMessageSubstringCaseSensitive + } + } + withClue("exception.cause") { + if (expectedCause == null) { + cause.shouldBeNull() + } else { + val cause = cause.shouldNotBeNull() + if (!expectedCause.isInstance(cause)) { + io.kotest.assertions.fail( + "cause was an instance of ${cause::class.qualifiedName}, " + + "but expected it to be an instance of ${expectedCause.qualifiedName}" + ) + } + } + } + withClue("exception.response.rawData") { response.rawData shouldBe expectedRawData } + withClue("exception.response.data") { response.data shouldBe expectedData } + withClue("exception.response.errors") { errorsValidator(response.errors) } + } +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/SuspendingCountDownLatch.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/SuspendingCountDownLatch.kt index 7098a390886..b7828e0c36a 100644 --- a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/SuspendingCountDownLatch.kt +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/SuspendingCountDownLatch.kt @@ -19,6 +19,7 @@ package com.google.firebase.dataconnect.testutil import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update /** * An implementation of [java.util.concurrent.CountDownLatch] that suspends instead of blocking. @@ -60,14 +61,10 @@ class SuspendingCountDownLatch(count: Int) { * @throws IllegalStateException if called when the count has already reached zero. */ fun countDown(): SuspendingCountDownLatch { - while (true) { - val oldValue = _count.value - check(oldValue > 0) { "countDown() called too many times (oldValue=$oldValue)" } - - val newValue = oldValue - 1 - if (_count.compareAndSet(oldValue, newValue)) { - return this - } + _count.update { currentValue -> + check(currentValue > 0) { "countDown() called too many times (currentValue=$currentValue)" } + currentValue - 1 } + return this } } diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt index 17d63fc7ebf..4a3f89a7ba8 100644 --- a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt @@ -19,6 +19,7 @@ package com.google.firebase.dataconnect.testutil.property.arbitrary import com.google.firebase.dataconnect.ConnectorConfig +import com.google.firebase.dataconnect.DataConnectPathSegment import com.google.firebase.dataconnect.DataConnectSettings import io.kotest.property.Arb import io.kotest.property.arbitrary.Codepoint @@ -27,11 +28,15 @@ import io.kotest.property.arbitrary.arabic import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.ascii import io.kotest.property.arbitrary.boolean +import io.kotest.property.arbitrary.choose import io.kotest.property.arbitrary.cyrillic import io.kotest.property.arbitrary.double import io.kotest.property.arbitrary.egyptianHieroglyphs import io.kotest.property.arbitrary.filterNot import io.kotest.property.arbitrary.hex +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.list +import io.kotest.property.arbitrary.map import io.kotest.property.arbitrary.merge import io.kotest.property.arbitrary.orNull import io.kotest.property.arbitrary.string @@ -132,6 +137,24 @@ object DataConnectArb { fun serializersModule(): Arb = arbitrary { mockk() }.orNull(nullProbability = 0.333) + + fun fieldPathSegment(string: Arb = string()): Arb = + string.map { DataConnectPathSegment.Field(it) } + + fun listIndexPathSegment(int: Arb = Arb.int()): Arb = + int.map { DataConnectPathSegment.ListIndex(it) } + + fun pathSegment( + field: Arb = fieldPathSegment(), + fieldWeight: Int = 1, + listIndex: Arb = listIndexPathSegment(), + listIndexWeight: Int = 1, + ): Arb = Arb.choose(fieldWeight to field, listIndexWeight to listIndex) + + fun errorPath( + pathSegment: Arb = pathSegment(), + range: IntRange = 0..10, + ): Arb> = Arb.list(pathSegment, range) } val Arb.Companion.dataConnect: DataConnectArb diff --git a/firebase-firestore/CHANGELOG.md b/firebase-firestore/CHANGELOG.md index 66fce5b35ce..29416bcf9a4 100644 --- a/firebase-firestore/CHANGELOG.md +++ b/firebase-firestore/CHANGELOG.md @@ -1,6 +1,26 @@ # Unreleased +# 25.1.4 +* [fixed] Fixed the `null` value handling in `whereNotEqualTo` and `whereNotIn` filters. +* [fixed] Catch exception when stream is already cancelled during close. [#6894](//github.com/firebase/firebase-android-sdk/pull/6894) + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-firestore` library. The Kotlin extensions library has no additional +updates. + +# 25.1.3 +* [fixed] Use lazy encoding in UTF-8 encoded byte comparison for strings to solve performance issues. [#6706](//github.com/firebase/firebase-android-sdk/pull/6706) +* [changed] Updated `protolite-well-known-types` dependency to `18.0.1`. [#6716] + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-firestore` library. The Kotlin extensions library has no additional +updates. + # 25.1.2 * [fixed] Fixed a server and sdk mismatch in unicode string sorting. [#6615](//github.com/firebase/firebase-android-sdk/pull/6615) diff --git a/firebase-firestore/gradle.properties b/firebase-firestore/gradle.properties index baa5399b1dc..e95453bed4d 100644 --- a/firebase-firestore/gradle.properties +++ b/firebase-firestore/gradle.properties @@ -1,2 +1,2 @@ -version=25.1.3 -latestReleasedVersion=25.1.2 +version=25.1.5 +latestReleasedVersion=25.1.4 diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/CompositeIndexQueryTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/CompositeIndexQueryTest.java index aa9be3bcf01..3c5ad1340ae 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/CompositeIndexQueryTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/CompositeIndexQueryTest.java @@ -91,24 +91,27 @@ public void testOrQueriesWithCompositeIndexes() { Query query = collection.where(or(greaterThan("a", 2), equalTo("b", 1))); // with one inequality: a>2 || b==1. - testHelper.assertOnlineAndOfflineResultsMatch(testHelper.query(query), "doc5", "doc2", "doc3"); + testHelper.assertOnlineAndOfflineResultsMatch( + collection, testHelper.query(query), "doc5", "doc2", "doc3"); // Test with limits (implicit order by ASC): (a==1) || (b > 0) LIMIT 2 query = collection.where(or(equalTo("a", 1), greaterThan("b", 0))).limit(2); - testHelper.assertOnlineAndOfflineResultsMatch(testHelper.query(query), "doc1", "doc2"); + testHelper.assertOnlineAndOfflineResultsMatch( + collection, testHelper.query(query), "doc1", "doc2"); // Test with limits (explicit order by): (a==1) || (b > 0) LIMIT_TO_LAST 2 // Note: The public query API does not allow implicit ordering when limitToLast is used. query = collection.where(or(equalTo("a", 1), greaterThan("b", 0))).limitToLast(2).orderBy("b"); - testHelper.assertOnlineAndOfflineResultsMatch(testHelper.query(query), "doc3", "doc4"); + testHelper.assertOnlineAndOfflineResultsMatch( + collection, testHelper.query(query), "doc3", "doc4"); // Test with limits (explicit order by ASC): (a==2) || (b == 1) ORDER BY a LIMIT 1 query = collection.where(or(equalTo("a", 2), equalTo("b", 1))).limit(1).orderBy("a"); - testHelper.assertOnlineAndOfflineResultsMatch(testHelper.query(query), "doc5"); + testHelper.assertOnlineAndOfflineResultsMatch(collection, testHelper.query(query), "doc5"); // Test with limits (explicit order by DESC): (a==2) || (b == 1) ORDER BY a LIMIT_TO_LAST 1 query = collection.where(or(equalTo("a", 2), equalTo("b", 1))).limitToLast(1).orderBy("a"); - testHelper.assertOnlineAndOfflineResultsMatch(testHelper.query(query), "doc2"); + testHelper.assertOnlineAndOfflineResultsMatch(collection, testHelper.query(query), "doc2"); } @Test @@ -771,17 +774,17 @@ public void testMultipleInequalityFromCacheAndFromServer() { // implicit AND: a != 1 && b < 2 Query query1 = testHelper.query(collection).whereNotEqualTo("a", 1).whereLessThan("b", 2); - testHelper.assertOnlineAndOfflineResultsMatch(query1, "doc2"); + testHelper.assertOnlineAndOfflineResultsMatch(collection, query1, "doc2"); // explicit AND: a != 1 && b < 2 Query query2 = testHelper.query(collection).where(and(notEqualTo("a", 1), lessThan("b", 2))); - testHelper.assertOnlineAndOfflineResultsMatch(query2, "doc2"); + testHelper.assertOnlineAndOfflineResultsMatch(collection, query2, "doc2"); // explicit AND: a < 3 && b not-in [2, 3] // Implicitly ordered by: a asc, b asc, __name__ asc Query query3 = testHelper.query(collection).where(and(lessThan("a", 3), notInArray("b", asList(2, 3)))); - testHelper.assertOnlineAndOfflineResultsMatch(query3, "doc1", "doc5", "doc2"); + testHelper.assertOnlineAndOfflineResultsMatch(collection, query3, "doc1", "doc5", "doc2"); // a <3 && b != 0, ordered by: b desc, a desc, __name__ desc Query query4 = @@ -791,11 +794,11 @@ public void testMultipleInequalityFromCacheAndFromServer() { .whereNotEqualTo("b", 0) .orderBy("b", Direction.DESCENDING) .limit(2); - testHelper.assertOnlineAndOfflineResultsMatch(query4, "doc4", "doc2"); + testHelper.assertOnlineAndOfflineResultsMatch(collection, query4, "doc4", "doc2"); // explicit OR: a>2 || b<1. Query query5 = testHelper.query(collection).where(or(greaterThan("a", 2), lessThan("b", 1))); - testHelper.assertOnlineAndOfflineResultsMatch(query5, "doc1", "doc3"); + testHelper.assertOnlineAndOfflineResultsMatch(collection, query5, "doc1", "doc3"); } @Test diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/FirestoreTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/FirestoreTest.java index 796632e192e..6afbd54b60f 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/FirestoreTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/FirestoreTest.java @@ -1651,24 +1651,40 @@ public void sdkOrdersQueryByDocumentIdTheSameWayOnlineAndOffline() { "a"); // Run query with snapshot listener - checkOnlineAndOfflineResultsMatch(orderedQuery, expectedDocIds.toArray(new String[0])); + checkOnlineAndOfflineResultsMatch(colRef, orderedQuery, expectedDocIds.toArray(new String[0])); } @Test public void snapshotListenerSortsUnicodeStringsAsServer() { Map> testDocs = map( - "a", map("value", "Łukasiewicz"), - "b", map("value", "Sierpiński"), - "c", map("value", "岩澤"), - "d", map("value", "🄟"), - "e", map("value", "P"), - "f", map("value", "︒"), - "g", map("value", "🐵")); + "a", + map("value", "Łukasiewicz"), + "b", + map("value", "Sierpiński"), + "c", + map("value", "岩澤"), + "d", + map("value", "🄟"), + "e", + map("value", "P"), + "f", + map("value", "︒"), + "g", + map("value", "🐵"), + "h", + map("value", "你好"), + "i", + map("value", "你顥"), + "j", + map("value", "😁"), + "k", + map("value", "😀")); CollectionReference colRef = testCollectionWithDocs(testDocs); Query orderedQuery = colRef.orderBy("value"); - List expectedDocIds = Arrays.asList("b", "a", "c", "f", "e", "d", "g"); + List expectedDocIds = + Arrays.asList("b", "a", "h", "i", "c", "f", "e", "d", "g", "k", "j"); QuerySnapshot getSnapshot = waitFor(orderedQuery.get()); List getSnapshotDocIds = @@ -1692,24 +1708,40 @@ public void snapshotListenerSortsUnicodeStringsAsServer() { assertTrue(getSnapshotDocIds.equals(expectedDocIds)); assertTrue(watchSnapshotDocIds.equals(expectedDocIds)); - checkOnlineAndOfflineResultsMatch(orderedQuery, expectedDocIds.toArray(new String[0])); + checkOnlineAndOfflineResultsMatch(colRef, orderedQuery, expectedDocIds.toArray(new String[0])); } @Test public void snapshotListenerSortsUnicodeStringsInArrayAsServer() { Map> testDocs = map( - "a", map("value", Arrays.asList("Łukasiewicz")), - "b", map("value", Arrays.asList("Sierpiński")), - "c", map("value", Arrays.asList("岩澤")), - "d", map("value", Arrays.asList("🄟")), - "e", map("value", Arrays.asList("P")), - "f", map("value", Arrays.asList("︒")), - "g", map("value", Arrays.asList("🐵"))); + "a", + map("value", Arrays.asList("Łukasiewicz")), + "b", + map("value", Arrays.asList("Sierpiński")), + "c", + map("value", Arrays.asList("岩澤")), + "d", + map("value", Arrays.asList("🄟")), + "e", + map("value", Arrays.asList("P")), + "f", + map("value", Arrays.asList("︒")), + "g", + map("value", Arrays.asList("🐵")), + "h", + map("value", Arrays.asList("你好")), + "i", + map("value", Arrays.asList("你顥")), + "j", + map("value", Arrays.asList("😁")), + "k", + map("value", Arrays.asList("😀"))); CollectionReference colRef = testCollectionWithDocs(testDocs); Query orderedQuery = colRef.orderBy("value"); - List expectedDocIds = Arrays.asList("b", "a", "c", "f", "e", "d", "g"); + List expectedDocIds = + Arrays.asList("b", "a", "h", "i", "c", "f", "e", "d", "g", "k", "j"); QuerySnapshot getSnapshot = waitFor(orderedQuery.get()); List getSnapshotDocIds = @@ -1733,24 +1765,40 @@ public void snapshotListenerSortsUnicodeStringsInArrayAsServer() { assertTrue(getSnapshotDocIds.equals(expectedDocIds)); assertTrue(watchSnapshotDocIds.equals(expectedDocIds)); - checkOnlineAndOfflineResultsMatch(orderedQuery, expectedDocIds.toArray(new String[0])); + checkOnlineAndOfflineResultsMatch(colRef, orderedQuery, expectedDocIds.toArray(new String[0])); } @Test public void snapshotListenerSortsUnicodeStringsInMapAsServer() { Map> testDocs = map( - "a", map("value", map("foo", "Łukasiewicz")), - "b", map("value", map("foo", "Sierpiński")), - "c", map("value", map("foo", "岩澤")), - "d", map("value", map("foo", "🄟")), - "e", map("value", map("foo", "P")), - "f", map("value", map("foo", "︒")), - "g", map("value", map("foo", "🐵"))); + "a", + map("value", map("foo", "Łukasiewicz")), + "b", + map("value", map("foo", "Sierpiński")), + "c", + map("value", map("foo", "岩澤")), + "d", + map("value", map("foo", "🄟")), + "e", + map("value", map("foo", "P")), + "f", + map("value", map("foo", "︒")), + "g", + map("value", map("foo", "🐵")), + "h", + map("value", map("foo", "你好")), + "i", + map("value", map("foo", "你顥")), + "j", + map("value", map("foo", "😁")), + "k", + map("value", map("foo", "😀"))); CollectionReference colRef = testCollectionWithDocs(testDocs); Query orderedQuery = colRef.orderBy("value"); - List expectedDocIds = Arrays.asList("b", "a", "c", "f", "e", "d", "g"); + List expectedDocIds = + Arrays.asList("b", "a", "h", "i", "c", "f", "e", "d", "g", "k", "j"); QuerySnapshot getSnapshot = waitFor(orderedQuery.get()); List getSnapshotDocIds = @@ -1774,24 +1822,40 @@ public void snapshotListenerSortsUnicodeStringsInMapAsServer() { assertTrue(getSnapshotDocIds.equals(expectedDocIds)); assertTrue(watchSnapshotDocIds.equals(expectedDocIds)); - checkOnlineAndOfflineResultsMatch(orderedQuery, expectedDocIds.toArray(new String[0])); + checkOnlineAndOfflineResultsMatch(colRef, orderedQuery, expectedDocIds.toArray(new String[0])); } @Test public void snapshotListenerSortsUnicodeStringsInMapKeyAsServer() { Map> testDocs = map( - "a", map("value", map("Łukasiewicz", "foo")), - "b", map("value", map("Sierpiński", "foo")), - "c", map("value", map("岩澤", "foo")), - "d", map("value", map("🄟", "foo")), - "e", map("value", map("P", "foo")), - "f", map("value", map("︒", "foo")), - "g", map("value", map("🐵", "foo"))); + "a", + map("value", map("Łukasiewicz", "foo")), + "b", + map("value", map("Sierpiński", "foo")), + "c", + map("value", map("岩澤", "foo")), + "d", + map("value", map("🄟", "foo")), + "e", + map("value", map("P", "foo")), + "f", + map("value", map("︒", "foo")), + "g", + map("value", map("🐵", "foo")), + "h", + map("value", map("你好", "foo")), + "i", + map("value", map("你顥", "foo")), + "j", + map("value", map("😁", "foo")), + "k", + map("value", map("😀", "foo"))); CollectionReference colRef = testCollectionWithDocs(testDocs); Query orderedQuery = colRef.orderBy("value"); - List expectedDocIds = Arrays.asList("b", "a", "c", "f", "e", "d", "g"); + List expectedDocIds = + Arrays.asList("b", "a", "h", "i", "c", "f", "e", "d", "g", "k", "j"); QuerySnapshot getSnapshot = waitFor(orderedQuery.get()); List getSnapshotDocIds = @@ -1815,25 +1879,90 @@ public void snapshotListenerSortsUnicodeStringsInMapKeyAsServer() { assertTrue(getSnapshotDocIds.equals(expectedDocIds)); assertTrue(watchSnapshotDocIds.equals(expectedDocIds)); - checkOnlineAndOfflineResultsMatch(orderedQuery, expectedDocIds.toArray(new String[0])); + checkOnlineAndOfflineResultsMatch(colRef, orderedQuery, expectedDocIds.toArray(new String[0])); } @Test public void snapshotListenerSortsUnicodeStringsInDocumentKeyAsServer() { Map> testDocs = map( - "Łukasiewicz", map("value", "foo"), - "Sierpiński", map("value", "foo"), - "岩澤", map("value", "foo"), - "🄟", map("value", "foo"), - "P", map("value", "foo"), - "︒", map("value", "foo"), - "🐵", map("value", "foo")); + "Łukasiewicz", + map("value", "foo"), + "Sierpiński", + map("value", "foo"), + "岩澤", + map("value", "foo"), + "🄟", + map("value", "foo"), + "P", + map("value", "foo"), + "︒", + map("value", "foo"), + "🐵", + map("value", "foo"), + "你好", + map("value", "foo"), + "你顥", + map("value", "foo"), + "😁", + map("value", "foo"), + "😀", + map("value", "foo")); CollectionReference colRef = testCollectionWithDocs(testDocs); Query orderedQuery = colRef.orderBy(FieldPath.documentId()); List expectedDocIds = - Arrays.asList("Sierpiński", "Łukasiewicz", "岩澤", "︒", "P", "🄟", "🐵"); + Arrays.asList( + "Sierpiński", "Łukasiewicz", "你好", "你顥", "岩澤", "︒", "P", "🄟", "🐵", "😀", "😁"); + + QuerySnapshot getSnapshot = waitFor(orderedQuery.get()); + List getSnapshotDocIds = + getSnapshot.getDocuments().stream().map(ds -> ds.getId()).collect(Collectors.toList()); + + EventAccumulator eventAccumulator = new EventAccumulator(); + ListenerRegistration registration = + orderedQuery.addSnapshotListener(eventAccumulator.listener()); + + List watchSnapshotDocIds = new ArrayList<>(); + try { + QuerySnapshot watchSnapshot = eventAccumulator.await(); + watchSnapshotDocIds = + watchSnapshot.getDocuments().stream() + .map(documentSnapshot -> documentSnapshot.getId()) + .collect(Collectors.toList()); + } finally { + registration.remove(); + } + + assertTrue(getSnapshotDocIds.equals(expectedDocIds)); + assertTrue(watchSnapshotDocIds.equals(expectedDocIds)); + + checkOnlineAndOfflineResultsMatch(colRef, orderedQuery, expectedDocIds.toArray(new String[0])); + } + + @Test + public void snapshotListenerSortsInvalidUnicodeStringsAsServer() { + // Note: Protocol Buffer converts any invalid surrogates to "?". + Map> testDocs = + map( + "a", + map("value", "Z"), + "b", + map("value", "你好"), + "c", + map("value", "😀"), + "d", + map("value", "ab\uD800"), // Lone high surrogate + "e", + map("value", "ab\uDC00"), // Lone low surrogate + "f", + map("value", "ab\uD800\uD800"), // Unpaired high surrogate + "g", + map("value", "ab\uDC00\uDC00")); // Unpaired low surrogate + + CollectionReference colRef = testCollectionWithDocs(testDocs); + Query orderedQuery = colRef.orderBy("value"); + List expectedDocIds = Arrays.asList("a", "d", "e", "f", "g", "b", "c"); QuerySnapshot getSnapshot = waitFor(orderedQuery.get()); List getSnapshotDocIds = @@ -1857,6 +1986,6 @@ public void snapshotListenerSortsUnicodeStringsInDocumentKeyAsServer() { assertTrue(getSnapshotDocIds.equals(expectedDocIds)); assertTrue(watchSnapshotDocIds.equals(expectedDocIds)); - checkOnlineAndOfflineResultsMatch(orderedQuery, expectedDocIds.toArray(new String[0])); + checkOnlineAndOfflineResultsMatch(colRef, orderedQuery, expectedDocIds.toArray(new String[0])); } } diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/QueryTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/QueryTest.java index 29ca658515e..f6209dd49a4 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/QueryTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/QueryTest.java @@ -1470,10 +1470,16 @@ public void testOrQueries() { // Two equalities: a==1 || b==1. checkOnlineAndOfflineResultsMatch( - collection.where(or(equalTo("a", 1), equalTo("b", 1))), "doc1", "doc2", "doc4", "doc5"); + collection, + collection.where(or(equalTo("a", 1), equalTo("b", 1))), + "doc1", + "doc2", + "doc4", + "doc5"); // (a==1 && b==0) || (a==3 && b==2) checkOnlineAndOfflineResultsMatch( + collection, collection.where( or(and(equalTo("a", 1), equalTo("b", 0)), and(equalTo("a", 3), equalTo("b", 2)))), "doc1", @@ -1481,19 +1487,21 @@ public void testOrQueries() { // a==1 && (b==0 || b==3). checkOnlineAndOfflineResultsMatch( + collection, collection.where(and(equalTo("a", 1), or(equalTo("b", 0), equalTo("b", 3)))), "doc1", "doc4"); // (a==2 || b==2) && (a==3 || b==3) checkOnlineAndOfflineResultsMatch( + collection, collection.where( and(or(equalTo("a", 2), equalTo("b", 2)), or(equalTo("a", 3), equalTo("b", 3)))), "doc3"); // Test with limits without orderBy (the __name__ ordering is the tie breaker). checkOnlineAndOfflineResultsMatch( - collection.where(or(equalTo("a", 2), equalTo("b", 1))).limit(1), "doc2"); + collection, collection.where(or(equalTo("a", 2), equalTo("b", 1))).limit(1), "doc2"); } @Test @@ -1510,7 +1518,11 @@ public void testOrQueriesWithIn() { // a==2 || b in [2,3] checkOnlineAndOfflineResultsMatch( - collection.where(or(equalTo("a", 2), inArray("b", asList(2, 3)))), "doc3", "doc4", "doc6"); + collection, + collection.where(or(equalTo("a", 2), inArray("b", asList(2, 3)))), + "doc3", + "doc4", + "doc6"); } @Test @@ -1527,10 +1539,15 @@ public void testOrQueriesWithArrayMembership() { // a==2 || b array-contains 7 checkOnlineAndOfflineResultsMatch( - collection.where(or(equalTo("a", 2), arrayContains("b", 7))), "doc3", "doc4", "doc6"); + collection, + collection.where(or(equalTo("a", 2), arrayContains("b", 7))), + "doc3", + "doc4", + "doc6"); // a==2 || b array-contains-any [0, 3] checkOnlineAndOfflineResultsMatch( + collection, collection.where(or(equalTo("a", 2), arrayContainsAny("b", asList(0, 3)))), "doc1", "doc4", @@ -1551,12 +1568,12 @@ public void testMultipleInOps() { // Two IN operations on different fields with disjunction. Query query1 = collection.where(or(inArray("a", asList(2, 3)), inArray("b", asList(0, 2)))); - checkOnlineAndOfflineResultsMatch(query1, "doc1", "doc3", "doc6"); + checkOnlineAndOfflineResultsMatch(collection, query1, "doc1", "doc3", "doc6"); // Two IN operations on the same field with disjunction. // a IN [0,3] || a IN [0,2] should union them (similar to: a IN [0,2,3]). Query query2 = collection.where(or(inArray("a", asList(0, 3)), inArray("a", asList(0, 2)))); - checkOnlineAndOfflineResultsMatch(query2, "doc3", "doc6"); + checkOnlineAndOfflineResultsMatch(collection, query2, "doc3", "doc6"); } @Test @@ -1573,14 +1590,14 @@ public void testUsingInWithArrayContainsAny() { Query query1 = collection.where(or(inArray("a", asList(2, 3)), arrayContainsAny("b", asList(0, 7)))); - checkOnlineAndOfflineResultsMatch(query1, "doc1", "doc3", "doc4", "doc6"); + checkOnlineAndOfflineResultsMatch(collection, query1, "doc1", "doc3", "doc4", "doc6"); Query query2 = collection.where( or( and(inArray("a", asList(2, 3)), equalTo("c", 10)), arrayContainsAny("b", asList(0, 7)))); - checkOnlineAndOfflineResultsMatch(query2, "doc1", "doc3", "doc4"); + checkOnlineAndOfflineResultsMatch(collection, query2, "doc1", "doc3", "doc4"); } @Test @@ -1596,20 +1613,20 @@ public void testUsingInWithArrayContains() { CollectionReference collection = testCollectionWithDocs(testDocs); Query query1 = collection.where(or(inArray("a", asList(2, 3)), arrayContains("b", 3))); - checkOnlineAndOfflineResultsMatch(query1, "doc3", "doc4", "doc6"); + checkOnlineAndOfflineResultsMatch(collection, query1, "doc3", "doc4", "doc6"); Query query2 = collection.where(and(inArray("a", asList(2, 3)), arrayContains("b", 7))); - checkOnlineAndOfflineResultsMatch(query2, "doc3"); + checkOnlineAndOfflineResultsMatch(collection, query2, "doc3"); Query query3 = collection.where( or(inArray("a", asList(2, 3)), and(arrayContains("b", 3), equalTo("a", 1)))); - checkOnlineAndOfflineResultsMatch(query3, "doc3", "doc4", "doc6"); + checkOnlineAndOfflineResultsMatch(collection, query3, "doc3", "doc4", "doc6"); Query query4 = collection.where( and(inArray("a", asList(2, 3)), or(arrayContains("b", 7), equalTo("a", 1)))); - checkOnlineAndOfflineResultsMatch(query4, "doc3"); + checkOnlineAndOfflineResultsMatch(collection, query4, "doc3"); } @Test @@ -1625,9 +1642,58 @@ public void testOrderByEquality() { CollectionReference collection = testCollectionWithDocs(testDocs); Query query1 = collection.where(equalTo("a", 1)).orderBy("a"); - checkOnlineAndOfflineResultsMatch(query1, "doc1", "doc4", "doc5"); + checkOnlineAndOfflineResultsMatch(collection, query1, "doc1", "doc4", "doc5"); Query query2 = collection.where(inArray("a", asList(2, 3))).orderBy("a"); - checkOnlineAndOfflineResultsMatch(query2, "doc6", "doc3"); + checkOnlineAndOfflineResultsMatch(collection, query2, "doc6", "doc3"); + } + + @Test + public void testSDKUsesNotEqualFiltersSameAsServer() { + Map> testDocs = + map( + "a", map("zip", Double.NaN), + "b", map("zip", 91102L), + "c", map("zip", 98101L), + "d", map("zip", "98101"), + "e", map("zip", asList(98101L)), + "f", map("zip", asList(98101L, 98102L)), + "g", map("zip", asList("98101", map("zip", 98101L))), + "h", map("zip", map("code", 500L)), + "i", map("zip", null), + "j", map("code", 500L)); + CollectionReference collection = testCollectionWithDocs(testDocs); + + Query query = collection.whereNotEqualTo("zip", 98101L); + checkOnlineAndOfflineResultsMatch(collection, query, "a", "b", "d", "e", "f", "g", "h"); + + query = collection.whereNotEqualTo("zip", Double.NaN); + checkOnlineAndOfflineResultsMatch(collection, query, "b", "c", "d", "e", "f", "g", "h"); + + query = collection.whereNotEqualTo("zip", null); + checkOnlineAndOfflineResultsMatch(collection, query, "a", "b", "c", "d", "e", "f", "g", "h"); + } + + @Test + public void testSDKUsesNotInFiltersSameAsServer() { + Map> testDocs = + map( + "a", map("zip", Double.NaN), + "b", map("zip", 91102L), + "c", map("zip", 98101L), + "d", map("zip", "98101"), + "e", map("zip", asList(98101L)), + "f", map("zip", asList(98101L, 98102L)), + "g", map("zip", asList("98101", map("zip", 98101L))), + "h", map("zip", map("code", 500L)), + "i", map("zip", null), + "j", map("code", 500L)); + CollectionReference collection = testCollectionWithDocs(testDocs); + + Query query = collection.whereNotIn("zip", asList(98101L, 98103L, asList(98101L, 98102L))); + checkOnlineAndOfflineResultsMatch(collection, query, "a", "b", "d", "e", "g", "h"); + + query = collection.whereNotIn("zip", nullList()); + checkOnlineAndOfflineResultsMatch(collection, query); } } diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/VectorTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/VectorTest.java index 8f51a3800d8..bab92383216 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/VectorTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/VectorTest.java @@ -323,7 +323,8 @@ public void vectorFieldOrderOnlineAndOffline() throws Exception { Query orderedQuery = randomColl.orderBy("embedding"); // Run query with snapshot listener - checkOnlineAndOfflineResultsMatch(orderedQuery, docIds.stream().toArray(String[]::new)); + checkOnlineAndOfflineResultsMatch( + randomColl, orderedQuery, docIds.stream().toArray(String[]::new)); } /** Verifies that the SDK filters vector fields the same way for online and offline queries*/ @@ -363,13 +364,15 @@ public void vectorFieldFilterOnlineAndOffline() throws Exception { .orderBy("embedding") .whereLessThan("embedding", FieldValue.vector(new double[] {1, 2, 100, 4, 4})); checkOnlineAndOfflineResultsMatch( - orderedQueryLessThan, docIds.subList(2, 11).stream().toArray(String[]::new)); + randomColl, orderedQueryLessThan, docIds.subList(2, 11).stream().toArray(String[]::new)); Query orderedQueryGreaterThan = randomColl .orderBy("embedding") .whereGreaterThan("embedding", FieldValue.vector(new double[] {1, 2, 100, 4, 4})); checkOnlineAndOfflineResultsMatch( - orderedQueryGreaterThan, docIds.subList(12, 13).stream().toArray(String[]::new)); + randomColl, + orderedQueryGreaterThan, + docIds.subList(12, 13).stream().toArray(String[]::new)); } } diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/testutil/CompositeIndexTestHelper.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/testutil/CompositeIndexTestHelper.java index 0751473ae4a..23b949c96d4 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/testutil/CompositeIndexTestHelper.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/testutil/CompositeIndexTestHelper.java @@ -122,8 +122,14 @@ private Map> prepareTestDocuments( // actual document IDs created by the test helper. @NonNull public void assertOnlineAndOfflineResultsMatch( - @NonNull Query query, @NonNull String... expectedDocs) { - checkOnlineAndOfflineResultsMatch(query, toHashedIds(expectedDocs)); + @NonNull CollectionReference collection, + @NonNull Query query, + @NonNull String... expectedDocs) { + // `checkOnlineAndOfflineResultsMatch` first makes sure all documents needed for + // `query` are in the cache. It does so making a `get` on the first argument. + // Since *all* composite index tests use the same collection, this is very inefficient to do. + // Therefore, we should only do so for tests where `TEST_ID_FIELD` matches the current test. + checkOnlineAndOfflineResultsMatch(this.query(collection), query, toHashedIds(expectedDocs)); } // Asserts that the IDs in the query snapshot matches the expected Ids. The expected document diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/testutil/IntegrationTestUtil.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/testutil/IntegrationTestUtil.java index a7417d96563..dd676b5f0ab 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/testutil/IntegrationTestUtil.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/testutil/IntegrationTestUtil.java @@ -524,17 +524,41 @@ public static List nullList() { * documents as running the query while offline. If `expectedDocs` is provided, it also checks * that both online and offline query result is equal to the expected documents. * + * This function first performs a "get" for the entire COLLECTION from the server. + * It then performs the QUERY from CACHE which, results in `executeFullCollectionScan()` + * It then performs the QUERY from SERVER. + * It then performs the QUERY from CACHE again, which results in `performQueryUsingRemoteKeys()`. + * It then ensure that all the above QUERY results are the same. + * + * @param collection The collection on which the query is performed. * @param query The query to check * @param expectedDocs Ordered list of document keys that are expected to match the query */ - public static void checkOnlineAndOfflineResultsMatch(Query query, String... expectedDocs) { + public static void checkOnlineAndOfflineResultsMatch( + Query collection, Query query, String... expectedDocs) { + // Note: Order matters. The following has to be done in the specific order: + + // 1- Pre-populate the cache with the entire collection. + waitFor(collection.get(Source.SERVER)); + + // 2- This performs the query against the cache using full collection scan. + QuerySnapshot docsFromCacheFullCollectionScan = waitFor(query.get(Source.CACHE)); + + // 3- This goes to the server (backend/emulator). QuerySnapshot docsFromServer = waitFor(query.get(Source.SERVER)); - QuerySnapshot docsFromCache = waitFor(query.get(Source.CACHE)); - assertEquals(querySnapshotToIds(docsFromServer), querySnapshotToIds(docsFromCache)); - List expected = asList(expectedDocs); - if (!expected.isEmpty()) { - assertEquals(expected, querySnapshotToIds(docsFromCache)); + // 4- This performs the query against the cache using remote keys. + QuerySnapshot docsFromCacheUsingRemoteKeys = waitFor(query.get(Source.CACHE)); + + assertEquals( + querySnapshotToIds(docsFromServer), querySnapshotToIds(docsFromCacheFullCollectionScan)); + assertEquals( + querySnapshotToIds(docsFromServer), querySnapshotToIds(docsFromCacheUsingRemoteKeys)); + + // Expected document IDs. + List expectedDocIds = asList(expectedDocs); + if (!expectedDocIds.isEmpty()) { + assertEquals(expectedDocIds, querySnapshotToIds(docsFromServer)); } } } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FieldFilter.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FieldFilter.java index 6ebf26d0718..04a6f252a80 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FieldFilter.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FieldFilter.java @@ -115,7 +115,9 @@ public boolean matches(Document doc) { Value other = doc.getField(field); // Types do not have to match in NOT_EQUAL filters. if (operator == Operator.NOT_EQUAL) { - return other != null && this.matchesComparison(Values.compare(other, value)); + return other != null + && !other.hasNullValue() + && this.matchesComparison(Values.compare(other, value)); } // Only compare types with matching backend order (such as double and int). return other != null diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/NotInFilter.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/NotInFilter.java index 1f827f688e8..2f430d7f71c 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/NotInFilter.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/NotInFilter.java @@ -34,6 +34,8 @@ public boolean matches(Document doc) { return false; } Value other = doc.getField(getField()); - return other != null && !Values.contains(getValue().getArrayValue(), other); + return other != null + && !other.hasNullValue() + && !Values.contains(getValue().getArrayValue(), other); } } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/AbstractStream.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/AbstractStream.java index c1cd01dfbe9..963cb4ca532 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/AbstractStream.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/AbstractStream.java @@ -352,7 +352,21 @@ private void close(State finalState, Status status) { getClass().getSimpleName(), "(%x) Closing stream client-side", System.identityHashCode(this)); - call.halfClose(); + try { + call.halfClose(); + } catch (IllegalStateException e) { + // Secondary failure encountered. The underlying RPC has entered an error state. We will + // log and continue since the RPC is being discarded anyway. + // + // Example, "IllegalStateException: call was cancelled" was observed in + // https://github.com/firebase/firebase-android-sdk/issues/6883 + // Likely caused by other part of system already cancelling stream. + Logger.debug( + getClass().getSimpleName(), + "(%x) Closing stream client-side result in exception: [%s]", + System.identityHashCode(this), + e); + } } call = null; } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/util/Util.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/util/Util.java index 543da11e7d3..2cc39337002 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/util/Util.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/util/Util.java @@ -87,9 +87,44 @@ public static int compareIntegers(int i1, int i2) { /** Compare strings in UTF-8 encoded byte order */ public static int compareUtf8Strings(String left, String right) { - ByteString leftBytes = ByteString.copyFromUtf8(left); - ByteString rightBytes = ByteString.copyFromUtf8(right); - return compareByteStrings(leftBytes, rightBytes); + int i = 0; + while (i < left.length() && i < right.length()) { + int leftCodePoint = left.codePointAt(i); + int rightCodePoint = right.codePointAt(i); + + if (leftCodePoint != rightCodePoint) { + if (leftCodePoint < 128 && rightCodePoint < 128) { + // ASCII comparison + return Integer.compare(leftCodePoint, rightCodePoint); + } else { + // substring and do UTF-8 encoded byte comparison + ByteString leftBytes = ByteString.copyFromUtf8(getUtf8SafeBytes(left, i)); + ByteString rightBytes = ByteString.copyFromUtf8(getUtf8SafeBytes(right, i)); + int comp = compareByteStrings(leftBytes, rightBytes); + if (comp != 0) { + return comp; + } else { + // EXTREMELY RARE CASE: Code points differ, but their UTF-8 byte representations are + // identical. This can happen with malformed input (invalid surrogate pairs), where + // Java's encoding leads to unexpected byte sequences. Meanwhile, any invalid surrogate + // inputs get converted to "?" by protocol buffer while round tripping, so we almost + // never receive invalid strings from backend. + // Fallback to code point comparison for graceful handling. + return Integer.compare(leftCodePoint, rightCodePoint); + } + } + } + // Increment by 2 for surrogate pairs, 1 otherwise. + i += Character.charCount(leftCodePoint); + } + + // Compare lengths if all characters are equal + return Integer.compare(left.length(), right.length()); + } + + private static String getUtf8SafeBytes(String str, int index) { + int firstCodePoint = str.codePointAt(index); + return str.substring(index, index + Character.charCount(firstCodePoint)); } /** diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/core/QueryTest.java b/firebase-firestore/src/test/java/com/google/firebase/firestore/core/QueryTest.java index de3de67463c..cdc932bfa01 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/core/QueryTest.java +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/core/QueryTest.java @@ -237,7 +237,7 @@ public void testNotInFilters() { // Null match. document = doc("collection/1", 0, map("zip", null)); - assertTrue(query.matches(document)); + assertFalse(query.matches(document)); // NaN match. document = doc("collection/1", 0, map("zip", Double.NaN)); @@ -333,7 +333,7 @@ public void testNaNFilter() { assertTrue(query.matches(doc3)); assertTrue(query.matches(doc4)); assertTrue(query.matches(doc5)); - assertTrue(query.matches(doc6)); + assertFalse(query.matches(doc6)); } @Test diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/util/UtilTest.java b/firebase-firestore/src/test/java/com/google/firebase/firestore/util/UtilTest.java index 6ff424ef994..ccd88854ba7 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/util/UtilTest.java +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/util/UtilTest.java @@ -17,6 +17,7 @@ import static com.google.common.truth.Truth.assertThat; import static com.google.firebase.firestore.util.Util.firstNEntries; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; import com.google.firebase.firestore.testutil.TestUtil; import com.google.protobuf.ByteString; @@ -26,6 +27,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Random; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -87,4 +89,184 @@ private void validateDiffCollection(List before, List after) { Util.diffCollections(before, after, String::compareTo, result::add, result::remove); assertThat(result).containsExactlyElementsIn(after); } + + @Test + public void compareUtf8StringsShouldReturnCorrectValue() { + ArrayList errors = new ArrayList<>(); + int seed = new Random().nextInt(Integer.MAX_VALUE); + int passCount = 0; + StringGenerator stringGenerator = new StringGenerator(29750468); + StringPairGenerator stringPairGenerator = new StringPairGenerator(stringGenerator); + for (int i = 0; i < 1_000_000 && errors.size() < 10; i++) { + StringPairGenerator.StringPair stringPair = stringPairGenerator.next(); + final String s1 = stringPair.s1; + final String s2 = stringPair.s2; + + int actual = Util.compareUtf8Strings(s1, s2); + + ByteString b1 = ByteString.copyFromUtf8(s1); + ByteString b2 = ByteString.copyFromUtf8(s2); + int expected = Util.compareByteStrings(b1, b2); + + if (actual == expected) { + passCount++; + } else { + errors.add( + "compareUtf8Strings(s1=\"" + + s1 + + "\", s2=\"" + + s2 + + "\") returned " + + actual + + ", but expected " + + expected + + " (i=" + + i + + ", s1.length=" + + s1.length() + + ", s2.length=" + + s2.length() + + ")"); + } + } + + if (!errors.isEmpty()) { + StringBuilder sb = new StringBuilder(); + sb.append(errors.size()).append(" test cases failed, "); + sb.append(passCount).append(" test cases passed, "); + sb.append("seed=").append(seed).append(";"); + for (int i = 0; i < errors.size(); i++) { + sb.append("\nerrors[").append(i).append("]: ").append(errors.get(i)); + } + fail(sb.toString()); + } + } + + private static class StringPairGenerator { + + private final StringGenerator stringGenerator; + + public StringPairGenerator(StringGenerator stringGenerator) { + this.stringGenerator = stringGenerator; + } + + public StringPair next() { + String prefix = stringGenerator.next(); + String s1 = prefix + stringGenerator.next(); + String s2 = prefix + stringGenerator.next(); + return new StringPair(s1, s2); + } + + public static class StringPair { + public final String s1, s2; + + public StringPair(String s1, String s2) { + this.s1 = s1; + this.s2 = s2; + } + } + } + + private static class StringGenerator { + + private static final float DEFAULT_SURROGATE_PAIR_PROBABILITY = 0.33f; + private static final int DEFAULT_MAX_LENGTH = 20; + + private static final int MIN_HIGH_SURROGATE = 0xD800; + private static final int MAX_HIGH_SURROGATE = 0xDBFF; + private static final int MIN_LOW_SURROGATE = 0xDC00; + private static final int MAX_LOW_SURROGATE = 0xDFFF; + + private final Random rnd; + private final float surrogatePairProbability; + private final int maxLength; + + public StringGenerator(int seed) { + this(new Random(seed), DEFAULT_SURROGATE_PAIR_PROBABILITY, DEFAULT_MAX_LENGTH); + } + + public StringGenerator(Random rnd, float surrogatePairProbability, int maxLength) { + this.rnd = rnd; + this.surrogatePairProbability = validateProbability(surrogatePairProbability); + this.maxLength = validateLength(maxLength); + } + + private static float validateProbability(float probability) { + if (!Float.isFinite(probability)) { + throw new IllegalArgumentException( + "invalid surrogate pair probability: " + + probability + + " (must be between 0.0 and 1.0, inclusive)"); + } else if (probability < 0.0f) { + throw new IllegalArgumentException( + "invalid surrogate pair probability: " + + probability + + " (must be greater than or equal to zero)"); + } else if (probability > 1.0f) { + throw new IllegalArgumentException( + "invalid surrogate pair probability: " + + probability + + " (must be less than or equal to 1)"); + } + return probability; + } + + private static int validateLength(int length) { + if (length < 0) { + throw new IllegalArgumentException( + "invalid maximum string length: " + + length + + " (must be greater than or equal to zero)"); + } + return length; + } + + public String next() { + final int length = rnd.nextInt(maxLength + 1); + final StringBuilder sb = new StringBuilder(); + while (sb.length() < length) { + int codePoint = nextCodePoint(); + sb.appendCodePoint(codePoint); + } + return sb.toString(); + } + + private boolean isNextSurrogatePair() { + return nextBoolean(rnd, surrogatePairProbability); + } + + private static boolean nextBoolean(Random rnd, float probability) { + if (probability == 0.0f) { + return false; + } else if (probability == 1.0f) { + return true; + } else { + return rnd.nextFloat() < probability; + } + } + + private int nextCodePoint() { + if (isNextSurrogatePair()) { + return nextSurrogateCodePoint(); + } else { + return nextNonSurrogateCodePoint(); + } + } + + private int nextSurrogateCodePoint() { + int highSurrogate = + rnd.nextInt(MAX_HIGH_SURROGATE - MIN_HIGH_SURROGATE + 1) + MIN_HIGH_SURROGATE; + int lowSurrogate = rnd.nextInt(MAX_LOW_SURROGATE - MIN_LOW_SURROGATE + 1) + MIN_LOW_SURROGATE; + return Character.toCodePoint((char) highSurrogate, (char) lowSurrogate); + } + + private int nextNonSurrogateCodePoint() { + int codePoint; + do { + codePoint = rnd.nextInt(0x10000); // BMP range + } while (codePoint >= MIN_HIGH_SURROGATE + && codePoint <= MAX_LOW_SURROGATE); // Exclude surrogate range + return codePoint; + } + } } diff --git a/firebase-functions/CHANGELOG.md b/firebase-functions/CHANGELOG.md index e9fe66c897d..72ae1363ca1 100644 --- a/firebase-functions/CHANGELOG.md +++ b/firebase-functions/CHANGELOG.md @@ -1,8 +1,35 @@ # Unreleased + + +# 21.2.1 +* [fixed] Fixed issue that caused the SDK to crash when trying to stream a function that does not exist. + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-functions` library. The Kotlin extensions library has no additional +updates. + +# 21.2.0 +* [feature] Streaming callable functions are now supported. +* [fixed] Fixed an issue that prevented the App Check token from being handled correctly in case of error. + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-functions` library. The Kotlin extensions library has no additional +updates. + +# 21.1.1 * [fixed] Resolve Kotlin migration visibility issues ([#6522](//github.com/firebase/firebase-android-sdk/pull/6522)) +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-functions` library. The Kotlin extensions library has no additional +updates. + # 21.1.0 * [changed] Migrated to Kotlin @@ -217,3 +244,4 @@ updates. optional region to override the default "us-central1". * [feature] New `useFunctionsEmulator` method allows testing against a local instance of the [Cloud Functions Emulator](https://firebase.google.com/docs/functions/local-emulator). + diff --git a/firebase-functions/api.txt b/firebase-functions/api.txt index a9a05c703a8..1a12a250b35 100644 --- a/firebase-functions/api.txt +++ b/firebase-functions/api.txt @@ -84,6 +84,8 @@ package com.google.firebase.functions { method public com.google.android.gms.tasks.Task call(Object? data); method public long getTimeout(); method public void setTimeout(long timeout, java.util.concurrent.TimeUnit units); + method public org.reactivestreams.Publisher stream(); + method public org.reactivestreams.Publisher stream(Object? data = null); method public com.google.firebase.functions.HttpsCallableReference withTimeout(long timeout, java.util.concurrent.TimeUnit units); property public final long timeout; } @@ -93,6 +95,21 @@ package com.google.firebase.functions { field public final Object? data; } + public abstract class StreamResponse { + } + + public static final class StreamResponse.Message extends com.google.firebase.functions.StreamResponse { + ctor public StreamResponse.Message(com.google.firebase.functions.HttpsCallableResult message); + method public com.google.firebase.functions.HttpsCallableResult getMessage(); + property public final com.google.firebase.functions.HttpsCallableResult message; + } + + public static final class StreamResponse.Result extends com.google.firebase.functions.StreamResponse { + ctor public StreamResponse.Result(com.google.firebase.functions.HttpsCallableResult result); + method public com.google.firebase.functions.HttpsCallableResult getResult(); + property public final com.google.firebase.functions.HttpsCallableResult result; + } + } package com.google.firebase.functions.ktx { diff --git a/firebase-functions/firebase-functions.gradle.kts b/firebase-functions/firebase-functions.gradle.kts index 7ec958bdd79..b1c220a6594 100644 --- a/firebase-functions/firebase-functions.gradle.kts +++ b/firebase-functions/firebase-functions.gradle.kts @@ -112,6 +112,8 @@ dependencies { implementation(libs.okhttp) implementation(libs.playservices.base) implementation(libs.playservices.basement) + api(libs.reactive.streams) + api(libs.playservices.tasks) kapt(libs.autovalue) @@ -131,6 +133,7 @@ dependencies { androidTestImplementation(libs.truth) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.kotlinx.coroutines.reactive) androidTestImplementation(libs.mockito.core) androidTestImplementation(libs.mockito.dexmaker) kapt("com.google.dagger:dagger-android-processor:2.43.2") diff --git a/firebase-functions/gradle.properties b/firebase-functions/gradle.properties index ff0fa6afed0..2c555435ffd 100644 --- a/firebase-functions/gradle.properties +++ b/firebase-functions/gradle.properties @@ -1,3 +1,3 @@ -version=21.1.1 -latestReleasedVersion=21.1.0 +version=21.2.2 +latestReleasedVersion=21.2.1 android.enableUnitTestBinaryResources=true diff --git a/firebase-functions/src/androidTest/backend/functions/index.js b/firebase-functions/src/androidTest/backend/functions/index.js index fed5a371b89..db1b9ab13e6 100644 --- a/firebase-functions/src/androidTest/backend/functions/index.js +++ b/firebase-functions/src/androidTest/backend/functions/index.js @@ -14,6 +14,16 @@ const assert = require('assert'); const functions = require('firebase-functions'); +const functionsV2 = require('firebase-functions/v2'); + +/** + * Pauses the execution for a specified amount of time. + * @param {number} ms - The number of milliseconds to sleep. + * @return {Promise} A promise that resolves after the specified time. + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} exports.dataTest = functions.https.onRequest((request, response) => { assert.deepEqual(request.body, { @@ -122,3 +132,105 @@ exports.timeoutTest = functions.https.onRequest((request, response) => { // Wait for longer than 500ms. setTimeout(() => response.send({data: true}), 500); }); + +const streamData = ['hello', 'world', 'this', 'is', 'cool']; + +/** + * Generates chunks of text asynchronously, yielding one chunk at a time. + * @async + * @generator + * @yields {string} A chunk of text from the data array. + */ +async function* generateText() { + for (const chunk of streamData) { + yield chunk; + await sleep(100); + } +} + +exports.genStream = functionsV2.https.onCall(async (request, response) => { + if (request.acceptsStreaming) { + for await (const chunk of generateText()) { + response.sendChunk(chunk); + } + } + else { + console.log("CLIENT DOES NOT SUPPORT STEAMING"); + } + return streamData.join(' '); +}); + +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 } +}; + +/** + * Generates weather forecasts asynchronously for the given locations. + * @async + * @generator + * @param {Array<{name: string}>} locations - An array of location objects. + */ +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 locations = request.data && request.data.data? + request.data.data: []; + const forecasts = []; + if (request.acceptsStreaming) { + for await (const chunk of generateForecast(locations)) { + forecasts.push(chunk); + response.sendChunk(chunk); + } + } + return {forecasts}; + }); + +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); + } + } else { + console.log("CLIENT DOES NOT SUPPORT STEAMING") + } + return "Stream Completed"; + } +); diff --git a/firebase-functions/src/androidTest/backend/functions/package.json b/firebase-functions/src/androidTest/backend/functions/package.json index 6c5f9933d8b..53a2aac9d4a 100644 --- a/firebase-functions/src/androidTest/backend/functions/package.json +++ b/firebase-functions/src/androidTest/backend/functions/package.json @@ -2,11 +2,11 @@ "name": "functions", "description": "Cloud Functions for Firebase", "dependencies": { - "firebase-admin": "11.8.0", - "firebase-functions": "4.4.0" + "firebase-admin": "13.2.0", + "firebase-functions": "6.3.2" }, "private": true, "engines": { - "node": "18" + "node": "22" } } diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/FirebaseContextProviderTest.java b/firebase-functions/src/androidTest/java/com/google/firebase/functions/FirebaseContextProviderTest.java index 1126ae55fbb..384230867d9 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/FirebaseContextProviderTest.java +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/FirebaseContextProviderTest.java @@ -117,7 +117,7 @@ public void getContext_whenOnlyAuthIsAvailableAndNotSignedIn_shouldContainOnlyIi } @Test - public void getContext_whenOnlyAppCheckIsAvailableAndHasError_shouldContainOnlyIid() + public void getContext_whenOnlyAppCheckIsAvailableAndHasError() throws ExecutionException, InterruptedException { FirebaseContextProvider contextProvider = new FirebaseContextProvider( @@ -129,11 +129,12 @@ public void getContext_whenOnlyAppCheckIsAvailableAndHasError_shouldContainOnlyI HttpsCallableContext context = Tasks.await(contextProvider.getContext(false)); assertThat(context.getAuthToken()).isNull(); assertThat(context.getInstanceIdToken()).isEqualTo(IID_TOKEN); - assertThat(context.getAppCheckToken()).isNull(); + // AppCheck token needs to be send in all circumstances. + assertThat(context.getAppCheckToken()).isEqualTo(APP_CHECK_TOKEN); } @Test - public void getContext_facLimitedUse_whenOnlyAppCheckIsAvailableAndHasError_shouldContainOnlyIid() + public void getContext_facLimitedUse_whenOnlyAppCheckIsAvailableAndHasError() throws ExecutionException, InterruptedException { FirebaseContextProvider contextProvider = new FirebaseContextProvider( @@ -145,7 +146,8 @@ public void getContext_facLimitedUse_whenOnlyAppCheckIsAvailableAndHasError_shou HttpsCallableContext context = Tasks.await(contextProvider.getContext(true)); assertThat(context.getAuthToken()).isNull(); assertThat(context.getInstanceIdToken()).isEqualTo(IID_TOKEN); - assertThat(context.getAppCheckToken()).isNull(); + // AppCheck token needs to be sent in all circumstances. + assertThat(context.getAppCheckToken()).isEqualTo(APP_CHECK_LIMITED_USE_TOKEN); } @Test diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt new file mode 100644 index 00000000000..8e0d26bff3e --- /dev/null +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -0,0 +1,239 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.functions + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.Firebase +import com.google.firebase.initialize +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.delay +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription + +@RunWith(AndroidJUnit4::class) +class StreamTests { + + private lateinit var functions: FirebaseFunctions + + @Before + fun setup() { + Firebase.initialize(ApplicationProvider.getApplicationContext()) + functions = Firebase.functions + } + + internal class StreamSubscriber : Subscriber { + internal val messages = mutableListOf() + internal var result: StreamResponse.Result? = null + internal var throwable: Throwable? = null + internal var isComplete = false + internal lateinit var subscription: Subscription + + override fun onSubscribe(subscription: Subscription) { + this.subscription = subscription + subscription.request(Long.MAX_VALUE) + } + + override fun onNext(streamResponse: StreamResponse) { + if (streamResponse is StreamResponse.Message) { + messages.add(streamResponse) + } else { + result = streamResponse as StreamResponse.Result + } + } + + override fun onError(t: Throwable?) { + throwable = t + } + + override fun onComplete() { + isComplete = true + } + } + + @Test + fun genStream_withPublisher_receivesMessagesAndFinalResult() = runBlocking { + val input = mapOf("data" to "Why is the sky blue") + val function = functions.getHttpsCallable("genStream") + val subscriber = StreamSubscriber() + + function.stream(input).subscribe(subscriber) + + while (!subscriber.isComplete) { + delay(100) + } + assertThat(subscriber.messages.map { it.message.data.toString() }) + .containsExactly("hello", "world", "this", "is", "cool") + assertThat(subscriber.result).isNotNull() + assertThat(subscriber.result!!.result.data.toString()).isEqualTo("hello world this is cool") + assertThat(subscriber.throwable).isNull() + assertThat(subscriber.isComplete).isTrue() + } + + @Test + fun genStream_withFlow_receivesMessagesAndFinalResult() = runBlocking { + val input = mapOf("data" to "Why is the sky blue") + val function = functions.getHttpsCallable("genStream") + var isComplete = false + var throwable: Throwable? = null + val messages = mutableListOf() + var result: StreamResponse.Result? = null + + val flow = function.stream(input).asFlow() + try { + withTimeout(10_000) { + flow.collect { response -> + if (response is StreamResponse.Message) { + messages.add(response) + } else { + result = response as StreamResponse.Result + } + } + } + isComplete = true + } catch (e: Throwable) { + throwable = e + } + + assertThat(throwable).isNull() + assertThat(messages.map { it.message.data.toString() }) + .containsExactly("hello", "world", "this", "is", "cool") + assertThat(result).isNotNull() + assertThat(result!!.result.data.toString()).isEqualTo("hello world this is cool") + assertThat(isComplete).isTrue() + } + + @Test + fun genStreamError_receivesError() = runBlocking { + val input = mapOf("data" to "test error") + val function = + functions.getHttpsCallable("genStreamError").withTimeout(10_000, TimeUnit.MILLISECONDS) + val subscriber = StreamSubscriber() + + function.stream(input).subscribe(subscriber) + + withTimeout(10_000) { + while (subscriber.throwable == null) { + delay(1_000) + } + } + + assertThat(subscriber.throwable).isNotNull() + assertThat(subscriber.throwable).isInstanceOf(FirebaseFunctionsException::class.java) + } + + @Test + fun nonExistentFunction_receivesError() = runBlocking { + val function = + functions.getHttpsCallable("nonexistentFunction").withTimeout(10_000, TimeUnit.MILLISECONDS) + val subscriber = StreamSubscriber() + + function.stream().subscribe(subscriber) + + withTimeout(10_000) { + while (subscriber.throwable == null) { + delay(1_000) + } + } + + assertThat(subscriber.throwable).isNotNull() + assertThat(subscriber.throwable).isInstanceOf(FirebaseFunctionsException::class.java) + assertThat((subscriber.throwable as FirebaseFunctionsException).code) + .isEqualTo(FirebaseFunctionsException.Code.NOT_FOUND) + } + + @Test + fun genStreamWeather_receivesWeatherForecasts() = runBlocking { + val inputData = listOf(mapOf("name" to "Toronto"), mapOf("name" to "London")) + val input = mapOf("data" to inputData) + + val function = functions.getHttpsCallable("genStreamWeather") + val subscriber = StreamSubscriber() + + function.stream(input).subscribe(subscriber) + + while (!subscriber.isComplete) { + delay(100) + } + + assertThat(subscriber.messages.map { it.message.data.toString() }) + .containsExactly( + "{temperature=25, location={name=Toronto}, conditions=snowy}", + "{temperature=50, location={name=London}, conditions=rainy}" + ) + assertThat(subscriber.result).isNotNull() + assertThat(subscriber.result!!.result.data.toString()).contains("forecasts") + assertThat(subscriber.throwable).isNull() + assertThat(subscriber.isComplete).isTrue() + } + + @Test + fun genStreamEmpty_receivesNoMessages() = runBlocking { + val function = functions.getHttpsCallable("genStreamEmpty") + val subscriber = StreamSubscriber() + + function.stream(mapOf("data" to "test")).subscribe(subscriber) + + withTimeout(10_000) { delay(1000) } + assertThat(subscriber.throwable).isNull() + assertThat(subscriber.messages).isEmpty() + assertThat(subscriber.result).isNull() + } + + @Test + fun genStreamResultOnly_receivesOnlyResult() = runBlocking { + val function = functions.getHttpsCallable("genStreamResultOnly") + val subscriber = StreamSubscriber() + + function.stream(mapOf("data" to "test")).subscribe(subscriber) + + while (!subscriber.isComplete) { + delay(100) + } + assertThat(subscriber.messages).isEmpty() + assertThat(subscriber.result).isNotNull() + assertThat(subscriber.result!!.result.data.toString()).isEqualTo("Only a result") + } + + @Test + fun genStreamLargeData_receivesMultipleChunks() = runBlocking { + val function = functions.getHttpsCallable("genStreamLargeData") + val subscriber = StreamSubscriber() + + function.stream(mapOf("data" to "test large data")).subscribe(subscriber) + + while (!subscriber.isComplete) { + delay(100) + } + assertThat(subscriber.messages).isNotEmpty() + assertThat(subscriber.messages.size).isEqualTo(10) + val receivedString = + subscriber.messages.joinToString(separator = "") { it.message.data.toString() } + val expectedString = "A".repeat(10000) + assertThat(receivedString.length).isEqualTo(10000) + assertThat(receivedString).isEqualTo(expectedString) + assertThat(subscriber.result).isNotNull() + assertThat(subscriber.result!!.result.data.toString()).isEqualTo("Stream Completed") + } +} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseContextProvider.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseContextProvider.kt index 7ab1f74bf5d..96f18eb2c05 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseContextProvider.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseContextProvider.kt @@ -88,11 +88,9 @@ constructor( if (getLimitedUseAppCheckToken) appCheck.limitedUseToken else appCheck.getToken(false) return tokenTask.onSuccessTask(executor) { result: AppCheckTokenResult -> if (result.error != null) { - // If there was an error getting the App Check token, do NOT send the placeholder - // token. Only valid App Check tokens should be sent to the functions backend. Log.w(TAG, "Error getting App Check token. Error: " + result.error) - return@onSuccessTask Tasks.forResult(null) } + // Send valid token (success) or placeholder (failure). Tasks.forResult(result.token) } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index 824670c4346..8839763c4a3 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -45,6 +45,7 @@ import okhttp3.RequestBody import okhttp3.Response import org.json.JSONException import org.json.JSONObject +import org.reactivestreams.Publisher /** FirebaseFunctions lets you call Cloud Functions for Firebase. */ public class FirebaseFunctions @@ -311,6 +312,21 @@ internal constructor( return tcs.task } + internal fun stream( + name: String, + data: Any?, + options: HttpsCallOptions + ): Publisher = stream(getURL(name), data, options) + + internal fun stream(url: URL, data: Any?, options: HttpsCallOptions): Publisher { + val task = + providerInstalled.task.continueWithTask(executor) { + contextProvider.getContext(options.limitedUseAppCheckTokens) + } + + return PublisherStream(url, data, options, client, this.serializer, task, executor) + } + public companion object { /** A task that will be resolved once ProviderInstaller has installed what it needs to. */ private val providerInstalled = TaskCompletionSource() diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 88db9db4ee4..215722584ba 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -17,6 +17,7 @@ import androidx.annotation.VisibleForTesting import com.google.android.gms.tasks.Task import java.net.URL import java.util.concurrent.TimeUnit +import org.reactivestreams.Publisher /** A reference to a particular Callable HTTPS trigger in Cloud Functions. */ public class HttpsCallableReference { @@ -61,10 +62,8 @@ public class HttpsCallableReference { * * * Any primitive type, including null, int, long, float, and boolean. * * [String] - * * [List&lt;?&gt;][java.util.List], where the contained objects are also one of these - * types. - * * [Map&lt;String, ?&gt;>][java.util.Map], where the values are also one of these - * types. + * * [List][java.util.List], where the contained objects are also one of these types. + * * [Map][java.util.Map], where the values are also one of these types. * * [org.json.JSONArray] * * [org.json.JSONObject] * * [org.json.JSONObject.NULL] @@ -125,6 +124,55 @@ public class HttpsCallableReference { } } + /** + * Streams data to the specified HTTPS endpoint. + * + * The data passed into the trigger can be any of the following types: + * + * * Any primitive type, including null, int, long, float, and boolean. + * * [String] + * * [List][java.util.List], where the contained objects are also one of these types. + * * [Map][java.util.Map], where the values are also one of these types. + * * [org.json.JSONArray] + * * [org.json.JSONObject] + * * [org.json.JSONObject.NULL] + * + * If the returned streamResponse fails, the exception will be one of the following types: + * + * * [java.io.IOException] + * - if the HTTPS request failed to connect. + * * [FirebaseFunctionsException] + * - if the request connected, but the function returned an error. + * + * The request to the Cloud Functions backend made by this method automatically includes a + * Firebase Instance ID token to identify the app instance. If a user is logged in with Firebase + * Auth, an auth token for the user will also be automatically included. + * + * Firebase Instance ID sends data to the Firebase backend periodically to collect information + * regarding the app instance. To stop this, see + * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new + * Instance ID the next time you call this method. + * + * @param data Parameters to pass to the endpoint. Defaults to `null` if not provided. + * @return [Publisher] that will emit intermediate data, and the final result, as it is generated + * by the function. + * @see org.json.JSONArray + * + * @see org.json.JSONObject + * + * @see java.io.IOException + * + * @see FirebaseFunctionsException + */ + @JvmOverloads + public fun stream(data: Any? = null): Publisher { + return if (name != null) { + functionsClient.stream(name, data, options) + } else { + functionsClient.stream(requireNotNull(url), data, options) + } + } + /** * Changes the timeout for calls from this instance of Functions. The default is 60 seconds. * diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt new file mode 100644 index 00000000000..d853dfbbb7b --- /dev/null +++ b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt @@ -0,0 +1,345 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.functions + +import com.google.android.gms.tasks.Task +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.io.InterruptedIOException +import java.net.URL +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicLong +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import org.json.JSONObject +import org.reactivestreams.Publisher +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription + +internal class PublisherStream( + private val url: URL, + private val data: Any?, + private val options: HttpsCallOptions, + private val client: OkHttpClient, + private val serializer: Serializer, + private val contextTask: Task, + private val executor: Executor +) : Publisher { + + private val subscribers = ConcurrentLinkedQueue, AtomicLong>>() + private var activeCall: Call? = null + @Volatile private var isStreamingStarted = false + @Volatile private var isCompleted = false + private val messageQueue = ConcurrentLinkedQueue() + + override fun subscribe(subscriber: Subscriber) { + synchronized(this) { + if (isCompleted) { + subscriber.onError( + FirebaseFunctionsException( + "Cannot subscribe: Streaming has already completed.", + FirebaseFunctionsException.Code.CANCELLED, + null + ) + ) + return + } + subscribers.add(subscriber to AtomicLong(0)) + } + + subscriber.onSubscribe( + object : Subscription { + override fun request(n: Long) { + if (n <= 0) { + subscriber.onError(IllegalArgumentException("Requested messages must be positive.")) + return + } + + synchronized(this@PublisherStream) { + if (isCompleted) return + + val subscriberEntry = subscribers.find { it.first == subscriber } + subscriberEntry?.second?.addAndGet(n) + dispatchMessages() + if (!isStreamingStarted) { + isStreamingStarted = true + startStreaming() + } + } + } + + override fun cancel() { + synchronized(this@PublisherStream) { + notifyError( + FirebaseFunctionsException( + "Stream was canceled", + FirebaseFunctionsException.Code.CANCELLED, + null + ) + ) + val iterator = subscribers.iterator() + while (iterator.hasNext()) { + val pair = iterator.next() + if (pair.first == subscriber) { + iterator.remove() + } + } + if (subscribers.isEmpty()) { + cancelStream() + } + } + } + } + ) + } + + private fun startStreaming() { + contextTask.addOnCompleteListener(executor) { contextTask -> + if (!contextTask.isSuccessful) { + notifyError( + FirebaseFunctionsException( + "Error retrieving context", + FirebaseFunctionsException.Code.INTERNAL, + null, + contextTask.exception + ) + ) + return@addOnCompleteListener + } + + val context = contextTask.result + val configuredClient = options.apply(client) + val requestBody = + RequestBody.create( + MediaType.parse("application/json"), + JSONObject(mapOf("data" to serializer.encode(data))).toString() + ) + val request = + Request.Builder() + .url(url) + .post(requestBody) + .apply { + header("Accept", "text/event-stream") + header("Content-Type", "application/json") + context?.apply { + authToken?.let { header("Authorization", "Bearer $it") } + instanceIdToken?.let { header("Firebase-Instance-ID-Token", it) } + appCheckToken?.let { header("X-Firebase-AppCheck", it) } + } + } + .build() + val call = configuredClient.newCall(request) + activeCall = call + + call.enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + val code: FirebaseFunctionsException.Code = + if (e is InterruptedIOException) { + FirebaseFunctionsException.Code.DEADLINE_EXCEEDED + } else { + FirebaseFunctionsException.Code.INTERNAL + } + notifyError(FirebaseFunctionsException(code.name, code, null, e)) + } + + override fun onResponse(call: Call, response: Response) { + validateResponse(response) + val bodyStream = response.body()?.byteStream() + if (bodyStream != null) { + processSSEStream(bodyStream) + } else { + notifyError( + FirebaseFunctionsException( + "Response body is null", + FirebaseFunctionsException.Code.INTERNAL, + null + ) + ) + } + } + } + ) + } + } + + private fun cancelStream() { + activeCall?.cancel() + notifyError( + FirebaseFunctionsException( + "Stream was canceled", + FirebaseFunctionsException.Code.CANCELLED, + null + ) + ) + } + + private fun processSSEStream(inputStream: InputStream) { + BufferedReader(InputStreamReader(inputStream)).use { reader -> + try { + val eventBuffer = StringBuilder() + reader.lineSequence().forEach { line -> + if (line.isBlank()) { + processEvent(eventBuffer.toString()) + eventBuffer.clear() + } else { + val dataChunk = + when { + line.startsWith("data:") -> line.removePrefix("data:") + line.startsWith("result:") -> line.removePrefix("result:") + else -> return@forEach + } + eventBuffer.append(dataChunk.trim()).append("\n") + } + } + if (eventBuffer.isNotEmpty()) { + processEvent(eventBuffer.toString()) + } + } catch (e: Exception) { + notifyError( + FirebaseFunctionsException( + e.message ?: "Error reading stream", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + ) + } + } + } + + private fun processEvent(dataChunk: String) { + try { + val json = JSONObject(dataChunk) + when { + json.has("message") -> { + serializer.decode(json.opt("message"))?.let { + messageQueue.add(StreamResponse.Message(message = HttpsCallableResult(it))) + } + dispatchMessages() + } + json.has("error") -> { + serializer.decode(json.opt("error"))?.let { + notifyError( + FirebaseFunctionsException( + it.toString(), + FirebaseFunctionsException.Code.INTERNAL, + it + ) + ) + } + } + json.has("result") -> { + serializer.decode(json.opt("result"))?.let { + messageQueue.add(StreamResponse.Result(result = HttpsCallableResult(it))) + dispatchMessages() + notifyComplete() + } + } + } + } catch (e: Throwable) { + notifyError( + FirebaseFunctionsException( + "Invalid JSON: $dataChunk", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + ) + } + } + + private fun dispatchMessages() { + synchronized(this) { + val iterator = subscribers.iterator() + while (iterator.hasNext()) { + val (subscriber, requestedCount) = iterator.next() + while (requestedCount.get() > 0 && messageQueue.isNotEmpty()) { + subscriber.onNext(messageQueue.poll()) + requestedCount.decrementAndGet() + } + } + } + } + + private fun notifyError(e: Throwable) { + if (!isCompleted) { + isCompleted = true + subscribers.forEach { (subscriber, _) -> + try { + subscriber.onError(e) + } catch (ignored: Exception) {} + } + subscribers.clear() + messageQueue.clear() + } + } + + private fun notifyComplete() { + if (!isCompleted) { + isCompleted = true + subscribers.forEach { (subscriber, _) -> subscriber.onComplete() } + subscribers.clear() + messageQueue.clear() + } + } + + private fun validateResponse(response: Response) { + if (response.isSuccessful) return + + val errorMessage: String + if ( + response.code() == 404 && + MediaType.parse(response.header("Content-Type") ?: "")?.subtype() == "html" + ) { + errorMessage = """URL not found. Raw response: ${response.body()?.string()}""".trimMargin() + notifyError( + FirebaseFunctionsException( + errorMessage, + FirebaseFunctionsException.Code.fromHttpStatus(response.code()), + null + ) + ) + return + } + + val text = response.body()?.string() ?: "" + val error: Any? + try { + val json = JSONObject(text) + error = serializer.decode(json.opt("error")) + } catch (e: Throwable) { + notifyError( + FirebaseFunctionsException( + "${e.message} Unexpected Response:\n$text ", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + ) + return + } + notifyError( + FirebaseFunctionsException(error.toString(), FirebaseFunctionsException.Code.INTERNAL, error) + ) + } +} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt new file mode 100644 index 00000000000..123f804614d --- /dev/null +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.functions + +/** + * Represents a response from a Server-Sent Event (SSE) stream. + * + * The SSE stream consists of two types of responses: + * - [Message]: Represents an intermediate event pushed from the server. + * - [Result]: Represents the final response that signifies the stream has ended. + */ +public abstract class StreamResponse private constructor() { + + /** + * An event message received during the stream. + * + * Messages are intermediate data chunks sent by the server while processing a request. + * + * Example SSE format: + * ```json + * data: { "message": { "chunk": "foo" } } + * ``` + * + * @property message the intermediate data received from the server. + */ + public class Message(public val message: HttpsCallableResult) : StreamResponse() + + /** + * The final result of the computation, marking the end of the stream. + * + * Unlike [Message], which represents intermediate data chunks, [Result] contains the complete + * computation output. If clients only care about the final result, they can process this type + * alone and ignore intermediate messages. + * + * Example SSE format: + * ```json + * data: { "result": { "text": "foo bar" } } + * ``` + * + * @property result the final computed result received from the server. + */ + public class Result(public val result: HttpsCallableResult) : StreamResponse() +} diff --git a/firebase-inappmessaging-display/CHANGELOG.md b/firebase-inappmessaging-display/CHANGELOG.md index 15bd2abe75a..706aad30b1d 100644 --- a/firebase-inappmessaging-display/CHANGELOG.md +++ b/firebase-inappmessaging-display/CHANGELOG.md @@ -1,6 +1,15 @@ # Unreleased +# 21.0.2 +* [changed] Updated `protolite-well-known-types` dependency to `18.0.1`. [#6716] + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-inappmessaging-display` library. The Kotlin extensions library has no additional +updates. + # 21.0.1 * [changed] Updated protobuf dependency to `3.25.5` to fix [CVE-2024-7254](https://nvd.nist.gov/vuln/detail/CVE-2024-7254). diff --git a/firebase-inappmessaging-display/gradle.properties b/firebase-inappmessaging-display/gradle.properties index 4ad037837d4..f8301e25bcc 100644 --- a/firebase-inappmessaging-display/gradle.properties +++ b/firebase-inappmessaging-display/gradle.properties @@ -1,2 +1,2 @@ -version=21.0.2 -latestReleasedVersion=21.0.1 +version=21.0.3 +latestReleasedVersion=21.0.2 diff --git a/firebase-inappmessaging/CHANGELOG.md b/firebase-inappmessaging/CHANGELOG.md index 90b3e93ccae..c6f88c8e69d 100644 --- a/firebase-inappmessaging/CHANGELOG.md +++ b/firebase-inappmessaging/CHANGELOG.md @@ -1,6 +1,15 @@ # Unreleased +# 21.0.2 +* [changed] Updated `protolite-well-known-types` dependency to `18.0.1`. [#6716] + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-inappmessaging` library. The Kotlin extensions library has no additional +updates. + # 21.0.1 * [changed] Updated protobuf dependency to `3.25.5` to fix [CVE-2024-7254](https://nvd.nist.gov/vuln/detail/CVE-2024-7254). diff --git a/firebase-inappmessaging/gradle.properties b/firebase-inappmessaging/gradle.properties index 4ad037837d4..f8301e25bcc 100644 --- a/firebase-inappmessaging/gradle.properties +++ b/firebase-inappmessaging/gradle.properties @@ -1,2 +1,2 @@ -version=21.0.2 -latestReleasedVersion=21.0.1 +version=21.0.3 +latestReleasedVersion=21.0.2 diff --git a/firebase-inappmessaging/src/test/java/com/google/firebase/inappmessaging/internal/ForegroundNotifierTest.java b/firebase-inappmessaging/src/test/java/com/google/firebase/inappmessaging/internal/ForegroundNotifierTest.java index 2cedc0b7953..af01be33fb6 100644 --- a/firebase-inappmessaging/src/test/java/com/google/firebase/inappmessaging/internal/ForegroundNotifierTest.java +++ b/firebase-inappmessaging/src/test/java/com/google/firebase/inappmessaging/internal/ForegroundNotifierTest.java @@ -16,13 +16,14 @@ import static com.google.common.truth.Truth.assertThat; import static com.google.firebase.inappmessaging.internal.InAppMessageStreamManager.ON_FOREGROUND; +import static org.robolectric.Shadows.shadowOf; +import android.os.Looper; import io.reactivex.flowables.ConnectableFlowable; import io.reactivex.subscribers.TestSubscriber; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; @@ -61,7 +62,7 @@ public void notifier_onActivityResumedAfterRunnableExecution_notifiesListener() foregroundNotifier.onActivityResumed(null); // 1 assertThat(subscriber.getEvents().get(0)).hasSize(1); foregroundNotifier.onActivityPaused(null); - Robolectric.flushForegroundThreadScheduler(); + shadowOf(Looper.getMainLooper()).runToEndOfTasks(); foregroundNotifier.onActivityResumed(null); // 2 assertThat(subscriber.getEvents().get(0)).hasSize(2); } diff --git a/firebase-messaging-directboot/CHANGELOG.md b/firebase-messaging-directboot/CHANGELOG.md index ea728d95e54..1d9568c4f09 100644 --- a/firebase-messaging-directboot/CHANGELOG.md +++ b/firebase-messaging-directboot/CHANGELOG.md @@ -1,6 +1,9 @@ # Unreleased +# 24.1.1 +* [unchanged] Updated to keep messaging SDK versions aligned. + # 24.1.0 * [unchanged] Updated to keep messaging SDK versions aligned. diff --git a/firebase-messaging-directboot/gradle.properties b/firebase-messaging-directboot/gradle.properties index 11e55c591b5..23127f4cada 100644 --- a/firebase-messaging-directboot/gradle.properties +++ b/firebase-messaging-directboot/gradle.properties @@ -1,3 +1,3 @@ -version=24.1.1 -latestReleasedVersion=24.1.0 +version=24.1.2 +latestReleasedVersion=24.1.1 android.enableUnitTestBinaryResources=true diff --git a/firebase-messaging/CHANGELOG.md b/firebase-messaging/CHANGELOG.md index 4a7e28a5766..120620148dc 100644 --- a/firebase-messaging/CHANGELOG.md +++ b/firebase-messaging/CHANGELOG.md @@ -1,5 +1,16 @@ # Unreleased +* [changed] Added a NamedThreadFactory to WithinAppServiceConnection's service + connection Executor. +# 24.1.1 +* [changed] Bug fix in SyncTask to always unregister the receiver on the same + context on which it was registered. + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-messaging` library. The Kotlin extensions library has no additional +updates. # 24.1.0 * [deprecated] Deprecated additional FCM upstream messaging methods and updated diff --git a/firebase-messaging/gradle.properties b/firebase-messaging/gradle.properties index 11e55c591b5..23127f4cada 100644 --- a/firebase-messaging/gradle.properties +++ b/firebase-messaging/gradle.properties @@ -1,3 +1,3 @@ -version=24.1.1 -latestReleasedVersion=24.1.0 +version=24.1.2 +latestReleasedVersion=24.1.1 android.enableUnitTestBinaryResources=true diff --git a/firebase-messaging/src/main/java/com/google/firebase/messaging/SyncTask.java b/firebase-messaging/src/main/java/com/google/firebase/messaging/SyncTask.java index c0c4074c11e..cd821f0e1f3 100644 --- a/firebase-messaging/src/main/java/com/google/firebase/messaging/SyncTask.java +++ b/firebase-messaging/src/main/java/com/google/firebase/messaging/SyncTask.java @@ -161,6 +161,7 @@ boolean isDeviceConnected() { static class ConnectivityChangeReceiver extends BroadcastReceiver { @Nullable private SyncTask task; // task is set to null after it has been fired. + @Nullable private Context receiverContext; public ConnectivityChangeReceiver(SyncTask task) { this.task = task; @@ -171,7 +172,10 @@ public void registerReceiver() { Log.d(TAG, "Connectivity change received registered"); } IntentFilter intentFilter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION); - task.getContext().registerReceiver(this, intentFilter); + if (task != null) { + receiverContext = task.getContext(); + receiverContext.registerReceiver(this, intentFilter); + } } @Override @@ -191,7 +195,9 @@ public void onReceive(Context context, Intent intent) { Log.d(TAG, "Connectivity changed. Starting background sync."); } task.firebaseMessaging.enqueueTaskWithDelaySeconds(task, 0); - task.getContext().unregisterReceiver(this); + if (receiverContext != null) { + receiverContext.unregisterReceiver(this); + } task = null; } } diff --git a/firebase-messaging/src/main/java/com/google/firebase/messaging/WithinAppServiceConnection.java b/firebase-messaging/src/main/java/com/google/firebase/messaging/WithinAppServiceConnection.java index 63d13467edd..08823c88c90 100644 --- a/firebase-messaging/src/main/java/com/google/firebase/messaging/WithinAppServiceConnection.java +++ b/firebase-messaging/src/main/java/com/google/firebase/messaging/WithinAppServiceConnection.java @@ -27,6 +27,7 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.gms.common.stats.ConnectionTracker; +import com.google.android.gms.common.util.concurrent.NamedThreadFactory; import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.TaskCompletionSource; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -109,7 +110,9 @@ void finish() { @SuppressLint("ThreadPoolCreation") private static ScheduledThreadPoolExecutor createScheduledThreadPoolExecutor() { - ScheduledThreadPoolExecutor threadPoolExecutor = new ScheduledThreadPoolExecutor(1); + ScheduledThreadPoolExecutor threadPoolExecutor = + new ScheduledThreadPoolExecutor( + 1, new NamedThreadFactory("Firebase-FirebaseInstanceIdServiceConnection")); threadPoolExecutor.setKeepAliveTime(EnhancedIntentService.MESSAGE_TIMEOUT_S * 2, SECONDS); threadPoolExecutor.allowCoreThreadTimeOut(true); return threadPoolExecutor; diff --git a/firebase-perf/CHANGELOG.md b/firebase-perf/CHANGELOG.md index 2112244a524..dabc2485b29 100644 --- a/firebase-perf/CHANGELOG.md +++ b/firebase-perf/CHANGELOG.md @@ -1,6 +1,16 @@ # Unreleased +# 21.0.5 +* [changed] Updated `protolite-well-known-types` dependency to v18.0.1 [#6716] +* [fixed] Fixed a bug that allowed invalid payload bytes value in network request metrics [#6721] + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-performance` library. The Kotlin extensions library has no additional +updates. + # 21.0.4 * [fixed] Fixed a performance issue with shared preferences calling `.apply()` every time a value is read from remote config (#6407) diff --git a/firebase-perf/firebase-perf.gradle b/firebase-perf/firebase-perf.gradle index b6028e75b61..c0fd6df6056 100644 --- a/firebase-perf/firebase-perf.gradle +++ b/firebase-perf/firebase-perf.gradle @@ -111,7 +111,7 @@ dependencies { implementation libs.dagger.dagger api 'com.google.firebase:firebase-annotations:16.2.0' api 'com.google.firebase:firebase-installations-interop:17.1.0' - api 'com.google.firebase:protolite-well-known-types:18.0.0' + api project(":protolite-well-known-types") implementation libs.okhttp api("com.google.firebase:firebase-common:21.0.0") api("com.google.firebase:firebase-common-ktx:21.0.0") diff --git a/firebase-perf/gradle.properties b/firebase-perf/gradle.properties index 4b2de75bc47..beb8d9bb532 100644 --- a/firebase-perf/gradle.properties +++ b/firebase-perf/gradle.properties @@ -15,7 +15,7 @@ # # -version=21.0.5 -latestReleasedVersion=21.0.4 +version=21.0.6 +latestReleasedVersion=21.0.5 android.enableUnitTestBinaryResources=true diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/metrics/validator/PerfMetricValidator.java b/firebase-perf/src/main/java/com/google/firebase/perf/metrics/validator/PerfMetricValidator.java index 8791f0e9742..20de8ae642a 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/metrics/validator/PerfMetricValidator.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/metrics/validator/PerfMetricValidator.java @@ -23,10 +23,16 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** An abstract class providing an interface to validate PerfMetric */ public abstract class PerfMetricValidator { + // Regex to validate Attribute key + private static final Pattern ATTRIBUTE_KEY_PATTERN = + Pattern.compile("^(?!(firebase_|google_|ga_))[A-Za-z][A-Za-z_0-9]*"); + /** * Creates a list of PerfMetricValidator classes based on the contents of PerfMetric * @@ -164,7 +170,8 @@ public static void validateAttribute(@NonNull String key, @NonNull String value) Constants.MAX_ATTRIBUTE_VALUE_LENGTH)); } - if (!key.matches("^(?!(firebase_|google_|ga_))[A-Za-z][A-Za-z_0-9]*")) { + Matcher attributeKeyMatcher = ATTRIBUTE_KEY_PATTERN.matcher(key); + if (!attributeKeyMatcher.matches()) { throw new IllegalArgumentException( "Attribute key must start with letter, must only contain alphanumeric characters and" + " underscore and must not start with \"firebase_\", \"google_\" and \"ga_"); diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/network/InstrHttpInputStream.java b/firebase-perf/src/main/java/com/google/firebase/perf/network/InstrHttpInputStream.java index fc660c70426..5ffff6c0d2f 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/network/InstrHttpInputStream.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/network/InstrHttpInputStream.java @@ -30,13 +30,7 @@ public final class InstrHttpInputStream extends InputStream { private long timeToResponseInitiated; private long timeToResponseLastRead = -1; - /** - * Instrumented inputStream object - * - * @param inputStream - * @param builder - * @param timer - */ + /** Instrumented inputStream object */ public InstrHttpInputStream( final InputStream inputStream, final NetworkRequestMetricBuilder builder, Timer timer) { this.timer = timer; @@ -99,12 +93,13 @@ public int read() throws IOException { if (timeToResponseInitiated == -1) { timeToResponseInitiated = tempTime; } - if (bytesRead == -1 && timeToResponseLastRead == -1) { + boolean endOfStream = bytesRead == -1; + if (endOfStream && timeToResponseLastRead == -1) { timeToResponseLastRead = tempTime; networkMetricBuilder.setTimeToResponseCompletedMicros(timeToResponseLastRead); networkMetricBuilder.build(); } else { - this.bytesRead++; + incrementBytesRead(1); networkMetricBuilder.setResponsePayloadBytes(this.bytesRead); } return bytesRead; @@ -124,12 +119,13 @@ public int read(final byte[] buffer, final int byteOffset, final int byteCount) if (timeToResponseInitiated == -1) { timeToResponseInitiated = tempTime; } - if (bytesRead == -1 && timeToResponseLastRead == -1) { + boolean endOfStream = bytesRead == -1; + if (endOfStream && timeToResponseLastRead == -1) { timeToResponseLastRead = tempTime; networkMetricBuilder.setTimeToResponseCompletedMicros(timeToResponseLastRead); networkMetricBuilder.build(); } else { - this.bytesRead += bytesRead; + incrementBytesRead(bytesRead); networkMetricBuilder.setResponsePayloadBytes(this.bytesRead); } return bytesRead; @@ -148,12 +144,13 @@ public int read(final byte[] buffer) throws IOException { if (timeToResponseInitiated == -1) { timeToResponseInitiated = tempTime; } - if (bytesRead == -1 && timeToResponseLastRead == -1) { + boolean endOfStream = bytesRead == -1; + if (endOfStream && timeToResponseLastRead == -1) { timeToResponseLastRead = tempTime; networkMetricBuilder.setTimeToResponseCompletedMicros(timeToResponseLastRead); networkMetricBuilder.build(); } else { - this.bytesRead += bytesRead; + incrementBytesRead(bytesRead); networkMetricBuilder.setResponsePayloadBytes(this.bytesRead); } return bytesRead; @@ -183,11 +180,13 @@ public long skip(final long byteCount) throws IOException { if (timeToResponseInitiated == -1) { timeToResponseInitiated = tempTime; } - if (skipped == -1 && timeToResponseLastRead == -1) { + // InputStream.skip will return 0 for both end of stream and for 0 bytes skipped. + boolean endOfStream = (skipped == 0 && byteCount != 0); + if (endOfStream && timeToResponseLastRead == -1) { timeToResponseLastRead = tempTime; networkMetricBuilder.setTimeToResponseCompletedMicros(timeToResponseLastRead); } else { - bytesRead += skipped; + incrementBytesRead(skipped); networkMetricBuilder.setResponsePayloadBytes(bytesRead); } return skipped; @@ -197,4 +196,12 @@ public long skip(final long byteCount) throws IOException { throw e; } } + + private void incrementBytesRead(long bytesRead) { + if (this.bytesRead == -1) { + this.bytesRead = bytesRead; + } else { + this.bytesRead += bytesRead; + } + } } diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/network/InstrHttpInputStreamTest.java b/firebase-perf/src/test/java/com/google/firebase/perf/network/InstrHttpInputStreamTest.java index 8a7ecb2131b..e1f45c45329 100644 --- a/firebase-perf/src/test/java/com/google/firebase/perf/network/InstrHttpInputStreamTest.java +++ b/firebase-perf/src/test/java/com/google/firebase/perf/network/InstrHttpInputStreamTest.java @@ -30,6 +30,7 @@ import com.google.firebase.perf.v1.NetworkRequestMetric.NetworkClientErrorReason; import java.io.IOException; import java.io.InputStream; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -40,10 +41,14 @@ import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; -/** Unit tests for {@link com.google.firebase.perf.network.InstrHttpInputStream}. */ +/** + * Unit tests for {@link com.google.firebase.perf.network.InstrHttpInputStream}. + * + * @noinspection ResultOfMethodCallIgnored + */ @RunWith(RobolectricTestRunner.class) public class InstrHttpInputStreamTest extends FirebasePerformanceTestBase { - + private AutoCloseable closeable; @Mock InputStream mInputStream; @Mock TransportManager transportManager; @Mock Timer timer; @@ -53,12 +58,17 @@ public class InstrHttpInputStreamTest extends FirebasePerformanceTestBase { @Before public void setUp() { - MockitoAnnotations.initMocks(this); + closeable = MockitoAnnotations.openMocks(this); when(timer.getMicros()).thenReturn((long) 1000); when(timer.getDurationMicros()).thenReturn((long) 2000); networkMetricBuilder = NetworkRequestMetricBuilder.builder(transportManager); } + @After + public void releaseMocks() throws Exception { + closeable.close(); + } + @Test public void testAvailable() throws IOException { int availableVal = 7; @@ -80,7 +90,7 @@ public void testClose() throws IOException { } @Test - public void testMark() throws IOException { + public void testMark() { int markInput = 256; new InstrHttpInputStream(mInputStream, networkMetricBuilder, timer).mark(markInput); @@ -89,7 +99,7 @@ public void testMark() throws IOException { } @Test - public void testMarkSupported() throws IOException { + public void testMarkSupported() { when(mInputStream.markSupported()).thenReturn(true); boolean ret = new InstrHttpInputStream(mInputStream, networkMetricBuilder, timer).markSupported(); @@ -108,6 +118,20 @@ public void testRead() throws IOException { verify(mInputStream).read(); } + @Test + public void testReadBufferOffsetZero() throws IOException { + byte[] b = new byte[0]; + int off = 0; + int len = 0; + when(mInputStream.read(b, off, len)).thenReturn(len); + int ret = new InstrHttpInputStream(mInputStream, networkMetricBuilder, timer).read(b, off, len); + + NetworkRequestMetric metric = networkMetricBuilder.build(); + assertThat(ret).isEqualTo(0); + assertThat(metric.getResponsePayloadBytes()).isEqualTo(0); + verify(mInputStream).read(b, off, len); + } + @Test public void testReadBufferOffsetCount() throws IOException { byte[] buffer = new byte[] {(byte) 0xe0}; diff --git a/firebase-sessions/CHANGELOG.md b/firebase-sessions/CHANGELOG.md index 48987a62df5..70ad76eb6fe 100644 --- a/firebase-sessions/CHANGELOG.md +++ b/firebase-sessions/CHANGELOG.md @@ -1,7 +1,36 @@ # Unreleased + +# 2.1.1 +* [unchanged] Updated to keep SDK versions aligned. + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-sessions` library. The Kotlin extensions library has no additional +updates. + +# 2.1.0 +* [changed] Add warning for known issue b/328687152 +* [changed] Use Dagger for dependency injection +* [changed] Updated datastore dependency to v1.1.3 to + fix [CVE-2024-7254](https://github.com/advisories/GHSA-735f-pc8j-v9w8). + + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-sessions` library. The Kotlin extensions library has no additional +updates. + +# 2.0.9 * [fixed] Make AQS resilient to background init in multi-process apps. + +## Kotlin +The Kotlin extensions library transitively includes the updated +`firebase-sessions` library. The Kotlin extensions library has no additional +updates. + # 2.0.7 * [fixed] Removed extraneous logs that risk leaking internal identifiers. diff --git a/firebase-sessions/benchmark/README.md b/firebase-sessions/benchmark/README.md new file mode 100644 index 00000000000..13a9941c665 --- /dev/null +++ b/firebase-sessions/benchmark/README.md @@ -0,0 +1,5 @@ +# Firebase Sessions Macrobenchmark + +## Setup + +## Run diff --git a/firebase-sessions/benchmark/benchmark.gradle.kts b/firebase-sessions/benchmark/benchmark.gradle.kts new file mode 100644 index 00000000000..115e73c66ed --- /dev/null +++ b/firebase-sessions/benchmark/benchmark.gradle.kts @@ -0,0 +1,63 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("com.android.test") + id("org.jetbrains.kotlin.android") +} + +android { + val compileSdkVersion: Int by rootProject + val targetSdkVersion: Int by rootProject + + namespace = "com.google.firebase.benchmark.sessions" + compileSdk = compileSdkVersion + + defaultConfig { + targetSdk = targetSdkVersion + minSdk = 23 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + create("benchmark") { + isDebuggable = true + signingConfig = signingConfigs["debug"] + matchingFallbacks += "release" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { jvmTarget = "1.8" } + + targetProjectPath = ":firebase-sessions:test-app" + experimentalProperties["android.experimental.self-instrumenting"] = true +} + +dependencies { + implementation(libs.androidx.test.junit) + implementation(libs.androidx.benchmark.macro) +} + +androidComponents { + beforeVariants(selector().all()) { variantBuilder -> + variantBuilder.enable = (variantBuilder.buildType == "benchmark") + } +} diff --git a/firebase-sessions/benchmark/src/main/AndroidManifest.xml b/firebase-sessions/benchmark/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..4f78f2c185d --- /dev/null +++ b/firebase-sessions/benchmark/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + diff --git a/firebase-sessions/benchmark/src/main/kotlin/com/google/firebase/benchmark/sessions/StartupBenchmark.kt b/firebase-sessions/benchmark/src/main/kotlin/com/google/firebase/benchmark/sessions/StartupBenchmark.kt new file mode 100644 index 00000000000..fac6f1a4977 --- /dev/null +++ b/firebase-sessions/benchmark/src/main/kotlin/com/google/firebase/benchmark/sessions/StartupBenchmark.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.benchmark.sessions + +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StartupBenchmark { + @get:Rule val benchmarkRule = MacrobenchmarkRule() + + @Test + fun startup() = + benchmarkRule.measureRepeated( + packageName = "com.google.firebase.testing.sessions", + metrics = listOf(StartupTimingMetric()), + iterations = 5, + startupMode = StartupMode.COLD, + ) { + pressHome() + startActivityAndWait() + } +} diff --git a/firebase-sessions/firebase-sessions.gradle.kts b/firebase-sessions/firebase-sessions.gradle.kts index 15d22381e31..b136a281660 100644 --- a/firebase-sessions/firebase-sessions.gradle.kts +++ b/firebase-sessions/firebase-sessions.gradle.kts @@ -18,6 +18,7 @@ plugins { id("firebase-library") + id("firebase-vendor") id("kotlin-android") id("kotlin-kapt") } @@ -67,12 +68,18 @@ dependencies { exclude(group = "com.google.firebase", module = "firebase-common") exclude(group = "com.google.firebase", module = "firebase-components") } - implementation("androidx.datastore:datastore-preferences:1.0.0") - implementation("com.google.android.datatransport:transport-api:3.2.0") + api("com.google.firebase:firebase-annotations:16.2.0") api("com.google.firebase:firebase-encoders:17.0.0") api("com.google.firebase:firebase-encoders-json:18.0.1") + + implementation("com.google.android.datatransport:transport-api:3.2.0") + implementation(libs.javax.inject) implementation(libs.androidx.annotation) + implementation(libs.androidx.datastore.preferences) + + vendor(libs.dagger.dagger) { exclude(group = "javax.inject", module = "javax.inject") } + compileOnly(libs.errorprone.annotations) runtimeOnly("com.google.firebase:firebase-installations:18.0.0") { @@ -85,6 +92,7 @@ dependencies { } kapt(project(":encoders:firebase-encoders-processor")) + kapt(libs.dagger.compiler) testImplementation(project(":integ-testing")) { exclude(group = "com.google.firebase", module = "firebase-common") diff --git a/firebase-sessions/gradle.properties b/firebase-sessions/gradle.properties index c9bd869d4cd..0a3e66d5a26 100644 --- a/firebase-sessions/gradle.properties +++ b/firebase-sessions/gradle.properties @@ -12,5 +12,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -version=2.0.9 -latestReleasedVersion=2.0.8 +version=2.1.2 +latestReleasedVersion=2.1.1 diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/EventGDTLogger.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/EventGDTLogger.kt index a11b20a7d5c..496cc70d36d 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/EventGDTLogger.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/EventGDTLogger.kt @@ -21,6 +21,8 @@ import com.google.android.datatransport.Encoding import com.google.android.datatransport.Event import com.google.android.datatransport.TransportFactory import com.google.firebase.inject.Provider +import javax.inject.Inject +import javax.inject.Singleton /** * The [EventGDTLoggerInterface] is for testing purposes so that we can mock EventGDTLogger in other @@ -38,19 +40,17 @@ internal fun interface EventGDTLoggerInterface { * * @hide */ -internal class EventGDTLogger(private val transportFactoryProvider: Provider) : +@Singleton +internal class EventGDTLogger +@Inject +constructor(private val transportFactoryProvider: Provider) : EventGDTLoggerInterface { // Logs a [SessionEvent] to FireLog override fun log(sessionEvent: SessionEvent) { transportFactoryProvider .get() - .getTransport( - AQS_LOG_SOURCE, - SessionEvent::class.java, - Encoding.of("json"), - this::encode, - ) + .getTransport(AQS_LOG_SOURCE, SessionEvent::class.java, Encoding.of("json"), this::encode) .send(Event.ofData(sessionEvent)) } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt index 0dec3b98150..18b9961724b 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt @@ -20,18 +20,24 @@ import android.app.Application import android.util.Log import com.google.firebase.Firebase import com.google.firebase.FirebaseApp +import com.google.firebase.annotations.concurrent.Background import com.google.firebase.app import com.google.firebase.sessions.api.FirebaseSessionsDependencies import com.google.firebase.sessions.settings.SessionsSettings +import javax.inject.Inject +import javax.inject.Singleton import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch /** Responsible for initializing AQS */ -internal class FirebaseSessions( +@Singleton +internal class FirebaseSessions +@Inject +constructor( private val firebaseApp: FirebaseApp, private val settings: SessionsSettings, - backgroundDispatcher: CoroutineContext, + @Background backgroundDispatcher: CoroutineContext, lifecycleServiceBinder: SessionLifecycleServiceBinder, ) { diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt new file mode 100644 index 00000000000..5680c9cc0ec --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions + +import android.content.Context +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.preferencesDataStoreFile +import com.google.android.datatransport.TransportFactory +import com.google.firebase.FirebaseApp +import com.google.firebase.annotations.concurrent.Background +import com.google.firebase.annotations.concurrent.Blocking +import com.google.firebase.inject.Provider +import com.google.firebase.installations.FirebaseInstallationsApi +import com.google.firebase.sessions.ProcessDetailsProvider.getProcessName +import com.google.firebase.sessions.settings.CrashlyticsSettingsFetcher +import com.google.firebase.sessions.settings.LocalOverrideSettings +import com.google.firebase.sessions.settings.RemoteSettings +import com.google.firebase.sessions.settings.RemoteSettingsFetcher +import com.google.firebase.sessions.settings.SessionsSettings +import com.google.firebase.sessions.settings.SettingsProvider +import dagger.Binds +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import javax.inject.Qualifier +import javax.inject.Singleton +import kotlin.coroutines.CoroutineContext + +@Qualifier internal annotation class SessionConfigsDataStore + +@Qualifier internal annotation class SessionDetailsDataStore + +@Qualifier internal annotation class LocalOverrideSettingsProvider + +@Qualifier internal annotation class RemoteSettingsProvider + +/** + * Dagger component to provide [FirebaseSessions] and its dependencies. + * + * This gets configured and built in [FirebaseSessionsRegistrar.getComponents]. + */ +@Singleton +@Component(modules = [FirebaseSessionsComponent.MainModule::class]) +internal interface FirebaseSessionsComponent { + val firebaseSessions: FirebaseSessions + + val sessionDatastore: SessionDatastore + val sessionFirelogPublisher: SessionFirelogPublisher + val sessionGenerator: SessionGenerator + val sessionsSettings: SessionsSettings + + @Component.Builder + interface Builder { + @BindsInstance fun appContext(appContext: Context): Builder + + @BindsInstance + fun backgroundDispatcher(@Background backgroundDispatcher: CoroutineContext): Builder + + @BindsInstance fun blockingDispatcher(@Blocking blockingDispatcher: CoroutineContext): Builder + + @BindsInstance fun firebaseApp(firebaseApp: FirebaseApp): Builder + + @BindsInstance + fun firebaseInstallationsApi(firebaseInstallationsApi: FirebaseInstallationsApi): Builder + + @BindsInstance + fun transportFactoryProvider(transportFactoryProvider: Provider): Builder + + fun build(): FirebaseSessionsComponent + } + + @Module + interface MainModule { + @Binds @Singleton fun eventGDTLoggerInterface(impl: EventGDTLogger): EventGDTLoggerInterface + + @Binds @Singleton fun sessionDatastore(impl: SessionDatastoreImpl): SessionDatastore + + @Binds + @Singleton + fun sessionFirelogPublisher(impl: SessionFirelogPublisherImpl): SessionFirelogPublisher + + @Binds + @Singleton + fun sessionLifecycleServiceBinder( + impl: SessionLifecycleServiceBinderImpl + ): SessionLifecycleServiceBinder + + @Binds + @Singleton + fun crashlyticsSettingsFetcher(impl: RemoteSettingsFetcher): CrashlyticsSettingsFetcher + + @Binds + @Singleton + @LocalOverrideSettingsProvider + fun localOverrideSettings(impl: LocalOverrideSettings): SettingsProvider + + @Binds + @Singleton + @RemoteSettingsProvider + fun remoteSettings(impl: RemoteSettings): SettingsProvider + + companion object { + private const val TAG = "FirebaseSessions" + + @Provides @Singleton fun timeProvider(): TimeProvider = TimeProviderImpl + + @Provides @Singleton fun uuidGenerator(): UuidGenerator = UuidGeneratorImpl + + @Provides + @Singleton + fun applicationInfo(firebaseApp: FirebaseApp): ApplicationInfo = + SessionEvents.getApplicationInfo(firebaseApp) + + @Provides + @Singleton + @SessionConfigsDataStore + fun sessionConfigsDataStore(appContext: Context): DataStore = + PreferenceDataStoreFactory.create( + corruptionHandler = + ReplaceFileCorruptionHandler { ex -> + Log.w(TAG, "CorruptionException in settings DataStore in ${getProcessName()}.", ex) + emptyPreferences() + } + ) { + appContext.preferencesDataStoreFile(SessionDataStoreConfigs.SETTINGS_CONFIG_NAME) + } + + @Provides + @Singleton + @SessionDetailsDataStore + fun sessionDetailsDataStore(appContext: Context): DataStore = + PreferenceDataStoreFactory.create( + corruptionHandler = + ReplaceFileCorruptionHandler { ex -> + Log.w(TAG, "CorruptionException in sessions DataStore in ${getProcessName()}.", ex) + emptyPreferences() + } + ) { + appContext.preferencesDataStoreFile(SessionDataStoreConfigs.SESSIONS_CONFIG_NAME) + } + } + } +} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsRegistrar.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsRegistrar.kt index caad2de6ff8..5cb8de7a182 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsRegistrar.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsRegistrar.kt @@ -16,7 +16,10 @@ package com.google.firebase.sessions +import android.content.Context +import android.util.Log import androidx.annotation.Keep +import androidx.datastore.preferences.preferencesDataStore import com.google.android.datatransport.TransportFactory import com.google.firebase.FirebaseApp import com.google.firebase.annotations.concurrent.Background @@ -28,7 +31,6 @@ import com.google.firebase.components.Qualified.qualified import com.google.firebase.components.Qualified.unqualified import com.google.firebase.installations.FirebaseInstallationsApi import com.google.firebase.platforminfo.LibraryVersionComponent -import com.google.firebase.sessions.settings.SessionsSettings import kotlinx.coroutines.CoroutineDispatcher /** @@ -42,87 +44,66 @@ internal class FirebaseSessionsRegistrar : ComponentRegistrar { listOf( Component.builder(FirebaseSessions::class.java) .name(LIBRARY_NAME) - .add(Dependency.required(firebaseApp)) - .add(Dependency.required(sessionsSettings)) - .add(Dependency.required(backgroundDispatcher)) - .add(Dependency.required(sessionLifecycleServiceBinder)) - .factory { container -> - FirebaseSessions( - container[firebaseApp], - container[sessionsSettings], - container[backgroundDispatcher], - container[sessionLifecycleServiceBinder], - ) - } + .add(Dependency.required(firebaseSessionsComponent)) + .factory { container -> container[firebaseSessionsComponent].firebaseSessions } .eagerInDefaultApp() .build(), - Component.builder(SessionGenerator::class.java) - .name("session-generator") - .factory { SessionGenerator(timeProvider = WallClock) } - .build(), - Component.builder(SessionFirelogPublisher::class.java) - .name("session-publisher") - .add(Dependency.required(firebaseApp)) - .add(Dependency.required(firebaseInstallationsApi)) - .add(Dependency.required(sessionsSettings)) - .add(Dependency.requiredProvider(transportFactory)) + Component.builder(FirebaseSessionsComponent::class.java) + .name("fire-sessions-component") + .add(Dependency.required(appContext)) .add(Dependency.required(backgroundDispatcher)) - .factory { container -> - SessionFirelogPublisherImpl( - container[firebaseApp], - container[firebaseInstallationsApi], - container[sessionsSettings], - EventGDTLogger(container.getProvider(transportFactory)), - container[backgroundDispatcher], - ) - } - .build(), - Component.builder(SessionsSettings::class.java) - .name("sessions-settings") - .add(Dependency.required(firebaseApp)) .add(Dependency.required(blockingDispatcher)) - .add(Dependency.required(backgroundDispatcher)) - .add(Dependency.required(firebaseInstallationsApi)) - .factory { container -> - SessionsSettings( - container[firebaseApp], - container[blockingDispatcher], - container[backgroundDispatcher], - container[firebaseInstallationsApi], - ) - } - .build(), - Component.builder(SessionDatastore::class.java) - .name("sessions-datastore") .add(Dependency.required(firebaseApp)) - .add(Dependency.required(backgroundDispatcher)) + .add(Dependency.required(firebaseInstallationsApi)) + .add(Dependency.requiredProvider(transportFactory)) .factory { container -> - SessionDatastoreImpl( - container[firebaseApp].applicationContext, - container[backgroundDispatcher], - ) + DaggerFirebaseSessionsComponent.builder() + .appContext(container[appContext]) + .backgroundDispatcher(container[backgroundDispatcher]) + .blockingDispatcher(container[blockingDispatcher]) + .firebaseApp(container[firebaseApp]) + .firebaseInstallationsApi(container[firebaseInstallationsApi]) + .transportFactoryProvider(container.getProvider(transportFactory)) + .build() } .build(), - Component.builder(SessionLifecycleServiceBinder::class.java) - .name("sessions-service-binder") - .add(Dependency.required(firebaseApp)) - .factory { container -> SessionLifecycleServiceBinderImpl(container[firebaseApp]) } - .build(), LibraryVersionComponent.create(LIBRARY_NAME, BuildConfig.VERSION_NAME), ) private companion object { - private const val LIBRARY_NAME = "fire-sessions" + const val TAG = "FirebaseSessions" + const val LIBRARY_NAME = "fire-sessions" + + val appContext = unqualified(Context::class.java) + val firebaseApp = unqualified(FirebaseApp::class.java) + val firebaseInstallationsApi = unqualified(FirebaseInstallationsApi::class.java) + val backgroundDispatcher = qualified(Background::class.java, CoroutineDispatcher::class.java) + val blockingDispatcher = qualified(Blocking::class.java, CoroutineDispatcher::class.java) + val transportFactory = unqualified(TransportFactory::class.java) + val firebaseSessionsComponent = unqualified(FirebaseSessionsComponent::class.java) + + init { + try { + ::preferencesDataStore.javaClass + } catch (ex: NoClassDefFoundError) { + Log.w( + TAG, + """ + Your app is experiencing a known issue in the Android Gradle plugin, see https://issuetracker.google.com/328687152 + + It affects Java-only apps using AGP version 8.3.2 and under. To avoid the issue, either: + + 1. Upgrade Android Gradle plugin to 8.4.0+ + Follow the guide at https://developer.android.com/build/agp-upgrade-assistant + + 2. Or, add the Kotlin plugin to your app + Follow the guide at https://developer.android.com/kotlin/add-kotlin - private val firebaseApp = unqualified(FirebaseApp::class.java) - private val firebaseInstallationsApi = unqualified(FirebaseInstallationsApi::class.java) - private val backgroundDispatcher = - qualified(Background::class.java, CoroutineDispatcher::class.java) - private val blockingDispatcher = - qualified(Blocking::class.java, CoroutineDispatcher::class.java) - private val transportFactory = unqualified(TransportFactory::class.java) - private val sessionsSettings = unqualified(SessionsSettings::class.java) - private val sessionLifecycleServiceBinder = - unqualified(SessionLifecycleServiceBinder::class.java) + 3. Or, do the technical workaround described in https://issuetracker.google.com/issues/328687152#comment3 + """ + .trimIndent(), + ) + } + } } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDetailsProvider.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDetailsProvider.kt index 72e80469880..65d1dfbbc60 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDetailsProvider.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDetailsProvider.kt @@ -74,7 +74,7 @@ internal object ProcessDetailsProvider { /** Gets the app's current process name. If it could not be found, returns an empty string. */ internal fun getProcessName(): String { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU) { return Process.myProcessName() } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt index 736761617fd..2c4f243f942 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt @@ -16,20 +16,19 @@ package com.google.firebase.sessions -import android.content.Context import android.util.Log import androidx.datastore.core.DataStore -import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore import com.google.firebase.Firebase +import com.google.firebase.annotations.concurrent.Background import com.google.firebase.app -import com.google.firebase.sessions.ProcessDetailsProvider.getProcessName import java.io.IOException import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject +import javax.inject.Singleton import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -53,13 +52,16 @@ internal interface SessionDatastore { companion object { val instance: SessionDatastore - get() = Firebase.app[SessionDatastore::class.java] + get() = Firebase.app[FirebaseSessionsComponent::class.java].sessionDatastore } } -internal class SessionDatastoreImpl( - private val context: Context, - private val backgroundDispatcher: CoroutineContext, +@Singleton +internal class SessionDatastoreImpl +@Inject +constructor( + @Background private val backgroundDispatcher: CoroutineContext, + @SessionDetailsDataStore private val dataStore: DataStore, ) : SessionDatastore { /** Most recent session from datastore is updated asynchronously whenever it changes */ @@ -70,7 +72,7 @@ internal class SessionDatastoreImpl( } private val firebaseSessionDataFlow: Flow = - context.dataStore.data + dataStore.data .catch { exception -> Log.e(TAG, "Error reading stored session data.", exception) emit(emptyPreferences()) @@ -86,14 +88,11 @@ internal class SessionDatastoreImpl( override fun updateSessionId(sessionId: String) { CoroutineScope(backgroundDispatcher).launch { try { - context.dataStore.edit { preferences -> + dataStore.edit { preferences -> preferences[FirebaseSessionDataKeys.SESSION_ID] = sessionId } } catch (e: IOException) { - Log.w( - TAG, - "Failed to update session Id: $e", - ) + Log.w(TAG, "Failed to update session Id: $e") } } } @@ -101,21 +100,9 @@ internal class SessionDatastoreImpl( override fun getCurrentSessionId() = currentSessionFromDatastore.get()?.sessionId private fun mapSessionsData(preferences: Preferences): FirebaseSessionsData = - FirebaseSessionsData( - preferences[FirebaseSessionDataKeys.SESSION_ID], - ) + FirebaseSessionsData(preferences[FirebaseSessionDataKeys.SESSION_ID]) private companion object { private const val TAG = "FirebaseSessionsRepo" - - private val Context.dataStore: DataStore by - preferencesDataStore( - name = SessionDataStoreConfigs.SESSIONS_CONFIG_NAME, - corruptionHandler = - ReplaceFileCorruptionHandler { ex -> - Log.w(TAG, "CorruptionException in sessions DataStore in ${getProcessName()}.", ex) - emptyPreferences() - }, - ) } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionFirelogPublisher.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionFirelogPublisher.kt index d63d49e3fe5..6e4b6153f8d 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionFirelogPublisher.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionFirelogPublisher.kt @@ -19,10 +19,13 @@ package com.google.firebase.sessions import android.util.Log import com.google.firebase.Firebase import com.google.firebase.FirebaseApp +import com.google.firebase.annotations.concurrent.Background import com.google.firebase.app import com.google.firebase.installations.FirebaseInstallationsApi import com.google.firebase.sessions.api.FirebaseSessionsDependencies import com.google.firebase.sessions.settings.SessionsSettings +import javax.inject.Inject +import javax.inject.Singleton import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -35,7 +38,7 @@ internal fun interface SessionFirelogPublisher { companion object { val instance: SessionFirelogPublisher - get() = Firebase.app[SessionFirelogPublisher::class.java] + get() = Firebase.app[FirebaseSessionsComponent::class.java].sessionFirelogPublisher } } @@ -44,12 +47,15 @@ internal fun interface SessionFirelogPublisher { * * @hide */ -internal class SessionFirelogPublisherImpl( +@Singleton +internal class SessionFirelogPublisherImpl +@Inject +constructor( private val firebaseApp: FirebaseApp, private val firebaseInstallations: FirebaseInstallationsApi, private val sessionSettings: SessionsSettings, private val eventGDTLogger: EventGDTLoggerInterface, - private val backgroundDispatcher: CoroutineContext, + @Background private val backgroundDispatcher: CoroutineContext, ) : SessionFirelogPublisher { /** diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionGenerator.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionGenerator.kt index 3b4c3124c98..4c4775e8b24 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionGenerator.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionGenerator.kt @@ -19,7 +19,8 @@ package com.google.firebase.sessions import com.google.errorprone.annotations.CanIgnoreReturnValue import com.google.firebase.Firebase import com.google.firebase.app -import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton /** * [SessionDetails] is a data class responsible for storing information about the current Session. @@ -35,10 +36,10 @@ internal data class SessionDetails( * The [SessionGenerator] is responsible for generating the Session ID, and keeping the * [SessionDetails] up to date with the latest values. */ -internal class SessionGenerator( - private val timeProvider: TimeProvider, - private val uuidGenerator: () -> UUID = UUID::randomUUID -) { +@Singleton +internal class SessionGenerator +@Inject +constructor(private val timeProvider: TimeProvider, private val uuidGenerator: UuidGenerator) { private val firstSessionId = generateSessionId() private var sessionIndex = -1 @@ -59,15 +60,15 @@ internal class SessionGenerator( sessionId = if (sessionIndex == 0) firstSessionId else generateSessionId(), firstSessionId, sessionIndex, - sessionStartTimestampUs = timeProvider.currentTimeUs() + sessionStartTimestampUs = timeProvider.currentTimeUs(), ) return currentSession } - private fun generateSessionId() = uuidGenerator().toString().replace("-", "").lowercase() + private fun generateSessionId() = uuidGenerator.next().toString().replace("-", "").lowercase() internal companion object { val instance: SessionGenerator - get() = Firebase.app[SessionGenerator::class.java] + get() = Firebase.app[FirebaseSessionsComponent::class.java].sessionGenerator } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleService.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleService.kt index bde6d138fbe..85930dc5455 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleService.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleService.kt @@ -128,7 +128,6 @@ internal class SessionLifecycleService : Service() { /** Generates a new session id and sends it everywhere it's needed */ private fun newSession() { try { - // TODO(mrober): Consider migrating to Dagger, or update [FirebaseSessionsRegistrar]. SessionGenerator.instance.generateNewSession() Log.d(TAG, "Generated new session.") broadcastSession() @@ -194,6 +193,7 @@ internal class SessionLifecycleService : Service() { handlerThread.start() messageHandler = MessageHandler(handlerThread.looper) messenger = Messenger(messageHandler) + Log.d(TAG, "Service created on process ${android.os.Process.myPid()}") } /** Called when a new [SessionLifecycleClient] binds to this service. */ diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinder.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinder.kt index 97a7d6b73ae..094a76ee51c 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinder.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinder.kt @@ -21,7 +21,8 @@ import android.content.Intent import android.content.ServiceConnection import android.os.Messenger import android.util.Log -import com.google.firebase.FirebaseApp +import javax.inject.Inject +import javax.inject.Singleton /** Interface for binding with the [SessionLifecycleService]. */ internal fun interface SessionLifecycleServiceBinder { @@ -32,11 +33,12 @@ internal fun interface SessionLifecycleServiceBinder { fun bindToService(callback: Messenger, serviceConnection: ServiceConnection) } -internal class SessionLifecycleServiceBinderImpl(private val firebaseApp: FirebaseApp) : - SessionLifecycleServiceBinder { +@Singleton +internal class SessionLifecycleServiceBinderImpl +@Inject +constructor(private val appContext: Context) : SessionLifecycleServiceBinder { override fun bindToService(callback: Messenger, serviceConnection: ServiceConnection) { - val appContext: Context = firebaseApp.applicationContext.applicationContext Intent(appContext, SessionLifecycleService::class.java).also { intent -> Log.d(TAG, "Binding service to application.") // This is necessary for the onBind() to be called by each process diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt index 706285de337..b66b09af19f 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt @@ -23,11 +23,12 @@ import kotlin.time.Duration.Companion.milliseconds /** Time provider interface, for testing purposes. */ internal interface TimeProvider { fun elapsedRealtime(): Duration + fun currentTimeUs(): Long } -/** "Wall clock" time provider. */ -internal object WallClock : TimeProvider { +/** "Wall clock" time provider implementation. */ +internal object TimeProviderImpl : TimeProvider { /** * Gets the [Duration] elapsed in "wall clock" time since device boot. * diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/UuidGenerator.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/UuidGenerator.kt new file mode 100644 index 00000000000..8c5b153fef2 --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/UuidGenerator.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions + +import java.util.UUID + +/** UUID generator interface. */ +internal fun interface UuidGenerator { + fun next(): UUID +} + +/** Generate random UUIDs using [UUID.randomUUID]. */ +internal object UuidGeneratorImpl : UuidGenerator { + override fun next(): UUID = UUID.randomUUID() +} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/LocalOverrideSettings.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/LocalOverrideSettings.kt index 37e7acc949b..f13d0ffde2e 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/LocalOverrideSettings.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/LocalOverrideSettings.kt @@ -19,20 +19,19 @@ package com.google.firebase.sessions.settings import android.content.Context import android.content.pm.PackageManager import android.os.Bundle +import javax.inject.Inject +import javax.inject.Singleton import kotlin.time.Duration import kotlin.time.DurationUnit import kotlin.time.toDuration -internal class LocalOverrideSettings(context: Context) : SettingsProvider { - @Suppress("DEPRECATION") // TODO(mrober): Use ApplicationInfoFlags when target sdk set to 33 +@Singleton +internal class LocalOverrideSettings @Inject constructor(appContext: Context) : SettingsProvider { private val metadata = - context.packageManager - .getApplicationInfo( - context.packageName, - PackageManager.GET_META_DATA, - ) + appContext.packageManager + .getApplicationInfo(appContext.packageName, PackageManager.GET_META_DATA) .metaData - ?: Bundle.EMPTY // Default to an empty bundle, meaning no cached values. + ?: Bundle.EMPTY // Default to an empty bundle override val sessionEnabled: Boolean? get() = diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt index 1e6015a5c0d..67a48bc7924 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt @@ -19,11 +19,13 @@ package com.google.firebase.sessions.settings import android.os.Build import android.util.Log import androidx.annotation.VisibleForTesting -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences +import com.google.firebase.annotations.concurrent.Background import com.google.firebase.installations.FirebaseInstallationsApi import com.google.firebase.sessions.ApplicationInfo import com.google.firebase.sessions.InstallationId +import dagger.Lazy +import javax.inject.Inject +import javax.inject.Singleton import kotlin.coroutines.CoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -34,14 +36,19 @@ import kotlinx.coroutines.sync.withLock import org.json.JSONException import org.json.JSONObject -internal class RemoteSettings( - private val backgroundDispatcher: CoroutineContext, +@Singleton +internal class RemoteSettings +@Inject +constructor( + @Background private val backgroundDispatcher: CoroutineContext, private val firebaseInstallationsApi: FirebaseInstallationsApi, private val appInfo: ApplicationInfo, private val configsFetcher: CrashlyticsSettingsFetcher, - dataStore: DataStore, + private val lazySettingsCache: Lazy, ) : SettingsProvider { - private val settingsCache by lazy { SettingsCache(dataStore) } + private val settingsCache: SettingsCache + get() = lazySettingsCache.get() + private val fetchInProgress = Mutex() override val sessionEnabled: Boolean? diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettingsFetcher.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettingsFetcher.kt index a0896c24e7e..92d530f2fa1 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettingsFetcher.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettingsFetcher.kt @@ -17,10 +17,13 @@ package com.google.firebase.sessions.settings import android.net.Uri +import com.google.firebase.annotations.concurrent.Background import com.google.firebase.sessions.ApplicationInfo import java.io.BufferedReader import java.io.InputStreamReader import java.net.URL +import javax.inject.Inject +import javax.inject.Singleton import javax.net.ssl.HttpsURLConnection import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.withContext @@ -30,20 +33,22 @@ internal fun interface CrashlyticsSettingsFetcher { suspend fun doConfigFetch( headerOptions: Map, onSuccess: suspend (JSONObject) -> Unit, - onFailure: suspend (msg: String) -> Unit + onFailure: suspend (msg: String) -> Unit, ) } -internal class RemoteSettingsFetcher( +@Singleton +internal class RemoteSettingsFetcher +@Inject +constructor( private val appInfo: ApplicationInfo, - private val blockingDispatcher: CoroutineContext, - private val baseUrl: String = FIREBASE_SESSIONS_BASE_URL_STRING, + @Background private val blockingDispatcher: CoroutineContext, ) : CrashlyticsSettingsFetcher { @Suppress("BlockingMethodInNonBlockingContext") // blockingDispatcher is safe for blocking calls. override suspend fun doConfigFetch( headerOptions: Map, onSuccess: suspend (JSONObject) -> Unit, - onFailure: suspend (String) -> Unit + onFailure: suspend (String) -> Unit, ) = withContext(blockingDispatcher) { try { @@ -78,7 +83,7 @@ internal class RemoteSettingsFetcher( val uri = Uri.Builder() .scheme("https") - .authority(baseUrl) + .authority(FIREBASE_SESSIONS_BASE_URL_STRING) .appendPath("spi") .appendPath("v2") .appendPath("platforms") diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionsSettings.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionsSettings.kt index fd2ee5dbddd..d319bebb7a2 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionsSettings.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionsSettings.kt @@ -16,64 +16,24 @@ package com.google.firebase.sessions.settings -import android.content.Context -import android.util.Log -import androidx.datastore.core.DataStore -import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.preferencesDataStore import com.google.firebase.Firebase -import com.google.firebase.FirebaseApp import com.google.firebase.app -import com.google.firebase.installations.FirebaseInstallationsApi -import com.google.firebase.sessions.ApplicationInfo -import com.google.firebase.sessions.ProcessDetailsProvider.getProcessName -import com.google.firebase.sessions.SessionDataStoreConfigs -import com.google.firebase.sessions.SessionEvents -import kotlin.coroutines.CoroutineContext +import com.google.firebase.sessions.FirebaseSessionsComponent +import com.google.firebase.sessions.LocalOverrideSettingsProvider +import com.google.firebase.sessions.RemoteSettingsProvider +import javax.inject.Inject +import javax.inject.Singleton import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes /** [SessionsSettings] manages all the configs that are relevant to the sessions library. */ -internal class SessionsSettings( - private val localOverrideSettings: SettingsProvider, - private val remoteSettings: SettingsProvider, +@Singleton +internal class SessionsSettings +@Inject +constructor( + @LocalOverrideSettingsProvider private val localOverrideSettings: SettingsProvider, + @RemoteSettingsProvider private val remoteSettings: SettingsProvider, ) { - private constructor( - context: Context, - blockingDispatcher: CoroutineContext, - backgroundDispatcher: CoroutineContext, - firebaseInstallationsApi: FirebaseInstallationsApi, - appInfo: ApplicationInfo, - ) : this( - localOverrideSettings = LocalOverrideSettings(context), - remoteSettings = - RemoteSettings( - backgroundDispatcher, - firebaseInstallationsApi, - appInfo, - configsFetcher = - RemoteSettingsFetcher( - appInfo, - blockingDispatcher, - ), - dataStore = context.dataStore, - ), - ) - - constructor( - firebaseApp: FirebaseApp, - blockingDispatcher: CoroutineContext, - backgroundDispatcher: CoroutineContext, - firebaseInstallationsApi: FirebaseInstallationsApi, - ) : this( - firebaseApp.applicationContext, - blockingDispatcher, - backgroundDispatcher, - firebaseInstallationsApi, - SessionEvents.getApplicationInfo(firebaseApp), - ) // Order of preference for all the configs below: // 1. Honor local overrides @@ -140,19 +100,7 @@ internal class SessionsSettings( } internal companion object { - private const val TAG = "SessionsSettings" - val instance: SessionsSettings - get() = Firebase.app[SessionsSettings::class.java] - - private val Context.dataStore: DataStore by - preferencesDataStore( - name = SessionDataStoreConfigs.SETTINGS_CONFIG_NAME, - corruptionHandler = - ReplaceFileCorruptionHandler { ex -> - Log.w(TAG, "CorruptionException in settings DataStore in ${getProcessName()}.", ex) - emptyPreferences() - }, - ) + get() = Firebase.app[FirebaseSessionsComponent::class.java].sessionsSettings } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SettingsCache.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SettingsCache.kt index 33b6a4fe7c8..2e60e51650a 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SettingsCache.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SettingsCache.kt @@ -25,7 +25,10 @@ import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey +import com.google.firebase.sessions.SessionConfigsDataStore import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -37,7 +40,10 @@ internal data class SessionConfigs( val cacheUpdatedTime: Long?, ) -internal class SettingsCache(private val dataStore: DataStore) { +@Singleton +internal class SettingsCache +@Inject +constructor(@SessionConfigsDataStore private val dataStore: DataStore) { private lateinit var sessionConfigs: SessionConfigs init { @@ -54,7 +60,7 @@ internal class SettingsCache(private val dataStore: DataStore) { sessionSamplingRate = preferences[SAMPLING_RATE], sessionRestartTimeout = preferences[RESTART_TIMEOUT_SECONDS], cacheDuration = preferences[CACHE_DURATION_SECONDS], - cacheUpdatedTime = preferences[CACHE_UPDATED_TIME] + cacheUpdatedTime = preferences[CACHE_UPDATED_TIME], ) } @@ -105,10 +111,7 @@ internal class SettingsCache(private val dataStore: DataStore) { updateSessionConfigs(preferences) } } catch (e: IOException) { - Log.w( - TAG, - "Failed to remove config values: $e", - ) + Log.w(TAG, "Failed to remove config values: $e") } } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionGeneratorTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionGeneratorTest.kt index 7f29fb66ae7..7126bae4dbf 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionGeneratorTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionGeneratorTest.kt @@ -18,8 +18,8 @@ package com.google.firebase.sessions import com.google.common.truth.Truth.assertThat import com.google.firebase.sessions.testing.FakeTimeProvider +import com.google.firebase.sessions.testing.FakeUuidGenerator import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_TIMESTAMP_US -import java.util.UUID import org.junit.Test class SessionGeneratorTest { @@ -41,9 +41,7 @@ class SessionGeneratorTest { @Test(expected = UninitializedPropertyAccessException::class) fun currentSession_beforeGenerate_throwsUninitialized() { val sessionGenerator = - SessionGenerator( - timeProvider = FakeTimeProvider(), - ) + SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = UuidGeneratorImpl) sessionGenerator.currentSession } @@ -51,9 +49,7 @@ class SessionGeneratorTest { @Test fun hasGenerateSession_beforeGenerate_returnsFalse() { val sessionGenerator = - SessionGenerator( - timeProvider = FakeTimeProvider(), - ) + SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = UuidGeneratorImpl) assertThat(sessionGenerator.hasGenerateSession).isFalse() } @@ -61,9 +57,7 @@ class SessionGeneratorTest { @Test fun hasGenerateSession_afterGenerate_returnsTrue() { val sessionGenerator = - SessionGenerator( - timeProvider = FakeTimeProvider(), - ) + SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = UuidGeneratorImpl) sessionGenerator.generateNewSession() @@ -73,9 +67,7 @@ class SessionGeneratorTest { @Test fun generateNewSession_generatesValidSessionIds() { val sessionGenerator = - SessionGenerator( - timeProvider = FakeTimeProvider(), - ) + SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = UuidGeneratorImpl) sessionGenerator.generateNewSession() @@ -91,10 +83,7 @@ class SessionGeneratorTest { @Test fun generateNewSession_generatesValidSessionDetails() { val sessionGenerator = - SessionGenerator( - timeProvider = FakeTimeProvider(), - uuidGenerator = UUIDs()::next, - ) + SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = FakeUuidGenerator()) sessionGenerator.generateNewSession() @@ -117,10 +106,7 @@ class SessionGeneratorTest { @Test fun generateNewSession_incrementsSessionIndex_keepsFirstSessionId() { val sessionGenerator = - SessionGenerator( - timeProvider = FakeTimeProvider(), - uuidGenerator = UUIDs()::next, - ) + SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = FakeUuidGenerator()) val firstSessionDetails = sessionGenerator.generateNewSession() @@ -170,22 +156,9 @@ class SessionGeneratorTest { ) } - private class UUIDs(val names: List = listOf(UUID_1, UUID_2, UUID_3)) { - var index = -1 - - fun next(): UUID { - index = (index + 1).coerceAtMost(names.size - 1) - return UUID.fromString(names[index]) - } - } - - @Suppress("SpellCheckingInspection") // UUIDs are not words. companion object { - const val UUID_1 = "11111111-1111-1111-1111-111111111111" const val SESSION_ID_1 = "11111111111111111111111111111111" - const val UUID_2 = "22222222-2222-2222-2222-222222222222" const val SESSION_ID_2 = "22222222222222222222222222222222" - const val UUID_3 = "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC" const val SESSION_ID_3 = "cccccccccccccccccccccccccccccccc" } } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleClientTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleClientTest.kt index b038e68081c..12a017a7462 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleClientTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleClientTest.kt @@ -31,6 +31,7 @@ import com.google.firebase.sessions.api.SessionSubscriber.SessionDetails import com.google.firebase.sessions.testing.FakeFirebaseApp import com.google.firebase.sessions.testing.FakeSessionLifecycleServiceBinder import com.google.firebase.sessions.testing.FakeSessionSubscriber +import com.google.firebase.sessions.testing.FirebaseSessionsFakeComponent import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -47,21 +48,21 @@ import org.robolectric.Shadows.shadowOf @RunWith(RobolectricTestRunner::class) internal class SessionLifecycleClientTest { private lateinit var fakeService: FakeSessionLifecycleServiceBinder - private lateinit var lifecycleServiceBinder: FakeSessionLifecycleServiceBinder + private lateinit var lifecycleServiceBinder: SessionLifecycleServiceBinder @Before fun setUp() { - val firebaseApp = - Firebase.initialize( - ApplicationProvider.getApplicationContext(), - FirebaseOptions.Builder() - .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) - .setApiKey(FakeFirebaseApp.MOCK_API_KEY) - .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) - .build(), - ) - fakeService = firebaseApp[FakeSessionLifecycleServiceBinder::class.java] - lifecycleServiceBinder = firebaseApp[FakeSessionLifecycleServiceBinder::class.java] + Firebase.initialize( + ApplicationProvider.getApplicationContext(), + FirebaseOptions.Builder() + .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) + .setApiKey(FakeFirebaseApp.MOCK_API_KEY) + .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) + .build(), + ) + + fakeService = FirebaseSessionsFakeComponent.instance.fakeSessionLifecycleServiceBinder + lifecycleServiceBinder = FirebaseSessionsFakeComponent.instance.sessionLifecycleServiceBinder } @After diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleServiceTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleServiceTest.kt index 682a9ddfbbb..ccd933f1213 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleServiceTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleServiceTest.kt @@ -16,7 +16,6 @@ package com.google.firebase.sessions -import android.content.Context import android.content.Intent import android.os.Handler import android.os.Looper @@ -30,10 +29,8 @@ import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions import com.google.firebase.initialize import com.google.firebase.sessions.testing.FakeFirebaseApp -import com.google.firebase.sessions.testing.FakeFirelogPublisher -import com.google.firebase.sessions.testing.FakeSessionDatastore +import com.google.firebase.sessions.testing.FirebaseSessionsFakeComponent import java.time.Duration -import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.After import org.junit.Before import org.junit.Test @@ -46,14 +43,11 @@ import org.robolectric.annotation.LooperMode import org.robolectric.annotation.LooperMode.Mode.PAUSED import org.robolectric.shadows.ShadowSystemClock -@OptIn(ExperimentalCoroutinesApi::class) @MediumTest @LooperMode(PAUSED) @RunWith(RobolectricTestRunner::class) internal class SessionLifecycleServiceTest { - - lateinit var service: ServiceController - lateinit var firebaseApp: FirebaseApp + private lateinit var service: ServiceController data class CallbackMessage(val code: Int, val sessionId: String?) @@ -68,16 +62,14 @@ internal class SessionLifecycleServiceTest { @Before fun setUp() { - val context = ApplicationProvider.getApplicationContext() - firebaseApp = - Firebase.initialize( - ApplicationProvider.getApplicationContext(), - FirebaseOptions.Builder() - .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) - .setApiKey(FakeFirebaseApp.MOCK_API_KEY) - .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) - .build() - ) + Firebase.initialize( + ApplicationProvider.getApplicationContext(), + FirebaseOptions.Builder() + .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) + .setApiKey(FakeFirebaseApp.MOCK_API_KEY) + .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) + .build(), + ) service = createService() } @@ -99,7 +91,7 @@ internal class SessionLifecycleServiceTest { @Test fun binding_callbackOnInitialBindWhenSessionIdSet() { val client = TestCallbackHandler() - firebaseApp.get(FakeSessionDatastore::class.java).updateSessionId("123") + FirebaseSessionsFakeComponent.instance.fakeSessionDatastore.updateSessionId("123") bindToService(client) @@ -222,11 +214,9 @@ internal class SessionLifecycleServiceTest { } private fun createServiceLaunchIntent(client: TestCallbackHandler) = - Intent( - ApplicationProvider.getApplicationContext(), - SessionLifecycleService::class.java - ) - .apply { putExtra(SessionLifecycleService.CLIENT_CALLBACK_MESSENGER, Messenger(client)) } + Intent(ApplicationProvider.getApplicationContext(), SessionLifecycleService::class.java).apply { + putExtra(SessionLifecycleService.CLIENT_CALLBACK_MESSENGER, Messenger(client)) + } private fun createService() = Robolectric.buildService(SessionLifecycleService::class.java).create() @@ -237,7 +227,7 @@ internal class SessionLifecycleServiceTest { } private fun getUploadedSessions() = - firebaseApp.get(FakeFirelogPublisher::class.java).loggedSessions + FirebaseSessionsFakeComponent.instance.fakeFirelogPublisher.loggedSessions private fun getSessionId(msg: Message) = msg.data?.getString(SessionLifecycleService.SESSION_UPDATE_EXTRA) diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacksTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacksTest.kt index 189e13fed89..62e650d90c8 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacksTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacksTest.kt @@ -31,6 +31,7 @@ import com.google.firebase.sessions.api.SessionSubscriber import com.google.firebase.sessions.testing.FakeFirebaseApp import com.google.firebase.sessions.testing.FakeSessionLifecycleServiceBinder import com.google.firebase.sessions.testing.FakeSessionSubscriber +import com.google.firebase.sessions.testing.FirebaseSessionsFakeComponent import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher @@ -46,7 +47,7 @@ import org.robolectric.Shadows @RunWith(AndroidJUnit4::class) internal class SessionsActivityLifecycleCallbacksTest { private lateinit var fakeService: FakeSessionLifecycleServiceBinder - private lateinit var lifecycleServiceBinder: FakeSessionLifecycleServiceBinder + private lateinit var lifecycleServiceBinder: SessionLifecycleServiceBinder private val fakeActivity = Activity() @Before @@ -63,17 +64,17 @@ internal class SessionsActivityLifecycleCallbacksTest { ) ) - val firebaseApp = - Firebase.initialize( - ApplicationProvider.getApplicationContext(), - FirebaseOptions.Builder() - .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) - .setApiKey(FakeFirebaseApp.MOCK_API_KEY) - .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) - .build(), - ) - fakeService = firebaseApp[FakeSessionLifecycleServiceBinder::class.java] - lifecycleServiceBinder = firebaseApp[FakeSessionLifecycleServiceBinder::class.java] + Firebase.initialize( + ApplicationProvider.getApplicationContext(), + FirebaseOptions.Builder() + .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) + .setApiKey(FakeFirebaseApp.MOCK_API_KEY) + .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) + .build(), + ) + + fakeService = FirebaseSessionsFakeComponent.instance.fakeSessionLifecycleServiceBinder + lifecycleServiceBinder = FirebaseSessionsFakeComponent.instance.sessionLifecycleServiceBinder } @After diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/RemoteSettingsTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/RemoteSettingsTest.kt index 6a3a4a1f8c3..e4fb0b00148 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/RemoteSettingsTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/RemoteSettingsTest.kt @@ -22,11 +22,13 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.concurrent.TestOnlyExecutors +import com.google.firebase.installations.FirebaseInstallationsApi +import com.google.firebase.sessions.ApplicationInfo import com.google.firebase.sessions.SessionEvents import com.google.firebase.sessions.testing.FakeFirebaseApp import com.google.firebase.sessions.testing.FakeFirebaseInstallations import com.google.firebase.sessions.testing.FakeRemoteConfigFetcher -import com.google.firebase.sessions.testing.TestSessionEventData.TEST_APPLICATION_INFO +import kotlin.coroutines.CoroutineContext import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.Dispatchers @@ -55,19 +57,18 @@ class RemoteSettingsTest { val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") val fakeFetcher = FakeRemoteConfigFetcher() - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext - val remoteSettings = - RemoteSettings( + buildRemoteSettings( TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), fakeFetcher, - dataStore = + SettingsCache( PreferenceDataStoreFactory.create( scope = this, produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ), + ) + ), ) runCurrent() @@ -97,16 +98,17 @@ class RemoteSettingsTest { val fakeFetcher = FakeRemoteConfigFetcher() val remoteSettings = - RemoteSettings( + buildRemoteSettings( TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), fakeFetcher, - dataStore = + SettingsCache( PreferenceDataStoreFactory.create( scope = this, produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ), + ) + ), ) runCurrent() @@ -138,16 +140,17 @@ class RemoteSettingsTest { val fakeFetcher = FakeRemoteConfigFetcher() val remoteSettings = - RemoteSettings( + buildRemoteSettings( TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), fakeFetcher, - dataStore = + SettingsCache( PreferenceDataStoreFactory.create( scope = this, produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ), + ) + ), ) val fetchedResponse = JSONObject(VALID_RESPONSE) @@ -190,16 +193,17 @@ class RemoteSettingsTest { val fakeFetcher = FakeRemoteConfigFetcher() val remoteSettings = - RemoteSettings( + buildRemoteSettings( TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), fakeFetcher, - dataStore = + SettingsCache( PreferenceDataStoreFactory.create( scope = this, produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ), + ) + ), ) val fetchedResponse = JSONObject(VALID_RESPONSE) @@ -248,26 +252,24 @@ class RemoteSettingsTest { val context = firebaseApp.applicationContext val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") val fakeFetcherWithDelay = - FakeRemoteConfigFetcher( - JSONObject(VALID_RESPONSE), - networkDelay = 3.seconds, - ) + FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE), networkDelay = 3.seconds) fakeFetcherWithDelay.responseJSONObject .getJSONObject("app_quality") .put("sampling_rate", 0.125) val remoteSettingsWithDelay = - RemoteSettings( + buildRemoteSettings( TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), - configsFetcher = fakeFetcherWithDelay, - dataStore = + fakeFetcherWithDelay, + SettingsCache( PreferenceDataStoreFactory.create( scope = this, produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ), + ) + ), ) // Do the first fetch. This one should fetched the configsFetcher. @@ -290,30 +292,12 @@ class RemoteSettingsTest { assertThat(remoteSettingsWithDelay.samplingRate).isEqualTo(0.125) } - @Test - fun remoteSettingsFetcher_badFetch_callsOnFailure() = runTest { - var failure: String? = null - - RemoteSettingsFetcher( - TEST_APPLICATION_INFO, - TestOnlyExecutors.blocking().asCoroutineDispatcher() + coroutineContext, - baseUrl = "this.url.is.invalid", - ) - .doConfigFetch( - headerOptions = emptyMap(), - onSuccess = {}, - onFailure = { failure = it }, - ) - - assertThat(failure).isNotNull() - } - @After fun cleanUp() { FirebaseApp.clearInstancesForTest() } - private companion object { + internal companion object { const val SESSION_TEST_CONFIGS_NAME = "firebase_session_settings_test" const val VALID_RESPONSE = @@ -334,5 +318,30 @@ class RemoteSettingsTest { } } """ + + /** + * Build an instance of [RemoteSettings] using the Dagger factory. + * + * This is needed because the SDK vendors Dagger to a difference namespace, but it does not for + * these unit tests. The [RemoteSettings.lazySettingsCache] has type [dagger.Lazy] in these + * tests, but type `com.google.firebase.sessions.dagger.Lazy` in the SDK. This method to build + * the instance is the easiest I could find that does not need any reference to [dagger.Lazy] in + * the test code. + */ + fun buildRemoteSettings( + backgroundDispatcher: CoroutineContext, + firebaseInstallationsApi: FirebaseInstallationsApi, + appInfo: ApplicationInfo, + configsFetcher: CrashlyticsSettingsFetcher, + settingsCache: SettingsCache, + ): RemoteSettings = + RemoteSettings_Factory.create( + { backgroundDispatcher }, + { firebaseInstallationsApi }, + { appInfo }, + { configsFetcher }, + { settingsCache }, + ) + .get() } } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SessionsSettingsTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SessionsSettingsTest.kt index f74eac409e5..12f40e7cca8 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SessionsSettingsTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SessionsSettingsTest.kt @@ -107,16 +107,17 @@ class SessionsSettingsTest { val fakeFetcher = FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE)) val remoteSettings = - RemoteSettings( + RemoteSettingsTest.buildRemoteSettings( TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), fakeFetcher, - dataStore = + SettingsCache( PreferenceDataStoreFactory.create( scope = this, produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ), + ) + ), ) val sessionsSettings = @@ -149,16 +150,17 @@ class SessionsSettingsTest { val fakeFetcher = FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE)) val remoteSettings = - RemoteSettings( + RemoteSettingsTest.buildRemoteSettings( TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), fakeFetcher, - dataStore = + SettingsCache( PreferenceDataStoreFactory.create( scope = this, produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ), + ) + ), ) val sessionsSettings = @@ -197,16 +199,17 @@ class SessionsSettingsTest { fakeFetcher.responseJSONObject = JSONObject(invalidResponse) val remoteSettings = - RemoteSettings( + RemoteSettingsTest.buildRemoteSettings( TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), fakeFetcher, - dataStore = + SettingsCache( PreferenceDataStoreFactory.create( scope = this, produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ), + ) + ), ) val sessionsSettings = diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeUuidGenerator.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeUuidGenerator.kt new file mode 100644 index 00000000000..88f1f816c12 --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeUuidGenerator.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions.testing + +import com.google.firebase.sessions.UuidGenerator +import java.util.UUID + +/** Fake implementation of [UuidGenerator] to provide uuids of the given names in order. */ +internal class FakeUuidGenerator(private val names: List = listOf(UUID_1, UUID_2, UUID_3)) : + UuidGenerator { + private var index = -1 + + override fun next(): UUID { + index = (index + 1).coerceAtMost(names.size - 1) + return UUID.fromString(names[index]) + } + + companion object { + const val UUID_1 = "11111111-1111-1111-1111-111111111111" + const val UUID_2 = "22222222-2222-2222-2222-222222222222" + const val UUID_3 = "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC" + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeComponent.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeComponent.kt new file mode 100644 index 00000000000..b3431f71840 --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeComponent.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions.testing + +import com.google.firebase.Firebase +import com.google.firebase.app +import com.google.firebase.sessions.FirebaseSessions +import com.google.firebase.sessions.FirebaseSessionsComponent +import com.google.firebase.sessions.SessionDatastore +import com.google.firebase.sessions.SessionFirelogPublisher +import com.google.firebase.sessions.SessionGenerator +import com.google.firebase.sessions.SessionLifecycleServiceBinder +import com.google.firebase.sessions.settings.SessionsSettings +import com.google.firebase.sessions.settings.SettingsProvider + +/** Fake component to manage [FirebaseSessions] and related, often faked, dependencies. */ +@Suppress("MemberVisibilityCanBePrivate") // Keep access to fakes open for convenience +internal class FirebaseSessionsFakeComponent : FirebaseSessionsComponent { + // TODO(mrober): Move tests that need DI to integration tests, and remove this component. + + // Fakes, access these instances to setup test cases, e.g., add interval to fake time provider. + val fakeTimeProvider = FakeTimeProvider() + val fakeUuidGenerator = FakeUuidGenerator() + val fakeSessionDatastore = FakeSessionDatastore() + val fakeFirelogPublisher = FakeFirelogPublisher() + val fakeSessionLifecycleServiceBinder = FakeSessionLifecycleServiceBinder() + + // Settings providers, default to fake, set these to real instances for relevant test cases. + var localOverrideSettings: SettingsProvider = FakeSettingsProvider() + var remoteSettings: SettingsProvider = FakeSettingsProvider() + + override val firebaseSessions: FirebaseSessions + get() = throw NotImplementedError("FirebaseSessions not implemented, use integration tests.") + + override val sessionDatastore: SessionDatastore = fakeSessionDatastore + + override val sessionFirelogPublisher: SessionFirelogPublisher = fakeFirelogPublisher + + override val sessionGenerator: SessionGenerator by lazy { + SessionGenerator(timeProvider = fakeTimeProvider, uuidGenerator = fakeUuidGenerator) + } + + override val sessionsSettings: SessionsSettings by lazy { + SessionsSettings(localOverrideSettings, remoteSettings) + } + + val sessionLifecycleServiceBinder: SessionLifecycleServiceBinder + get() = fakeSessionLifecycleServiceBinder + + companion object { + val instance: FirebaseSessionsFakeComponent + get() = Firebase.app[FirebaseSessionsFakeComponent::class.java] + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeRegistrar.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeRegistrar.kt index 9755a5e12d0..8dc6454931e 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeRegistrar.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeRegistrar.kt @@ -16,102 +16,37 @@ package com.google.firebase.sessions.testing -import androidx.annotation.Keep -import com.google.android.datatransport.TransportFactory -import com.google.firebase.FirebaseApp -import com.google.firebase.annotations.concurrent.Background -import com.google.firebase.annotations.concurrent.Blocking import com.google.firebase.components.Component import com.google.firebase.components.ComponentRegistrar import com.google.firebase.components.Dependency -import com.google.firebase.components.Qualified.qualified import com.google.firebase.components.Qualified.unqualified -import com.google.firebase.installations.FirebaseInstallationsApi import com.google.firebase.platforminfo.LibraryVersionComponent import com.google.firebase.sessions.BuildConfig import com.google.firebase.sessions.FirebaseSessions -import com.google.firebase.sessions.SessionDatastore -import com.google.firebase.sessions.SessionFirelogPublisher -import com.google.firebase.sessions.SessionGenerator -import com.google.firebase.sessions.SessionLifecycleServiceBinder -import com.google.firebase.sessions.WallClock -import com.google.firebase.sessions.settings.SessionsSettings -import kotlinx.coroutines.CoroutineDispatcher +import com.google.firebase.sessions.FirebaseSessionsComponent /** * [ComponentRegistrar] for setting up Fake components for [FirebaseSessions] and its internal * dependencies for unit tests. - * - * @hide */ -@Keep internal class FirebaseSessionsFakeRegistrar : ComponentRegistrar { override fun getComponents() = listOf( - Component.builder(SessionGenerator::class.java) - .name("session-generator") - .factory { SessionGenerator(timeProvider = WallClock) } - .build(), - Component.builder(FakeFirelogPublisher::class.java) - .name("fake-session-publisher") - .factory { FakeFirelogPublisher() } - .build(), - Component.builder(SessionFirelogPublisher::class.java) - .name("session-publisher") - .add(Dependency.required(fakeFirelogPublisher)) - .factory { container -> container.get(fakeFirelogPublisher) } - .build(), - Component.builder(SessionsSettings::class.java) - .name("sessions-settings") - .add(Dependency.required(firebaseApp)) - .add(Dependency.required(blockingDispatcher)) - .add(Dependency.required(backgroundDispatcher)) - .factory { container -> - SessionsSettings( - container.get(firebaseApp), - container.get(blockingDispatcher), - container.get(backgroundDispatcher), - fakeFirebaseInstallations, - ) - } + Component.builder(FirebaseSessionsComponent::class.java) + .name("fire-sessions-component") + .add(Dependency.required(firebaseSessionsFakeComponent)) + .factory { container -> container.get(firebaseSessionsFakeComponent) } .build(), - Component.builder(FakeSessionDatastore::class.java) - .name("fake-sessions-datastore") - .factory { FakeSessionDatastore() } - .build(), - Component.builder(SessionDatastore::class.java) - .name("sessions-datastore") - .add(Dependency.required(fakeDatastore)) - .factory { container -> container.get(fakeDatastore) } - .build(), - Component.builder(FakeSessionLifecycleServiceBinder::class.java) - .name("fake-sessions-service-binder") - .factory { FakeSessionLifecycleServiceBinder() } - .build(), - Component.builder(SessionLifecycleServiceBinder::class.java) - .name("sessions-service-binder") - .add(Dependency.required(fakeServiceBinder)) - .factory { container -> container.get(fakeServiceBinder) } + Component.builder(FirebaseSessionsFakeComponent::class.java) + .name("fire-sessions-fake-component") + .factory { FirebaseSessionsFakeComponent() } .build(), LibraryVersionComponent.create(LIBRARY_NAME, BuildConfig.VERSION_NAME), ) private companion object { - private const val LIBRARY_NAME = "fire-sessions" - - private val firebaseApp = unqualified(FirebaseApp::class.java) - private val firebaseInstallationsApi = unqualified(FirebaseInstallationsApi::class.java) - private val backgroundDispatcher = - qualified(Background::class.java, CoroutineDispatcher::class.java) - private val blockingDispatcher = - qualified(Blocking::class.java, CoroutineDispatcher::class.java) - private val transportFactory = unqualified(TransportFactory::class.java) - private val fakeFirelogPublisher = unqualified(FakeFirelogPublisher::class.java) - private val fakeDatastore = unqualified(FakeSessionDatastore::class.java) - private val fakeServiceBinder = unqualified(FakeSessionLifecycleServiceBinder::class.java) - private val sessionGenerator = unqualified(SessionGenerator::class.java) - private val sessionsSettings = unqualified(SessionsSettings::class.java) + const val LIBRARY_NAME = "fire-sessions" - private val fakeFirebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val firebaseSessionsFakeComponent = unqualified(FirebaseSessionsFakeComponent::class.java) } } diff --git a/firebase-sessions/test-app/src/main/AndroidManifest.xml b/firebase-sessions/test-app/src/main/AndroidManifest.xml index 3e1f4840cb3..9965842a01e 100644 --- a/firebase-sessions/test-app/src/main/AndroidManifest.xml +++ b/firebase-sessions/test-app/src/main/AndroidManifest.xml @@ -43,6 +43,11 @@ android:process=":second" android:theme="@style/Theme.Widget_test_app.NoActionBar" /> + + + @@ -51,6 +56,12 @@ android:name="firebase_sessions_sessions_restart_timeout" android:value="5" /> + + + + - - = Build.VERSION_CODES.P) Application.getProcessName() else "unknown" + private fun logProcessDetails() { + val pid = android.os.Process.myPid() + val uid = android.os.Process.myUid() + val activity = javaClass.name + val process = getProcessName() + Log.i(TAG, "activity: $activity process: $process, pid: $pid, uid: $uid") + } + + private fun logFirebaseDetails() { + val activity = javaClass.name + val firebaseApps = FirebaseApp.getApps(this) + val defaultFirebaseApp = FirebaseApp.getInstance() + Log.i( + TAG, + "activity: $activity firebase: ${defaultFirebaseApp.name} appsCount: ${firebaseApps.count()}" + ) + } + + private fun setProcessAttribute() { + FirebasePerformance.getInstance().putAttribute("process_name", getProcessName()) + } + companion object { val TAG = "BaseActivity" } diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt index 88488a4cc92..f5a965da7d4 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt @@ -16,6 +16,7 @@ package com.google.firebase.testing.sessions +import android.app.Application import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT import android.content.Intent.FLAG_ACTIVITY_NEW_TASK @@ -26,14 +27,20 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.google.firebase.perf.FirebasePerformance import com.google.firebase.testing.sessions.databinding.FragmentFirstBinding import java.util.Date import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch /** A simple [Fragment] subclass as the default destination in the navigation. */ class FirstFragment : Fragment() { val crashlytics = FirebaseCrashlytics.getInstance() + val performance = FirebasePerformance.getInstance() private var _binding: FragmentFirstBinding? = null @@ -64,6 +71,14 @@ class FirstFragment : Fragment() { Thread.sleep(1_000) } } + binding.createTrace.setOnClickListener { + lifecycleScope.launch(Dispatchers.IO) { + val performanceTrace = performance.newTrace("test_trace") + performanceTrace.start() + delay(1000) + performanceTrace.stop() + } + } binding.buttonForegroundProcess.setOnClickListener { if (binding.buttonForegroundProcess.getText().startsWith("Start")) { ForegroundService.startService(requireContext(), "Starting service at ${getDateText()}") @@ -89,6 +104,7 @@ class FirstFragment : Fragment() { intent.addFlags(FLAG_ACTIVITY_NEW_TASK) startActivity(intent) } + binding.processName.text = getProcessName() } override fun onResume() { @@ -111,5 +127,9 @@ class FirstFragment : Fragment() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) else "unknown" + + fun getProcessName(): String = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) Application.getProcessName() + else "unknown" } } diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/SecondActivity.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/SecondActivity.kt index 9272510d0f3..6c2fd3c06b0 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/SecondActivity.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/SecondActivity.kt @@ -22,10 +22,14 @@ import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.os.Build import android.os.Bundle import android.widget.Button +import android.widget.TextView +import androidx.lifecycle.lifecycleScope +import com.google.firebase.perf.FirebasePerformance +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch /** Second activity from the MainActivity that runs on a different process. */ class SecondActivity : BaseActivity() { - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_second) @@ -38,12 +42,21 @@ class SecondActivity : BaseActivity() { findViewById