diff --git a/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE/bug_report.md similarity index 91% rename from ISSUE_TEMPLATE.md rename to .github/ISSUE_TEMPLATE/bug_report.md index 5de83b2cc..36eea6edb 100644 --- a/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,3 +1,12 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + ### [READ] Step 1: Are you in the right place? * For issues or feature requests related to __the code in this repository__ diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..3408a0aa1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '[FR]' +labels: 'type: feature request' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..04ceb6e86 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "maven" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/resources/firebase.asc.gpg b/.github/resources/firebase.asc.gpg new file mode 100644 index 000000000..4b41f18c5 Binary files /dev/null and b/.github/resources/firebase.asc.gpg differ diff --git a/.github/resources/integ-service-account.json.gpg b/.github/resources/integ-service-account.json.gpg new file mode 100644 index 000000000..5a52805c9 Binary files /dev/null and b/.github/resources/integ-service-account.json.gpg differ diff --git a/.github/resources/settings.xml b/.github/resources/settings.xml new file mode 100644 index 000000000..53e482738 --- /dev/null +++ b/.github/resources/settings.xml @@ -0,0 +1,29 @@ + + + + false + + + + central + ${env.CENTRAL_USERNAME} + ${env.CENTRAL_TOKEN} + + + + + + release + + true + + + gpg + ${env.GPG_KEY_ID} + ${env.GPG_PASSPHRASE} + + + + diff --git a/.github/scripts/generate_changelog.sh b/.github/scripts/generate_changelog.sh new file mode 100755 index 000000000..a0afa6e41 --- /dev/null +++ b/.github/scripts/generate_changelog.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +# Copyright 2020 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 -e +set -u + +function printChangelog() { + local TITLE=$1 + shift + # Skip the sentinel value. + local ENTRIES=("${@:2}") + if [ ${#ENTRIES[@]} -ne 0 ]; then + echo "### ${TITLE}" + echo "" + for ((i = 0; i < ${#ENTRIES[@]}; i++)) + do + echo "* ${ENTRIES[$i]}" + done + echo "" + fi +} + +if [[ -z "${GITHUB_SHA}" ]]; then + GITHUB_SHA="HEAD" +fi + +LAST_TAG=`git describe --tags $(git rev-list --tags --max-count=1) 2> /dev/null` || true +if [[ -z "${LAST_TAG}" ]]; then + echo "[INFO] No tags found. Including all commits up to ${GITHUB_SHA}." + VERSION_RANGE="${GITHUB_SHA}" +else + echo "[INFO] Last release tag: ${LAST_TAG}." + COMMIT_SHA=`git show-ref -s ${LAST_TAG}` + echo "[INFO] Last release commit: ${COMMIT_SHA}." + VERSION_RANGE="${COMMIT_SHA}..${GITHUB_SHA}" + echo "[INFO] Including all commits in the range ${VERSION_RANGE}." +fi + +echo "" + +# Older versions of Bash (< 4.4) treat empty arrays as unbound variables, which triggers +# errors when referencing them. Therefore we initialize each of these arrays with an empty +# sentinel value, and later skip them. +CHANGES=("") +FIXES=("") +FEATS=("") +MISC=("") + +while read -r line +do + COMMIT_MSG=`echo ${line} | cut -d ' ' -f 2-` + if [[ $COMMIT_MSG =~ ^change(\(.*\))?: ]]; then + CHANGES+=("$COMMIT_MSG") + elif [[ $COMMIT_MSG =~ ^fix(\(.*\))?: ]]; then + FIXES+=("$COMMIT_MSG") + elif [[ $COMMIT_MSG =~ ^feat(\(.*\))?: ]]; then + FEATS+=("$COMMIT_MSG") + else + MISC+=("${COMMIT_MSG}") + fi +done < <(git log ${VERSION_RANGE} --oneline) + +printChangelog "Breaking Changes" "${CHANGES[@]}" +printChangelog "New Features" "${FEATS[@]}" +printChangelog "Bug Fixes" "${FIXES[@]}" +printChangelog "Miscellaneous" "${MISC[@]}" diff --git a/.github/scripts/package_artifacts.sh b/.github/scripts/package_artifacts.sh new file mode 100755 index 000000000..fbbc5555b --- /dev/null +++ b/.github/scripts/package_artifacts.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +# Copyright 2020 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 -e +set -u + +gpg --quiet --batch --yes --decrypt --passphrase="${FIREBASE_SERVICE_ACCT_KEY}" \ + --output integration_cert.json .github/resources/integ-service-account.json.gpg + +echo "${FIREBASE_API_KEY}" > integration_apikey.txt + +# Does the following: +# 1. Runs the Checkstyle plugin (validate phase) +# 2. Compiles the source (compile phase) +# 3. Runs the unit tests (test phase) +# 4. Packages the artifacts - src, bin, javadocs (package phase) +# 5. Runs the integration tests (verify phase) +mvn -DtrimStackTrace=false -B clean verify + +# Maven target directory can consist of many files. Just copy the jar artifacts +# into a new directory for upload. +mkdir -p dist +cp target/*.jar dist/ diff --git a/.github/scripts/publish_artifacts.sh b/.github/scripts/publish_artifacts.sh new file mode 100755 index 000000000..5da0f32a4 --- /dev/null +++ b/.github/scripts/publish_artifacts.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# Copyright 2020 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 -e +set -u + +gpg --quiet --batch --yes --decrypt --passphrase="${GPG_PRIVATE_KEY}" \ + --output firebase.asc .github/resources/firebase.asc.gpg + +gpg --import --no-tty --batch --yes firebase.asc + +# Does the following: +# 1. Compiles the source (compile phase) +# 2. Packages the artifacts - src, bin, javadocs (package phase) +# 3. Signs the artifacts (verify phase) +# 4. Publishes artifacts via Central Publisher Portal (deploy phase) +mvn -B clean deploy \ + -Dcheckstyle.skip \ + -DskipTests \ + -Prelease \ + --settings .github/resources/settings.xml + diff --git a/.github/scripts/publish_preflight_check.sh b/.github/scripts/publish_preflight_check.sh new file mode 100755 index 000000000..7c23eba57 --- /dev/null +++ b/.github/scripts/publish_preflight_check.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +# Copyright 2020 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. + + +###################################### Outputs ##################################### + +# 1. version: The version of this release including the 'v' prefix (e.g. v1.2.3). +# 2. changelog: Formatted changelog text for this release. + +#################################################################################### + +set -e +set -u + +function echo_info() { + local MESSAGE=$1 + echo "[INFO] ${MESSAGE}" +} + +function echo_warn() { + local MESSAGE=$1 + echo "[WARN] ${MESSAGE}" +} + +function terminate() { + echo "" + echo_warn "--------------------------------------------" + echo_warn "PREFLIGHT FAILED" + echo_warn "--------------------------------------------" + exit 1 +} + + +echo_info "Starting publish preflight check..." +echo_info "Git revision : ${GITHUB_SHA}" +echo_info "Workflow triggered by : ${GITHUB_ACTOR}" +echo_info "GitHub event : ${GITHUB_EVENT_NAME}" + + +echo_info "" +echo_info "--------------------------------------------" +echo_info "Extracting release version" +echo_info "--------------------------------------------" +echo_info "" + +echo_info "Loading version from: pom.xml" +readonly RELEASE_VERSION=`mvn help:evaluate -Dexpression=project.version -q -DforceStdout` || true +if [[ -z "${RELEASE_VERSION}" ]]; then + echo_warn "Failed to extract release version from: pom.xml" + terminate +fi + +if [[ ! "${RELEASE_VERSION}" =~ ^([0-9]*)\.([0-9]*)\.([0-9]*)$ ]]; then + echo_warn "Malformed release version string: ${RELEASE_VERSION}. Exiting." + terminate +fi + +echo_info "Extracted release version: ${RELEASE_VERSION}" +echo "version=v${RELEASE_VERSION}" >> $GITHUB_OUTPUT + + +echo_info "" +echo_info "--------------------------------------------" +echo_info "Checking previous releases" +echo_info "--------------------------------------------" +echo_info "" + +readonly MAVEN_CENTRAL_URL="https://repo1.maven.org/maven2/com/google/firebase/firebase-admin/${RELEASE_VERSION}" +readonly MAVEN_STATUS=`curl -s -o /dev/null -L -w "%{http_code}" ${MAVEN_CENTRAL_URL}` +if [[ $MAVEN_STATUS -eq 404 ]]; then + echo_info "Release version ${RELEASE_VERSION} not found in Maven Central." +elif [[ $MAVEN_STATUS -eq 200 ]]; then + echo_warn "Release version ${RELEASE_VERSION} already present in Maven Central." + terminate +else + echo_warn "Unexpected ${MAVEN_STATUS} response from Maven Central. Exiting." + terminate +fi + + +echo_info "" +echo_info "--------------------------------------------" +echo_info "Checking release tag" +echo_info "--------------------------------------------" +echo_info "" + +echo_info "---< git fetch --depth=1 origin +refs/tags/*:refs/tags/* >---" +git fetch --depth=1 origin +refs/tags/*:refs/tags/* +echo "" + +readonly EXISTING_TAG=`git rev-parse -q --verify "refs/tags/v${RELEASE_VERSION}"` || true +if [[ -n "${EXISTING_TAG}" ]]; then + echo_warn "Tag v${RELEASE_VERSION} already exists. Exiting." + echo_warn "If the tag was created in a previous unsuccessful attempt, delete it and try again." + echo_warn " $ git tag -d v${RELEASE_VERSION}" + echo_warn " $ git push --delete origin v${RELEASE_VERSION}" + + readonly RELEASE_URL="https://github.com/firebase/firebase-admin-java/releases/tag/v${RELEASE_VERSION}" + echo_warn "Delete any corresponding releases at ${RELEASE_URL}." + terminate +fi + +echo_info "Tag v${RELEASE_VERSION} does not exist." + + +echo_info "" +echo_info "--------------------------------------------" +echo_info "Generating changelog" +echo_info "--------------------------------------------" +echo_info "" + +echo_info "---< git fetch origin main --prune --unshallow >---" +git fetch origin main --prune --unshallow +echo "" + +echo_info "Generating changelog from history..." +readonly CURRENT_DIR=$(dirname "$0") +readonly CHANGELOG=`${CURRENT_DIR}/generate_changelog.sh` +echo "$CHANGELOG" + +# Parse and preformat the text to handle multi-line output. +# See https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#example-of-a-multiline-string +# and https://github.com/github/docs/issues/21529#issue-1418590935 +FILTERED_CHANGELOG=`echo "$CHANGELOG" | grep -v "\\[INFO\\]"` +FILTERED_CHANGELOG="${FILTERED_CHANGELOG//$'\''/'"'}" +echo "changelog<> $GITHUB_OUTPUT +echo -e "$FILTERED_CHANGELOG" >> $GITHUB_OUTPUT +echo "CHANGELOGEOF" >> $GITHUB_OUTPUT + + +echo "" +echo_info "--------------------------------------------" +echo_info "PREFLIGHT SUCCESSFUL" +echo_info "--------------------------------------------" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..48859acb1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +# Copyright 2020 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. +name: Continuous Integration + +on: pull_request + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + java-version: [8, 11, 17] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up JDK + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: 'zulu' + java-version: ${{ matrix.java-version }} + + # Does the following: + # 1. Runs the Checkstyle plugin (validate phase) + # 2. Compiles the source (compile phase) + # 3. Runs the unit tests (test phase) + - name: Build with Maven + run: mvn -B clean test diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..29962acae --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,88 @@ +# Copyright 2021 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. + +name: Nightly Builds + +on: + # Runs every day at 06:10 AM (PT) and 08:10 PM (PT) / 04:10 AM (UTC) and 02:10 PM (UTC) + # or on 'firebase_nightly_build' repository dispatch event. + schedule: + - cron: "10 4,14 * * *" + repository_dispatch: + types: [firebase_nightly_build] + +permissions: + contents: read + +jobs: + nightly: + + runs-on: ubuntu-latest + + steps: + - name: Checkout source for staging + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.client_payload.ref || github.ref }} + persist-credentials: false + + - name: Set up JDK 8 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: 'zulu' + java-version: 8 + + - name: Compile, test and package + run: ./.github/scripts/package_artifacts.sh + env: + FIREBASE_SERVICE_ACCT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCT_KEY }} + FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }} + + # Attach the packaged artifacts to the workflow output. These can be manually + # downloaded for later inspection if necessary. + - name: Archive artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: dist + + - name: Send email on failure + if: failure() + uses: firebase/firebase-admin-node/.github/actions/send-email@7343f166ec71c2efee5fe0554c2fb96203faf2a5 # Admin Node SDK v14.2.0 + with: + api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} + domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} + from: 'GitHub ' + to: ${{ secrets.FIREBASE_ADMIN_GITHUB_EMAIL }} + subject: 'Nightly build ${{github.run_id}} of ${{github.repository}} failed!' + html: > + Nightly workflow ${{github.run_id}} failed on: ${{github.repository}} +

Navigate to the + failed workflow. + continue-on-error: true + + - name: Send email on cancelled + if: cancelled() + uses: firebase/firebase-admin-node/.github/actions/send-email@7343f166ec71c2efee5fe0554c2fb96203faf2a5 # Admin Node SDK v14.2.0 + with: + api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} + domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} + from: 'GitHub ' + to: ${{ secrets.FIREBASE_ADMIN_GITHUB_EMAIL }} + subject: 'Nightly build ${{github.run_id}} of ${{github.repository}} cancelled!' + html: > + Nightly workflow ${{github.run_id}} cancelled on: ${{github.repository}} +

Navigate to the + cancelled workflow. + continue-on-error: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..7a64bd46a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,127 @@ +# Copyright 2020 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. + +name: Release Candidate + +on: + # Run the workflow when: + # 1. A PR is updated (staging checks). + # 2. A commit is pushed to main (release publication). + # 3. A developer explicitly requests a build via 'firebase_build' event. + pull_request: + types: [opened, synchronize] + + push: + branches: + - main + paths: + - 'pom.xml' + + repository_dispatch: + types: + - firebase_build + +permissions: + contents: read + +jobs: + stage_release: + # To publish a release, merge a PR with the title prefix '[chore] Release ' to main + # and ensure the squashed commit message also has the prefix. + # To stage a release without publishing it, send a 'firebase_build' event or apply + # the 'release:stage' label to a PR. + if: github.event.action == 'firebase_build' || + contains(github.event.pull_request.labels.*.name, 'release:stage') || + (github.event_name == 'push' && startsWith(github.event.head_commit.message, '[chore] Release ')) + + runs-on: ubuntu-latest + + steps: + - name: Checkout source for staging + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up JDK 8 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: 'zulu' + java-version: 8 + + - name: Compile, test and package + run: ./.github/scripts/package_artifacts.sh + env: + FIREBASE_SERVICE_ACCT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCT_KEY }} + FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }} + + # Attach the packaged artifacts to the workflow output. These can be manually + # downloaded for later inspection if necessary. + - name: Archive artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: dist + + publish_release: + needs: stage_release + + # Check whether the release should be published. We publish only when the trigger is + # 1. a push (merge) + # 2. to the main branch + # 3. and the commit message has the title prefix '[chore] Release '. + if: github.event_name == 'push' && + github.ref == 'refs/heads/main' && + startsWith(github.event.head_commit.message, '[chore] Release ') + + runs-on: ubuntu-latest + environment: Release + permissions: + contents: write + + steps: + - name: Checkout source for publish + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Ignored since the GITHUB_TOKEN needs to be persisted for this workflow. + persist-credentials: true # zizmor: ignore[artipacked] + + - name: Set up JDK 8 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: 'zulu' + java-version: 8 + + - name: Publish preflight check + id: preflight + run: ./.github/scripts/publish_preflight_check.sh + + - name: Publish to Maven Central + run: ./.github/scripts/publish_artifacts.sh + env: + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_TOKEN: ${{ secrets.CENTRAL_TOKEN }} + + # See: https://cli.github.com/manual/gh_release_create + - name: Create release tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VER: ${{ steps.preflight.outputs.version }} + RELEASE_BODY: ${{ steps.preflight.outputs.changelog }} + run: | + gh release create "$RELEASE_VER" \ + --title "Firebase Admin Java SDK $RELEASE_VER" \ + --notes "$RELEASE_BODY" diff --git a/.gitignore b/.gitignore index 49d03c7a3..6e7d29cfd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ target/ .classpath .project .checkstyle +.factorypath +.vscode/ release.properties integration_cert.json integration_apikey.txt +.DS_Store diff --git a/.opensource/project.json b/.opensource/project.json index 74e68f048..56484ef37 100644 --- a/.opensource/project.json +++ b/.opensource/project.json @@ -7,6 +7,7 @@ "content": "README.md", "pages": [], "related": [ + "firebase/firebase-admin-dotnet", "firebase/firebase-admin-go", "firebase/firebase-admin-node", "firebase/firebase-admin-python" diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index dff5f3a5d..000000000 --- a/.travis.yml +++ /dev/null @@ -1 +0,0 @@ -language: java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..d25e006a6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# AI coding assistant guidelines: Firebase Admin Java SDK + +This document defines repository-wide expectations, code styles, and Checkstyle compliance metrics that every autonomous AI coding agent or pair programming assistant must strictly follow when introducing or modifying code in this repository. + +--- + +## 🎨 Code Style & Checkstyle Compliance + +This repository enforces standard **Google Java Style** guidelines validated through automated Maven checkstyle executions (`checkstyle.xml`). Any compilation or pull request build will fail if these constraints are violated. + +### 1. Strict 100-Character Line Limit +* **Rule:** No line of code, comments, Javadoc documentation entries, or inline string literal concatenations may exceed **100 characters** under any circumstances. +* **Remediation:** + * Break long method parameters, array initializations, and logic comparisons onto separate lines. + * Wrap long `assertThrows` statements or lambda chains across multiple contiguous lines. + * Format long log messages, exception messages, or assertion string explanations using standard string block concatenation broken across separate lines. + +### 2. Import Ordering & Grouping +* **Rule:** Imports must be grouped and arranged alphabetically to avoid validation noise. +* **Remediation:** + 1. Static imports placed first, grouped, and alphabetically arranged. + 2. Non-static imports grouped alphabetically by package tier. + 3. Avoid utilizing wildcard (`*`) imports. Every import declaration must be explicit. + +### 3. Javadoc Completeness & Formatting +* **Rule:** Every public class, package-private component interface, constructor, and public method signature must include fully formed Javadoc documentation blocks. +* **Remediation:** + * Document all arguments via `@param`, explain error flows via `@throws`, and clear return constraints via `@return`. + * Wrap documentation description text explicitly so that no single javadoc documentation line passes the 100 characters limit. + +### 4. Indentation & Spacing +* **Rule:** Standard indentation uses exactly **2 spaces** per block indentation level. **4 spaces** are used explicitly for wrapped line continuation indentation. +* **Remediation:** Never utilize tab characters. Ensure block braces follow the Google K&R opening line placement convention. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index a26bb3258..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,464 +0,0 @@ -# Unreleased - -- - -# v6.5.0 - -- [added] `FirebaseOptions.Builder` class now provides a - `setFirestoreOptions()` method for configuring the Firestore client. -- [changed] Upgraded the Cloud Firestore client to 0.61.0-beta. -- [changed] Upgraded the Cloud Storage client to 1.43.0. - -# v6.4.0 - -- [added] `WebpushNotification` type now supports arbitrary key-value - pairs in its payload. - -# v6.3.0 - -- [added] Implemented the ability to create custom tokens without - service account credentials. -- [added] Added the `setServiceAccount()` method to the - `FirebaseOptions.Builder` API. -- [added] The SDK can now read the Firebase/GCP project ID from both - `GCLOUD_PROJECT` and `GOOGLE_CLOUD_PROJECT` environment variables. - -# v6.2.0 - -- [added] Added new `importUsersAsync()` API for bulk importing users - into Firebase Auth. - -# v6.1.0 - -- [changed] Deprecated the `FirebaseAuth.setCustomClaims()` method. - Developers should use the `FirebaseAuth.setCustomUserClaims()` method - instead. - -# v6.0.0 - -- [added] `FirebaseAuth`, `FirebaseMessaging` and `FirebaseInstanceId` - interfaces now expose a set of blocking methods. Each operation has - blocking an asynchronous versions. -- [changed] Removed the deprecated `FirebaseCredential` interface. -- [changed] Removed the deprecated `Task` interface along with the - `com.google.firebase.tasks` package. -- [changed] Dropped support for App Engine's Java 7 runtime. Developers - are advised to use the Admin SDK with Java 8 when deploying to App - Engine. -- [changed] Removed the deprecated `FirebaseDatabase.setLogLevel()` API - and the related logging utilities. Developers should use SLF4J to - configure logging directly. - -# v5.11.0 - -- [added] A new `FirebaseAuth.createSessionCookieAsync()` method for - creating a long-lived session cookie given a valid ID token. -- [added] A new `FirebaseAuth.verifySessionCookieAsync()` method for - verifying a given cookie string is valid. -- [fixed] Upgraded Cloud Firestore dependency version to 0.45.0-beta. -- [fixed] Upgraded Cloud Storage dependency version to 1.27.0. -- [fixed] Upgraded Netty dependency version to 4.1.22. - -# v5.10.0 - -- [fixed] Using the `HttpTransport` specified at `FirebaseOptions` in - `GooglePublicKeysManager`. This enables developers to use a custom - transport to fetch public keys when verifying ID tokens and session - cookies. -- [added] Connection timeout and read timeout for HTTP/REST connections - can now be configured via `FirebaseOptions.Builder` at app - initialization. -- [added] Added new `setMutableContent()`, `putCustomData()` and - `putAllCustomData()` methods to the `Aps.Builder` API. -- [fixed] Improved error handling in FCM by mapping more server-side - errors to client-side error codes. - -# v5.9.0 - -### Cloud Messaging - -- [feature] Added the `FirebaseCloudMessaging` API for sending - Firebase notifications and managing topic subscriptions. - -### Authentication - -- [added] The [`verifyIdTokenAsync()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#verifyIdTokenAsync) - method has an overload that accepts a boolean `checkRevoked` parameter. - When `true`, an additional check is performed to see whether the token - has been revoked. -- [added] A new [`revokeRefreshTokensAsync()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#revokeRefreshTokens) - method has been added to invalidate all tokens issued to a user. -- [added] A new getter `getTokensValidAfterTimestamp()` has been added - to the [`UserRecord`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/UserRecord) - class, which denotes the time before which tokens are not valid. - -### Realtime Database - -- [fixed] Exceptions thrown by database event handlers are now logged. - -### Initialization - -- [fixed] The [`FirebaseOptions.Builder.setStorageBucket()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/FirebaseOptions.Builder.html#setStorageBucket(java.lang.String)) - method now throws a clear exception when invoked with a bucket URL - instead of the name. -- [fixed] Implemented a fix for a potential Guava version conflict which - was causing an `IllegalStateException` (precondition failure) in some - environments. - -### Cloud Firestore - -- [fixed] Upgraded the Cloud Firestore client to the latest available - version. - -# v5.8.0 - -### Initialization - -- [added] The [`FirebaseApp.initializeApp()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/FirebaseApp.html#initializeApp()) - method now provides an overload that does not require any arguments. This - initializes an app using Google Application Default Credentials, and other - [`FirebaseOptions`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/FirebaseOptions) - loaded from the `FIREBASE_CONFIG` environment variable. - -### Authentication - -- [changed] Improved error handling in user management APIs in the - [`FirebaseAuth`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth) - class. These operations now throw exceptions with well-defined - [error codes](https://firebase.google.com/docs/auth/admin/errors). - -### Realtime Database - -- [changed] The SDK now serializes large whole double values as longs when - appropriate. - -# v5.7.0 - -- [added] A new [`FirebaseInstanceId`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/iid/FirebaseInstanceId) - API that facilitates deleting instance IDs and associated user data from - Firebase projects. - -### Authentication -- [changed] No longer using `org.json` dependency in Authentication APIs, which - makes it easier to use the API in environments with conflicting JSON - libraries. - -# v5.6.0 - -- [changed] Upgraded the version of Google API Common dependency to the latest - (1.2.0). - -### Authentication -- [added] Added the - [`listUsersAsync()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#listUsersAsync(java.lang.String)) - method to the - [`FirebaseAuth`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth) - class. This method enables listing or iterating over all user accounts - in a Firebase project. -- [added] Added the - [`setCustomUserClaimsAsync()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#setCustomUserClaimsAsync(java.lang.String, java.util.Map)) - method to the - [`FirebaseAuth`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth) - class. This method enables setting custom claims on a Firebase user. - The custom claims can be accessed via that user's ID token. - -### Realtime Database -- [changed] Re-implemented the WebSocket communication layer of the - Realtime Database client using Netty. - -# v5.5.0 - -- [added] A new [`FirestoreClient`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/cloud/FirestoreClient) - API that enables access to [Cloud Firestore](https://firebase.google.com/docs/firestore) databases. - -### Realtime Database -- [changed] Ensured graceful termination of database worker threads upon - callng [`FirebaseApp.delete()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/FirebaseApp.html#delete()). - -# v5.4.0 - -- [added] A new [`ThreadManager`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/ThreadManager) - API that can be used to specify the thread - pool and the `ThreadFactory` that should be used by the SDK. -- [added] All APIs that support asynchronous operations now return an - [`ApiFuture`](https://googleapis.github.io/api-common-java/1.1.0/apidocs/com/google/api/core/ApiFuture.html). - The old [`Task`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/tasks/Task) - API has been deprecated. For each method `x()` - that returns a `Task`, a new `xAsync()` method that returns an `ApiFuture` - has been introduced. -- [changed] The SDK now guarantees the graceful termination of all started - threads. In most environments, the SDK will use daemons for all background - activities. The developer can also initiate a graceful termination of threads - by calling [`FirebaseApp.delete()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/FirebaseApp.html#delete()). - -### Initialization - -- [added] [`FirebaseOptions`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/FirebaseOptions) - can now be initialized with - [`GoogleCredentials`](http://google.github.io/google-auth-library-java/releases/0.7.1/apidocs/com/google/auth/oauth2/GoogleCredentials.html). - This is the new recommended way to specify credentials when initializing the - SDK. The old [`FirebaseCredential`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseCredential) - and [`FirebaseCredentials`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseCredentials) - APIs have been deprecated. - -# v5.3.1 - -### Authentication -- [changed] Throwing an accurate and more detailed error from - [`verifyIdToken()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#verifyIdToken(java.lang.String)) - in the event of a low-level exception. - -### Realtime Database -- [changed] Proper handling and logging of exceptions thrown by the - [`onComplete()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/database/Transaction.Handler.html#onComplete(com.google.firebase.database.DatabaseError, boolean, com.google.firebase.database.DataSnapshot)) - event of transaction handlers. - -# v5.3.0 -- [added] A new [{{firebase_storage}} API](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/cloud/StorageClient) - that facilitates accessing Google Cloud Storage buckets using the - [`google-cloud-storage`](http://googlecloudplatform.github.io/google-cloud-java/latest/apidocs/com/google/cloud/storage/Storage.html) - library. -- [added] Integrated with the [SLF4J](https://www.slf4j.org/) library for all logging - purposes. - -### Authentication -- [added] Added the method - [`getUserByPhoneNumber()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#getUserByPhoneNumber(java.lang.String)) - to the [`FirebaseAuth`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth) - interface. This method - enables retrieving user profile information by a phone number. -- [added] [`CreateRequest`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/UserRecord.CreateRequest) - and [`UpdateRequest`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/UserRecord.UpdateRequest) types - now provide setters for specifying a phone number, which can be used to create users with a phone - number field and/or update the phone number associated with a user. -- [added] Added the `getPhoneNumber()` method to - [`UserRecord`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/UserRecord), - which exposes the phone number associated with a user account. -- [added] Added the `getPhoneNumber()` method to - [`UserInfo`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/UserInfo), - which exposes the phone number associated with a user account by a linked - identity provider. - -### Realtime Database -- {{changed}} Deprecated the `FirebaseDatabase.setLogLevel()` method. Use SLF4J to configure logging. -- [changed] Logging a detailed error when the database client fails to authenticate with the backend - Firebase servers. - -# v5.2.0 -- [added] New factory methods in the - [`FirebaseCredentials`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseCredentials) - class - that accept `HttpTransport` and `JsonFactory` arguments. These settings are - used when the credentials make HTTP calls to obtain OAuth2 access tokens. -- [added] New `setHttpTransport()` and `setJsonFactory()` methods in the - [`FirebaseOptions`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/FirebaseOptions) - class. These settings are used by all services of the SDK except - `FirebaseDatabase`. - -# v5.1.0 - -### Authentication - -- [added] A new user management API that allows provisioning and managing - Firebase users from Java applications. This API adds `getUser()`, - `getUserByEmail()`, `createUser()`, `updateUser()` and `deleteUser()` methods - to the [`FirebaseAuth`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth) - interface. - -# v5.0.1 - -### Realtime Database - -- [changed] Fixed a database API thread leak that made the SDK - unstable when running in the Google App Engine environment. - -# v5.0.0 - -### Initialization - -- [added] Factory methods in - [`FirebaseCredentials`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseCredentials) - class can now throw `IOExceptions`, providing fail-fast semantics while - facilitating simpler error handling. -- [added] The deprecated `setServiceAccount()` method has been removed - from the - [`FirebaseOptions.Builder`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/FirebaseOptions.Builder) - class in favor of the `setCredential()` method. -- [added] Trying to initialize the SDK without setting a credential now - results in an exception. -- {{changed}} The - [`FirebaseCredential`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseCredential) - interface now returns a new - [`GoogleOAuthAccessToken`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/GoogleOAuthAccessToken) - type, which encapsulates both token string and its expiry time. - -# v4.1.7 - -- [added] Introducing a new `FirebaseApp.delete()` method, which can - be used to gracefully shut down app instances. All app invocations after a - call to `delete()` will throw exceptions. Deleted app instances can also be - re-initialized with the same name if necessary. - -- [changed] Upgraded SDK dependencies. Guava, Google API Client, and JSON - libraries that the SDK depends on have been upgraded to more recent versions. - -# v4.1.6 - -### Realtime Database - -- [changed] Updated the SDK to select the correct thread pool - implementation when running on a regular JVM with App Engine - libraries in the `classpath`. - -# v4.1.5 - -### Realtime Database - -- [changed] Fixed the invalid SDK version constant in the `FirebaseDatabase` - class that was released in v4.1.4. - -# v4.1.4 - -### Authentication - -- [changed] Updated the SDK to periodically refresh the OAuth access token - internally used by `FirebaseApp`. This reduces the number of authentication - failures encountered at runtime by various SDK components (e.g. Realtime Database) - to nearly zero. This feature is active by default when running in typical - Java environments, or the Google App Engine environment with background - threads support. - -# v4.1.3 - -### Realtime Database - -- [changed] Updated Realtime Database to properly swap out the ID token used to - authenticate the underlying websocket when a new ID token is generated. The - websocket connection is still disconnected and reconnected every hour when an - ID token expires unless you manually call - [`getAccessToken`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseCredential.html#getAccessToken(boolean)) - on the `FirebaseCredential` used to authenticate the SDK. In a future - release, the SDK will proactively refresh ID tokens automatically before they - expire. - - -# v4.1.2 - -### Initialization - -- [changed] Updated `initalizeApp()` to synchronously read from an `InputStream` - to avoid issues with closing the stream after initializing the SDK. -- [changed] Improved confusing error messages when initializing the SDK with - a `null` or malformed `InputStream`. - - -# v4.1.1 -### Authentication - -- [changed] Fixed a dependency issue which caused the - [`verifyIdToken()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#verifyIdToken(java.lang.String)) - method to always throw an exception. - - -# v4.1.0 -### Initialization - -- {{deprecated}} The - [`FirebaseOptions.Builder.setServiceAccount()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/FirebaseOptions.Builder.html#setServiceAccount(java.io.InputStream)) - method has been deprecated in favor of a new - [`FirebaseOptions.Builder.setCredential()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/FirebaseOptions.Builder.html#setCredential(com.google.firebase.auth.FirebaseCredential)) - method. See [Initialize the SDK](https://firebase.google.com/docs/admin/setup/#initialize_the_sdk) for - usage instructions. -- [added] The new - [`FirebaseCredential.fromCertificate()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseCredentials.html#fromCertificate(java.io.InputStream)) - method allows you to authenticate the SDK with a service account certificate - file. -- [added] The new - [`FirebaseCredential.fromRefreshToken()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseCredentials.html#fromRefreshToken(java.io.InputStream)) - method allows you to authenticate the SDK with a Google OAuth2 refresh token. -- [added] The new - [`FirebaseCredential.applicationDefault()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseCredentials.html#applicationDefault()) - method allows you to authenticate the SDK with Google Application Default - Credentials. - -### Authentication - -- [issue] The - [`verifyIdToken()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#verifyIdToken(java.lang.String)) - method is broken in this release and throws an exception due to an incorrect - dependency. This was fixed in version [`4.1.1`](#4.1.1). - - -# v4.0.4 - -- [changed] Fixed issue which caused threads to be terminated in Google App - Engine after 24 hours, rendering the SDK unresponsive. -- [changed] Fixed issues which caused asynchronous task execution to fail on - automatically-scaled Google App Engine instances. - -### Authentication - -- [added] Improved error messages and added App Engine support for the - [`verifyIdToken()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#verifyIdToken(java.lang.String)) - method. - -### Realtime Database - -- [changed] Fixed a race condition which could occur when new writes are added - while the connection is being closed. - - -# v4.0.3 - -### Initialization - -- [changed] Fixed an issue that caused a `null` input to the - [`setDatabaseAuthVariableOverride()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/FirebaseOptions.Builder.html#setDatabaseAuthVariableOverride(java.util.Map)) - method to be ignored, which caused the app to still have full admin access. - Now, passing this value has the expected behavior: the app has unauthenticated - access to the Realtime Database, and behaves as if no user is logged into the - app. - -### Realtime Database - -- [changed] Use of the - [`updateChildren()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/database/DatabaseReference.html#updateChildren(java.util.Map)) - method now only cancels transactions that are directly included in the updated - paths (not transactions in adjacent paths). For example, an update at `/move` - for a child node `walk` will cancel transactions at `/`, `/move`, and - `/move/walk` and in any child nodes under `/move/walk`. But, it will no longer - cancel transactions at sibling nodes, such as `/move/run`. - - -# v4.0.2 - -- [changed] This update restores Java 7 compatibilty for the Admin Java SDK. - - -# v4.0.1 - -- [changed] Fixed an issue with a missing dependency in the [`4.0.0`](#4.0.0) - JAR which caused the Database API to not work. -- [issue] This version was compiled for Java 8 and does not support Java 7. - This was fixed in version [`4.0.2`](#4.0.2). - - -# v4.0.0 - -- [added] The Admin Java SDK (available on Maven as `firebase-admin`) - replaces the pre-existing `firebase-server-sdk` Maven package, which is now - deprecated. See - [Add the Firebase Admin SDK to your Server](https://firebase.google.com/docs/admin/setup/) to get - started. -- [issue] This version is missing a dependency which causes the Database API - to not work. This was fixed in version [`4.0.1`](#4.0.1). -- [issue] This version was compiled for Java 8 and does not support Java 7. - This was fixed in version [`4.0.2`](#4.0.2). - -### Authentication - -- [changed] The - [`createCustomToken()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#createCustomToken(java.lang.String)) - method is now asynchronous, returning a `Task` instead of a `String`. - - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b60aba82c..907223c5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ We get lots of those and we love helping you, but GitHub is not the best place f which just ask about usage will be closed. Here are some resources to get help: - Go through the [guides](https://firebase.google.com/docs/admin/setup/) -- Read the full [API reference](https://firebase.google.com/docs/reference/admin/java/) +- Read the full [API reference](https://firebase.google.com/docs/reference/admin#java) If the official documentation doesn't help, try asking a question on the [Firebase Google Group](https://groups.google.com/forum/#!forum/firebase-talk/) or one of our @@ -47,7 +47,7 @@ Great, we love hearing how we can improve our products! Share you idea through o ## Want to submit a pull request? Sweet, we'd love to accept your contribution! -[Open a new pull request](https://github.com/firebase/firebase-admin-java/pull/new/master) and fill +[Open a new pull request](https://github.com/firebase/firebase-admin-java/pull/new) and fill out the provided template. **If you want to implement a new feature, please open an issue with a proposal first so that we can @@ -85,9 +85,7 @@ information on using pull requests. ### Initial Setup -Install Java 7 or higher. You can also use Java 8, but please note that the Firebase Admin SDK must -maintain full Java 7 compatibility. Therefore make sure that you do not use any Java 8 features -(e.g. lambdas) when writing code for the Admin Java SDK. +Install Java 8 or higher. We use [Apache Maven](http://maven.apache.org/) for building, testing and releasing the Admin Java SDK code. Follow the [installation guide](http://maven.apache.org/install.html), and install Maven @@ -129,16 +127,92 @@ mvn test Integration tests are also written using Junit4. They coexist with the unit tests in the `src/test` subdirectory. Integration tests follow the naming convention `*IT.java` (e.g. `DataTestIT.java`), which enables the Maven Surefire and Failsafe plugins to differentiate between the two types of -tests. Integration tests are executed against a real life Firebase project, and therefore -requires an Internet connection. +tests. -Create a new project in the [Firebase console](https://console.firebase.google.com/) if you do -not already have one. Use a separate, dedicated project for integration tests since the test suite -makes a large number of writes to the Firebase realtime database. Download the service account -private key from the "Settings > Service Accounts" page of the project, and save it as -`integration_cert.json` at the root of the codebase. Also obtain the web API key of the project -from the "Settings > General" page, and save it as `integration_apikey.txt` at the root of the -codebase. Now run the following command to invoke the integration test suite: +Integration tests are executed against a real life Firebase project. If you do not already +have one suitable for running the tests against, you can create a new project in the +[Firebase Console](https://console.firebase.google.com) following the setup guide below. +If you already have a Firebase project, you'll need to obtain credentials to communicate and +authorize access to your Firebase project: + +1. Service account certificate: This allows access to your Firebase project through a service account +which is required for all integration tests. This can be downloaded as a JSON file from the +**Settings > Service Accounts** tab of the Firebase console when you click the +**Generate new private key** button. Copy the file into the repo so it's available at +`integration_cert.json`. + > **Note:** Service accounts should be carefully managed and their keys should never be stored in publicly accessible source code or repositories. + + +2. Web API key: This allows for Auth sign-in needed for some Authentication and Tenant Management +integration tests. This is displayed in the **Settings > General** tab of the Firebase console +after enabling Authentication as described in the steps below. Copy it and save to a new text +file at `integration_apikey.txt`. + + +Set up your Firebase project as follows: + + +1. Enable Authentication: + 1. Go to the Firebase Console, and select **Authentication** from the **Build** menu. + 2. Click on **Get Started**. + 3. Select **Sign-in method > Add new provider > Email/Password** then enable both the + **Email/Password** and **Email link (passwordless sign-in)** options. + + +2. Enable Firestore: + 1. Go to the Firebase Console, and select **Firestore Database** from the **Build** menu. + 2. Click on the **Create database** button. You can choose to set up Firestore either in + the production mode or in the test mode. + + +3. Enable Realtime Database: + 1. Go to the Firebase Console, and select **Realtime Database** from the **Build** menu. + 2. Click on the **Create Database** button. You can choose to set up the Realtime Database + either in the locked mode or in the test mode. + + > **Note:** Integration tests are not run against the default Realtime Database reference and are + instead run against a database created at `https://{PROJECT_ID}.firebaseio.com`. + This second Realtime Database reference is created in the following steps. + + 3. In the **Data** tab click on the kebab menu (3 dots) and select **Create Database**. + 4. Enter your Project ID (Found in the **General** tab in **Account Settings**) as the + **Realtime Database reference**. Again, you can choose to set up the Realtime Database + either in the locked mode or in the test mode. + + +4. Enable Storage: + 1. Go to the Firebase Console, and select **Storage** from the **Build** menu. + 2. Click on the **Get started** button. You can choose to set up Cloud Storage + either in the production mode or in the test mode. + + +5. Enable the IAM API: + 1. Go to the [Google Cloud console](https://console.cloud.google.com) + and make sure your Firebase project is selected. + 2. Select **APIs & Services** from the main menu, and click the + **ENABLE APIS AND SERVICES** button. + 3. Search for and enable **Identity and Access Management (IAM) API** by Google Enterprise API. + + +6. Enable Tenant Management: + 1. Go to + [Google Cloud console | Identity Platform](https://console.cloud.google.com/customer-identity/) + and if it is not already enabled, click **Enable**. + 2. Then + [enable multi-tenancy](https://cloud.google.com/identity-platform/docs/multi-tenancy-quickstart#enabling_multi-tenancy) + for your project. + + +7. Ensure your service account has the **Firebase Authentication Admin** role. This is required +to ensure that exported user records contain the password hashes of the user accounts: + 1. Go to [Google Cloud console | IAM & admin](https://console.cloud.google.com/iam-admin). + 2. Find your service account in the list. If not added click the pencil icon to edit its + permissions. + 3. Click **ADD ANOTHER ROLE** and choose **Firebase Authentication Admin**. + 4. Click **SAVE**. + + +Now run the following command to invoke the integration test suite: ``` mvn verify @@ -149,14 +223,14 @@ tests, specify the `-DskipUTs` flag. ### Generating API Docs -Invoke the [Maven Javadoc plugin](https://maven.apache.org/plugins/maven-javadoc-plugin/) as +Invoke the [Maven Javadoc plugin](https://maven.apache.org/plugins/maven-javadoc-plugin/) as follows to generate API docs for all packages in the codebase: ``` mvn javadoc:javadoc ``` -This will generate the API docs, and place them in the `target/site/apidocs` directory. +This will generate the API docs, and place them in the `target/site/apidocs` directory. To generate API docs for only the public APIs (i.e. ones that are not marked with `@hide` tags), you need to trigger the `devsite-apidocs` Maven profile. This profile uses Maven Javadoc plugin diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 000000000..f876712e4 --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,5 @@ +Firebase Admin Java SDK +Copyright 2019 Google LLC + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). diff --git a/README.md b/README.md index 65a80bed0..1ad5352e5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://travis-ci.org/firebase/firebase-admin-java.svg?branch=master)](https://travis-ci.org/firebase/firebase-admin-java) +[![Build Status](https://github.com/firebase/firebase-admin-java/workflows/Continuous%20Integration/badge.svg)](https://github.com/firebase/firebase-admin-java/actions) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.google.firebase/firebase-admin/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.google.firebase/firebase-admin) [![Javadoc](https://javadoc-badge.appspot.com/com.google.firebase/firebase-admin.svg)](https://firebase.google.com/docs/reference/admin/java/reference/packages) @@ -46,9 +46,21 @@ requests, code review feedback, and also pull requests. ## Supported Java Versions -We support Java 7 and higher. Firebase Admin Java SDK also runs on [Google App +We currently support Java 8 and higher. The Firebase Admin Java SDK also runs on [Google App Engine](https://cloud.google.com/appengine/). +The Firebase Admin Java SDK follows the [Oracle Java SE +support roadmap](https://www.oracle.com/java/technologies/java-se-support-roadmap.html) +(see the Oracle Java SE Product Releases section). + +### For new development + +In general, new feature development occurs with support for the lowest Java LTS version +covered by Oracle's Premier Support (which typically lasts 5 years from initial General +Availability). If the minimum required JVM for a given library is changed, it is +accompanied by a [semver](https://semver.org/) major release. + +Java 11 and Java 17 are the best choices for new development. ## Documentation diff --git a/checkstyle.xml b/checkstyle.xml index 663918173..c2a35946a 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -1,6 +1,6 @@ + + diff --git a/pom.xml b/pom.xml index acc0b426f..f84daffa4 100644 --- a/pom.xml +++ b/pom.xml @@ -1,5 +1,5 @@ - disable-java8-doclint - - [1.8,) - - - -Xdoclint:none - - devsite-apidocs @@ -99,6 +84,7 @@ maven-javadoc-plugin + 3.12.0 site @@ -122,24 +108,25 @@ 1.3 - - -hdf book.path /_book.yaml - -hdf project.path /_project.yaml - -hdf devsite.path /docs/reference/admin/java/reference/ - -d ${project.build.directory}/apidocs - -templatedir ${devsite.template} - -toroot /docs/reference/admin/java/reference/ - -yaml _toc.yaml - -warning 101 - + + -hdf book.path /docs/reference/_book.yaml + -hdf project.path /_project.yaml + -hdf devsite.path /docs/reference/admin/java/reference/ + -d ${project.build.directory}/apidocs + -templatedir ${devsite.template} + -toroot /docs/reference/admin/java/reference/ + -yaml _toc.yaml + -warning 101 + false -J-Xmx1024m + none maven-antrun-plugin - 1.7 + 3.2.0 site @@ -162,58 +149,13 @@ release - true - - maven-javadoc-plugin - - - package - - jar - - - - - - com.google.doclava - doclava - 1.0.6 - - com.google.doclava.Doclava - ${sun.boot.class.path} - - - com.google.j2objc - j2objc-annotations - 1.3 - - - - -warning 101 - - false - -J-Xmx1024m - - - - maven-source-plugin - 2.2.1 - - - attach-sources - - jar-no-fork - - - - maven-gpg-plugin - 1.5 + 3.2.8 sign-artifacts @@ -221,6 +163,12 @@ sign + + + --pinentry-mode + loopback + + @@ -239,7 +187,7 @@ org.jacoco jacoco-maven-plugin - 0.7.9 + 0.8.15 pre-unit-test @@ -265,7 +213,7 @@ org.codehaus.mojo exec-maven-plugin - 1.6.0 + 3.6.3 test @@ -295,6 +243,7 @@ + maven-checkstyle-plugin 2.17 @@ -315,120 +264,172 @@ + + maven-compiler-plugin - 3.6.1 + 3.16.0 - 1.7 - 1.7 + 1.8 + 1.8 + + maven-surefire-plugin - 2.19.1 + 3.6.0 ${skipUTs} + + - maven-failsafe-plugin - 2.19.1 + maven-source-plugin + 3.4.0 + attach-sources - integration-test - verify + jar-no-fork - + maven-javadoc-plugin - 2.10.4 - - - maven-release-plugin - 2.5.3 + 3.12.0 + + + attach-javadocs + + jar + + + - false - release - v@{project.version} - deploy + + com.google.doclava + doclava + 1.0.6 + + com.google.doclava.Doclava + ${sun.boot.class.path} + + + com.google.j2objc + j2objc-annotations + 1.3 + + + + -warning 101 + + false + -J-Xmx1024m + none + + - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.7 + maven-failsafe-plugin + 3.6.0 + + + + integration-test + verify + + + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.11.0 true - ossrh - https://oss.sonatype.org/ - false + central + true + published + + + + maven-project-info-reports-plugin + 3.9.0 + + + + + true + + + + + + + + + + + com.google.cloud + libraries-bom + 26.88.1 + pom + import + + + + + + com.google.auth + google-auth-library-oauth2-http + com.google.api-client google-api-client - 1.25.0 - - - com.google.guava - guava-jdk5 - - com.google.api-client google-api-client-gson - 1.25.0 com.google.http-client google-http-client - 1.25.0 com.google.api api-common - 1.7.0 - - - com.google.auth - google-auth-library-oauth2-http - 0.11.0 com.google.cloud google-cloud-storage - 1.43.0 com.google.cloud google-cloud-firestore - 0.61.0-beta com.google.guava guava - 20.0 - - - org.json - json - 20160810 org.slf4j slf4j-api - 1.7.25 + 2.0.20 io.netty @@ -445,12 +446,22 @@ netty-transport ${netty.version} + + org.apache.httpcomponents.client5 + httpclient5 + 5.6.4 + + + com.nimbusds + nimbus-jose-jwt + 10.10 + org.mockito mockito-core - 2.7.21 + 4.11.0 test @@ -459,23 +470,23 @@ 0.6 test - - com.cedarsoftware - java-util - 1.26.0 - test - junit junit - 4.12 + 4.13.2 test javax.ws.rs javax.ws.rs-api - 2.0 + 2.1.1 + test + + + org.hamcrest + hamcrest + 3.0 test diff --git a/prepare_release.sh b/prepare_release.sh index cc5969e0c..c30d3dbe2 100755 --- a/prepare_release.sh +++ b/prepare_release.sh @@ -1,4 +1,4 @@ -# Copyright 2018 Google Inc. +# Copyright 2018 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -66,28 +66,13 @@ if [[ $(git status --porcelain) ]]; then #exit 1 fi -echo "[INFO] Updating the master branch" -git checkout master -git pull origin master +echo "[INFO] Updating the main branch" +git fetch origin main TIMESTAMP=$(date +%s) RELEASE_BRANCH="release-${TIMESTAMP}" echo "[INFO] Creating new release branch: ${RELEASE_BRANCH}" -git checkout -b ${RELEASE_BRANCH} master - -HOST=$(uname) -echo "[INFO] Updating CHANGELOG.md" -if [ $HOST == "Darwin" ]; then - sed -i "" -e "1 s/# Unreleased//" "CHANGELOG.md" -else - sed -i -e "/# Unreleased/d" "CHANGELOG.md" -fi - -echo -e "# Unreleased\n\n-\n\n# v${VERSION}" | cat - CHANGELOG.md > TEMP_CHANGELOG.md -mv TEMP_CHANGELOG.md CHANGELOG.md -git add CHANGELOG.md -git commit -m "Updating CHANGELOG for ${VERSION} release." -git push origin ${RELEASE_BRANCH} +git checkout -b ${RELEASE_BRANCH} origin/main ################################# diff --git a/src/main/java/com/google/firebase/ErrorCode.java b/src/main/java/com/google/firebase/ErrorCode.java new file mode 100644 index 000000000..2eda54054 --- /dev/null +++ b/src/main/java/com/google/firebase/ErrorCode.java @@ -0,0 +1,109 @@ +/* + * Copyright 2020 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; + +/** + * Platform-wide error codes that can be raised by Admin SDK APIs. + */ +public enum ErrorCode { + + /** + * Client specified an invalid argument. + */ + INVALID_ARGUMENT, + + /** + * Request cannot be executed in the current system state, such as deleting a non-empty + * directory. + */ + FAILED_PRECONDITION, + + /** + * Client specified an invalid range. + */ + OUT_OF_RANGE, + + /** + * Request not authenticated due to missing, invalid, or expired OAuth token. + */ + UNAUTHENTICATED, + + /** + * Client does not have sufficient permission. This can happen because the OAuth token does + * not have the right scopes, the client doesn't have permission, or the API has not been + * enabled for the client project. + */ + PERMISSION_DENIED, + + /** + * A specified resource is not found, or the request is rejected for unknown reasons, + * such as a blocked network address. + */ + NOT_FOUND, + + /** + * Concurrency conflict, such as read-modify-write conflict. + */ + CONFLICT, + + /** + * Concurrency conflict, such as read-modify-write conflict. + */ + ABORTED, + + /** + * The resource that a client tried to create already exists. + */ + ALREADY_EXISTS, + + /** + * Either out of resource quota or rate limited. + */ + RESOURCE_EXHAUSTED, + + /** + * Request cancelled by the client. + */ + CANCELLED, + + /** + * Unrecoverable data loss or data corruption. The client should report the error to the user. + */ + DATA_LOSS, + + /** + * Unknown server error. Typically a server bug. + */ + UNKNOWN, + + /** + * Internal server error. Typically a server bug. + */ + INTERNAL, + + /** + * Service unavailable. Typically the server is down. + */ + UNAVAILABLE, + + /** + * Request deadline exceeded. This happens only if the caller sets a deadline that is + * shorter than the method's default deadline (i.e. requested deadline is not enough for the + * server to process the request) and the request did not finish within the deadline. + */ + DEADLINE_EXCEEDED, +} diff --git a/src/main/java/com/google/firebase/FirebaseApp.java b/src/main/java/com/google/firebase/FirebaseApp.java index 59a79e465..726e2d1bd 100644 --- a/src/main/java/com/google/firebase/FirebaseApp.java +++ b/src/main/java/com/google/firebase/FirebaseApp.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,8 +19,8 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; +import static com.google.firebase.FirebaseOptions.APPLICATION_DEFAULT_CREDENTIALS; -import com.google.api.client.googleapis.util.Utils; import com.google.api.client.json.JsonFactory; import com.google.api.client.json.JsonParser; import com.google.api.core.ApiFuture; @@ -34,7 +34,8 @@ import com.google.common.base.MoreObjects; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; -import com.google.firebase.internal.FirebaseAppStore; +import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.FirebaseProcessEnvironment; import com.google.firebase.internal.FirebaseScheduledExecutor; import com.google.firebase.internal.FirebaseService; import com.google.firebase.internal.ListenableFuture2ApiFuture; @@ -46,10 +47,8 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; @@ -119,7 +118,6 @@ private FirebaseApp(String name, FirebaseOptions options, TokenRefresher.Factory /** Returns a list of all FirebaseApps. */ public static List getApps() { - // TODO: reenable persistence. See b/28158809. synchronized (appsLock) { return ImmutableList.copyOf(instances.values()); } @@ -130,7 +128,6 @@ public static List getApps() { * * @throws IllegalStateException if the default app was not initialized. */ - @Nullable public static FirebaseApp getInstance() { return getInstance(DEFAULT_APP_NAME); } @@ -220,21 +217,16 @@ public static FirebaseApp initializeApp(FirebaseOptions options, String name) { static FirebaseApp initializeApp(FirebaseOptions options, String name, TokenRefresher.Factory tokenRefresherFactory) { - FirebaseAppStore appStore = FirebaseAppStore.initialize(); String normalizedName = normalize(name); - final FirebaseApp firebaseApp; synchronized (appsLock) { checkState( !instances.containsKey(normalizedName), "FirebaseApp name " + normalizedName + " already exists!"); - firebaseApp = new FirebaseApp(normalizedName, options, tokenRefresherFactory); + FirebaseApp firebaseApp = new FirebaseApp(normalizedName, options, tokenRefresherFactory); instances.put(normalizedName, firebaseApp); + return firebaseApp; } - - appStore.persistApp(firebaseApp); - - return firebaseApp; } @VisibleForTesting @@ -250,19 +242,13 @@ static void clearInstancesForTest() { } private static List getAllAppNames() { - Set allAppNames = new HashSet<>(); + List allAppNames; synchronized (appsLock) { - for (FirebaseApp app : instances.values()) { - allAppNames.add(app.getName()); - } - FirebaseAppStore appStore = FirebaseAppStore.getInstance(); - if (appStore != null) { - allAppNames.addAll(appStore.getAllPersistedAppNames()); - } + allAppNames = new ArrayList<>(instances.keySet()); } - List sortedNameList = new ArrayList<>(allAppNames); - Collections.sort(sortedNameList); - return sortedNameList; + + Collections.sort(allAppNames); + return ImmutableList.copyOf(allAppNames); } /** Normalizes the app name. */ @@ -276,8 +262,8 @@ public String getName() { return name; } - /** - * Returns the specified {@link FirebaseOptions}. + /** + * Returns the specified {@link FirebaseOptions}. */ @NonNull public FirebaseOptions getOptions() { @@ -292,6 +278,8 @@ public FirebaseOptions getOptions() { */ @Nullable String getProjectId() { + checkNotDeleted(); + // Try to get project ID from user-specified options. String projectId = options.getProjectId(); @@ -305,10 +293,10 @@ String getProjectId() { // Try to get project ID from the environment. if (Strings.isNullOrEmpty(projectId)) { - projectId = System.getenv("GOOGLE_CLOUD_PROJECT"); + projectId = FirebaseProcessEnvironment.getenv("GOOGLE_CLOUD_PROJECT"); } if (Strings.isNullOrEmpty(projectId)) { - projectId = System.getenv("GCLOUD_PROJECT"); + projectId = FirebaseProcessEnvironment.getenv("GCLOUD_PROJECT"); } return projectId; } @@ -329,8 +317,10 @@ public String toString() { } /** - * Deletes the {@link FirebaseApp} and all its data. All calls to this {@link FirebaseApp} - * instance will throw once it has been called. + * Deletes this {@link FirebaseApp} object, and releases any local state and managed resources + * associated with it. All calls to this {@link FirebaseApp} instance will throw once this method + * has been called. This also releases any managed resources allocated by other services + * attached to this object instance (e.g. {@code FirebaseAuth}). * *

A no-op if delete was called before. */ @@ -358,11 +348,6 @@ public void delete() { synchronized (appsLock) { instances.remove(name); } - - FirebaseAppStore appStore = FirebaseAppStore.getInstance(); - if (appStore != null) { - appStore.removeApp(name); - } } private void checkNotDeleted() { @@ -390,6 +375,10 @@ ThreadFactory getThreadFactory() { return threadManager.getThreadFactory(); } + ScheduledExecutorService getScheduledExecutorService() { + return ensureScheduledExecutorService(); + } + ApiFuture submit(Callable command) { checkNotNull(command); return new ListenableFuture2ApiFuture<>(executors.getListeningExecutor().submit(command)); @@ -405,6 +394,17 @@ ScheduledFuture schedule(Callable command, long delayMillis) { } } + ScheduledFuture schedule(Runnable runnable, long delayMillis) { + checkNotNull(runnable); + try { + return ensureScheduledExecutorService() + .schedule(runnable, delayMillis, TimeUnit.MILLISECONDS); + } catch (Exception e) { + // This may fail if the underlying ThreadFactory does not support long-lived threads. + throw new UnsupportedOperationException("Scheduled tasks not supported", e); + } + } + void startTokenRefresher() { synchronized (lock) { checkNotDeleted(); @@ -564,25 +564,23 @@ enum State { } private static FirebaseOptions getOptionsFromEnvironment() throws IOException { - String defaultConfig = System.getenv(FIREBASE_CONFIG_ENV_VAR); + String defaultConfig = FirebaseProcessEnvironment.getenv(FIREBASE_CONFIG_ENV_VAR); if (Strings.isNullOrEmpty(defaultConfig)) { - return new FirebaseOptions.Builder() - .setCredentials(GoogleCredentials.getApplicationDefault()) - .build(); + return FirebaseOptions.builder() + .setCredentials(APPLICATION_DEFAULT_CREDENTIALS) + .build(); } - - JsonFactory jsonFactory = Utils.getDefaultJsonFactory(); - FirebaseOptions.Builder builder = new FirebaseOptions.Builder(); + JsonFactory jsonFactory = ApiClientUtils.getDefaultJsonFactory(); + FirebaseOptions.Builder builder = FirebaseOptions.builder(); JsonParser parser; if (defaultConfig.startsWith("{")) { parser = jsonFactory.createJsonParser(defaultConfig); } else { - FileReader reader; - reader = new FileReader(defaultConfig); + FileReader reader = new FileReader(defaultConfig); parser = jsonFactory.createJsonParser(reader); } parser.parseAndClose(builder); - builder.setCredentials(GoogleCredentials.getApplicationDefault()); + builder.setCredentials(APPLICATION_DEFAULT_CREDENTIALS); return builder.build(); } } diff --git a/src/main/java/com/google/firebase/FirebaseAppLifecycleListener.java b/src/main/java/com/google/firebase/FirebaseAppLifecycleListener.java index 60118c249..b347bfe95 100644 --- a/src/main/java/com/google/firebase/FirebaseAppLifecycleListener.java +++ b/src/main/java/com/google/firebase/FirebaseAppLifecycleListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,7 @@ /** * A listener which gets notified when {@link com.google.firebase.FirebaseApp} gets deleted. */ -// TODO: consider making it public in a future release. +@Deprecated interface FirebaseAppLifecycleListener { /** diff --git a/src/main/java/com/google/firebase/FirebaseException.java b/src/main/java/com/google/firebase/FirebaseException.java index f78b3fb98..ce4325150 100644 --- a/src/main/java/com/google/firebase/FirebaseException.java +++ b/src/main/java/com/google/firebase/FirebaseException.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,24 +17,55 @@ package com.google.firebase; import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.base.Strings; import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; -/** Base class for all Firebase exceptions. */ +/** + * Base class for all Firebase exceptions. + */ public class FirebaseException extends Exception { - // TODO(b/27677218): Exceptions should have non-empty messages. - @Deprecated - protected FirebaseException() {} + private final ErrorCode errorCode; + private final IncomingHttpResponse httpResponse; + + public FirebaseException( + @NonNull ErrorCode errorCode, + @NonNull String message, + @Nullable Throwable cause, + @Nullable IncomingHttpResponse httpResponse) { + super(message, cause); + checkArgument(!Strings.isNullOrEmpty(message), "Message must not be null or empty"); + this.errorCode = checkNotNull(errorCode, "ErrorCode must not be null"); + this.httpResponse = httpResponse; + } + + public FirebaseException( + @NonNull ErrorCode errorCode, + @NonNull String message, + @Nullable Throwable cause) { + this(errorCode, message, cause, null); + } - public FirebaseException(@NonNull String detailMessage) { - super(detailMessage); - checkArgument(!Strings.isNullOrEmpty(detailMessage), "Detail message must not be empty"); + /** + * Returns the platform-wide error code associated with this exception. + * + * @return A Firebase error code. + */ + public final ErrorCode getErrorCode() { + return errorCode; } - public FirebaseException(@NonNull String detailMessage, Throwable cause) { - super(detailMessage, cause); - checkArgument(!Strings.isNullOrEmpty(detailMessage), "Detail message must not be empty"); + /** + * Returns the HTTP response that resulted in this exception. If the exception was not caused by + * an HTTP error response, returns null. + * + * @return An HTTP response or null. + */ + @Nullable + public final IncomingHttpResponse getHttpResponse() { + return httpResponse; } } diff --git a/src/main/java/com/google/firebase/FirebaseOptions.java b/src/main/java/com/google/firebase/FirebaseOptions.java index 99b4e1c5a..a994b98c2 100644 --- a/src/main/java/com/google/firebase/FirebaseOptions.java +++ b/src/main/java/com/google/firebase/FirebaseOptions.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,18 +19,22 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; -import com.google.api.client.googleapis.util.Utils; import com.google.api.client.http.HttpTransport; import com.google.api.client.json.JsonFactory; import com.google.api.client.util.Key; import com.google.auth.oauth2.GoogleCredentials; import com.google.cloud.firestore.FirestoreOptions; import com.google.common.base.Strings; +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; import com.google.common.collect.ImmutableList; +import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.ApplicationDefaultCredentialsProvider; import com.google.firebase.internal.FirebaseThreadManagers; import com.google.firebase.internal.NonNull; import com.google.firebase.internal.Nullable; +import java.io.IOException; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -56,24 +60,37 @@ public final class FirebaseOptions { "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/datastore"); + static final Supplier APPLICATION_DEFAULT_CREDENTIALS = + new Supplier() { + @Override + public GoogleCredentials get() { + try { + return ApplicationDefaultCredentialsProvider.getApplicationDefault() + .createScoped(FIREBASE_SCOPES); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + }; + private final String databaseUrl; private final String storageBucket; - private final GoogleCredentials credentials; + private final Supplier credentialsSupplier; private final Map databaseAuthVariableOverride; private final String projectId; private final String serviceAccountId; private final HttpTransport httpTransport; private final int connectTimeout; private final int readTimeout; + private final int writeTimeout; private final JsonFactory jsonFactory; private final ThreadManager threadManager; private final FirestoreOptions firestoreOptions; - private FirebaseOptions(@NonNull FirebaseOptions.Builder builder) { - this.credentials = checkNotNull(builder.credentials, - "FirebaseOptions must be initialized with setCredentials().") - .createScoped(FIREBASE_SCOPES); + private FirebaseOptions(@NonNull final FirebaseOptions.Builder builder) { this.databaseUrl = builder.databaseUrl; + this.credentialsSupplier = checkNotNull( + builder.credentialsSupplier, "FirebaseOptions must be initialized with setCredentials()."); this.databaseAuthVariableOverride = builder.databaseAuthVariableOverride; this.projectId = builder.projectId; if (!Strings.isNullOrEmpty(builder.storageBucket)) { @@ -86,16 +103,18 @@ private FirebaseOptions(@NonNull FirebaseOptions.Builder builder) { this.serviceAccountId = null; } this.storageBucket = builder.storageBucket; - this.httpTransport = checkNotNull(builder.httpTransport, - "FirebaseOptions must be initialized with a non-null HttpTransport."); - this.jsonFactory = checkNotNull(builder.jsonFactory, - "FirebaseOptions must be initialized with a non-null JsonFactory."); - this.threadManager = checkNotNull(builder.threadManager, - "FirebaseOptions must be initialized with a non-null ThreadManager."); + this.httpTransport = builder.httpTransport != null ? builder.httpTransport + : ApiClientUtils.getDefaultTransport(); + this.jsonFactory = builder.jsonFactory != null ? builder.jsonFactory + : ApiClientUtils.getDefaultJsonFactory(); + this.threadManager = builder.threadManager != null ? builder.threadManager + : FirebaseThreadManagers.DEFAULT_THREAD_MANAGER; checkArgument(builder.connectTimeout >= 0); this.connectTimeout = builder.connectTimeout; checkArgument(builder.readTimeout >= 0); this.readTimeout = builder.readTimeout; + checkArgument(builder.writeTimeout >= 0); + this.writeTimeout = builder.writeTimeout; this.firestoreOptions = builder.firestoreOptions; } @@ -118,7 +137,7 @@ public String getStorageBucket() { } GoogleCredentials getCredentials() { - return credentials; + return credentialsSupplier.get(); } /** @@ -182,8 +201,7 @@ public int getConnectTimeout() { } /** - * Returns the read timeout in milliseconds, which is applied to outgoing REST calls - * made by the SDK. + * Returns the read timeout applied to outgoing REST calls in milliseconds. * * @return Read timeout in milliseconds. 0 indicates an infinite timeout. */ @@ -191,6 +209,15 @@ public int getReadTimeout() { return readTimeout; } + /** + * Returns the write timeout applied to outgoing REST calls in milliseconds. + * + * @return Write timeout in milliseconds. 0 indicates an infinite timeout. + */ + public int getWriteTimeout() { + return writeTimeout; + } + @NonNull ThreadManager getThreadManager() { return threadManager; @@ -210,33 +237,48 @@ public static Builder builder() { } /** - * Builder for constructing {@link FirebaseOptions}. + * Creates a new {@code Builder} from the options object. + * + *

The new builder is not backed by this object's values; that is, changes made to the new + * builder don't change the values of the origin object. + */ + public Builder toBuilder() { + return new Builder(this); + } + + /** + * Builder for constructing {@link FirebaseOptions}. */ public static final class Builder { @Key("databaseAuthVariableOverride") private Map databaseAuthVariableOverride = new HashMap<>(); - + @Key("databaseUrl") private String databaseUrl; @Key("projectId") private String projectId; - + @Key("storageBucket") private String storageBucket; @Key("serviceAccountId") private String serviceAccountId; - - private GoogleCredentials credentials; + private Supplier credentialsSupplier; private FirestoreOptions firestoreOptions; - private HttpTransport httpTransport = Utils.getDefaultTransport(); - private JsonFactory jsonFactory = Utils.getDefaultJsonFactory(); - private ThreadManager threadManager = FirebaseThreadManagers.DEFAULT_THREAD_MANAGER; + private HttpTransport httpTransport; + private JsonFactory jsonFactory; + private ThreadManager threadManager; private int connectTimeout; private int readTimeout; + private int writeTimeout; - /** Constructs an empty builder. */ + /** + * Constructs an empty builder. + * + * @deprecated Use {@link FirebaseOptions#builder()} instead. + */ + @Deprecated public Builder() {} /** @@ -244,11 +286,14 @@ public Builder() {} * *

The new builder is not backed by this object's values, that is changes made to the new * builder don't change the values of the origin object. + * + * @deprecated Use {@link FirebaseOptions#toBuilder()} instead. */ + @Deprecated public Builder(FirebaseOptions options) { databaseUrl = options.databaseUrl; storageBucket = options.storageBucket; - credentials = options.credentials; + credentialsSupplier = options.credentialsSupplier; databaseAuthVariableOverride = options.databaseAuthVariableOverride; projectId = options.projectId; httpTransport = options.httpTransport; @@ -256,6 +301,7 @@ public Builder(FirebaseOptions options) { threadManager = options.threadManager; connectTimeout = options.connectTimeout; readTimeout = options.readTimeout; + writeTimeout = options.writeTimeout; firestoreOptions = options.firestoreOptions; } @@ -296,18 +342,32 @@ public Builder setStorageBucket(String storageBucket) { } /** - * Sets the GoogleCredentials to use to authenticate the SDK. + * Sets the GoogleCredentials to use to authenticate the SDK. This parameter + * must be specified when creating a new instance of {@link FirebaseOptions}. * *

See * Initialize the SDK for code samples and detailed documentation. * * @param credentials A - * {@code GoogleCredentials} + * {@code GoogleCredentials} * instance used to authenticate the SDK. * @return This Builder instance is returned so subsequent calls can be chained. */ public Builder setCredentials(GoogleCredentials credentials) { - this.credentials = checkNotNull(credentials); + this.credentialsSupplier = Suppliers + .ofInstance(checkNotNull(credentials).createScoped(FIREBASE_SCOPES)); + return this; + } + + /** + * Sets the Supplier of GoogleCredentials to use to authenticate the + * SDK. This is NOT intended for public use outside the SDK. + * + * @param credentialsSupplier Supplier instance that wraps GoogleCredentials. + * @return This Builder instance is returned so subsequent calls can be chained. + */ + Builder setCredentials(Supplier credentialsSupplier) { + this.credentialsSupplier = checkNotNull(credentialsSupplier); return this; } @@ -374,7 +434,8 @@ public Builder setServiceAccountId(@NonNull String serviceAccountId) { * @return This Builder instance is returned so subsequent calls can be chained. */ public Builder setHttpTransport(HttpTransport httpTransport) { - this.httpTransport = httpTransport; + this.httpTransport = checkNotNull(httpTransport, + "FirebaseOptions must be initialized with a non-null HttpTransport."); return this; } @@ -386,7 +447,8 @@ public Builder setHttpTransport(HttpTransport httpTransport) { * @return This Builder instance is returned so subsequent calls can be chained. */ public Builder setJsonFactory(JsonFactory jsonFactory) { - this.jsonFactory = jsonFactory; + this.jsonFactory = checkNotNull(jsonFactory, + "FirebaseOptions must be initialized with a non-null JsonFactory."); return this; } @@ -398,7 +460,8 @@ public Builder setJsonFactory(JsonFactory jsonFactory) { * @return This Builder instance is returned so subsequent calls can be chained. */ public Builder setThreadManager(ThreadManager threadManager) { - this.threadManager = threadManager; + this.threadManager = checkNotNull(threadManager, + "FirebaseOptions must be initialized with a non-null ThreadManager."); return this; } @@ -445,6 +508,19 @@ public Builder setReadTimeout(int readTimeout) { return this; } + /** + * Sets the write timeout for outgoing HTTP (REST) calls made by the SDK. This does not affect + * the {@link com.google.firebase.database.FirebaseDatabase} and + * {@link com.google.firebase.cloud.FirestoreClient} APIs. + * + * @param writeTimeout Write timeout in milliseconds. Must not be negative. + * @return This Builder instance is returned so subsequent calls can be chained. + */ + public Builder setWriteTimeout(int writeTimeout) { + this.writeTimeout = writeTimeout; + return this; + } + /** * Builds the {@link FirebaseOptions} instance from the previously set options. * diff --git a/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java b/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java index 4b56e838c..3dff2764c 100644 --- a/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java +++ b/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,13 +16,19 @@ package com.google.firebase; +import com.google.api.core.ApiAsyncFunction; +import com.google.api.core.ApiFunction; import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; import com.google.auth.oauth2.GoogleCredentials; import com.google.cloud.firestore.FirestoreOptions; +import com.google.firebase.auth.internal.Utils; +import com.google.firebase.internal.EmulatorCredentials; import com.google.firebase.internal.FirebaseService; import com.google.firebase.internal.NonNull; import java.util.concurrent.Callable; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadFactory; /** @@ -37,6 +43,9 @@ public final class ImplFirebaseTrampolines { private ImplFirebaseTrampolines() {} public static GoogleCredentials getCredentials(@NonNull FirebaseApp app) { + if (Utils.isEmulatorMode()) { + return new EmulatorCredentials(); + } return app.getOptions().getCredentials(); } @@ -67,6 +76,23 @@ public static ThreadFactory getThreadFactory(@NonNull FirebaseApp app) { return app.getThreadFactory(); } + public static ApiFuture transform( + ApiFuture input, + final ApiFunction function, + @NonNull FirebaseApp app) { + return ApiFutures.transform(input, function, app.getScheduledExecutorService()); + } + + public static ApiFuture transformAsync( + ApiFuture input, final ApiAsyncFunction function, @NonNull FirebaseApp app) { + return ApiFutures.transformAsync(input, function, app.getScheduledExecutorService()); + } + + public static ScheduledFuture schedule( + @NonNull FirebaseApp app, @NonNull Runnable runnable, long delayMillis) { + return app.schedule(runnable, delayMillis); + } + public static ApiFuture submitCallable( @NonNull FirebaseApp app, @NonNull Callable command) { return app.submit(command); diff --git a/src/main/java/com/google/firebase/IncomingHttpResponse.java b/src/main/java/com/google/firebase/IncomingHttpResponse.java new file mode 100644 index 000000000..794479040 --- /dev/null +++ b/src/main/java/com/google/firebase/IncomingHttpResponse.java @@ -0,0 +1,114 @@ +/* + * Copyright 2020 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; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpResponseException; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.database.annotations.Nullable; +import java.util.Map; + +/** + * Contains information that describes an HTTP response received by the SDK. + */ +public final class IncomingHttpResponse { + + private final int statusCode; + private final String content; + private final Map headers; + private final OutgoingHttpRequest request; + + /** + * Creates an {@code IncomingHttpResponse} from a successful response and the content read + * from it. The caller is expected to read the content from the response, and handle any errors + * that may occur while reading. + * + * @param response A successful response. + * @param content Content read from the response. + */ + public IncomingHttpResponse(HttpResponse response, @Nullable String content) { + checkNotNull(response, "response must not be null"); + this.statusCode = response.getStatusCode(); + this.content = content; + this.headers = ImmutableMap.copyOf(response.getHeaders()); + this.request = new OutgoingHttpRequest(response.getRequest()); + } + + /** + * Creates an {@code IncomingHttpResponse} from an HTTP error response. + * + * @param e The exception representing the HTTP error response. + * @param request The request that resulted in the error. + */ + public IncomingHttpResponse(HttpResponseException e, HttpRequest request) { + this(e, new OutgoingHttpRequest(request)); + } + + /** + * Creates an {@code IncomingHttpResponse} from an HTTP error response. + * + * @param e The exception representing the HTTP error response. + * @param request The request that resulted in the error. + */ + public IncomingHttpResponse(HttpResponseException e, OutgoingHttpRequest request) { + checkNotNull(e, "exception must not be null"); + this.statusCode = e.getStatusCode(); + this.content = e.getContent(); + this.headers = ImmutableMap.copyOf(e.getHeaders()); + this.request = checkNotNull(request, "request must not be null"); + } + + /** + * Returns the status code of the response. + * + * @return An HTTP status code (e.g. 500). + */ + public int getStatusCode() { + return this.statusCode; + } + + /** + * Returns the content of the response as a string. + * + * @return HTTP content or null. + */ + @Nullable + public String getContent() { + return this.content; + } + + /** + * Returns the headers set on the response. + * + * @return An immutable map of headers (possibly empty). + */ + public Map getHeaders() { + return this.headers; + } + + /** + * Returns the request that resulted in this response. + * + * @return An HTTP request. + */ + public OutgoingHttpRequest getRequest() { + return request; + } +} diff --git a/src/main/java/com/google/firebase/OutgoingHttpRequest.java b/src/main/java/com/google/firebase/OutgoingHttpRequest.java new file mode 100644 index 000000000..fdaee7458 --- /dev/null +++ b/src/main/java/com/google/firebase/OutgoingHttpRequest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2020 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; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpContent; +import com.google.api.client.http.HttpRequest; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.internal.Nullable; +import java.util.Map; + +/** + * Contains the information that describe an HTTP request made by the SDK. + */ +public final class OutgoingHttpRequest { + + private final String method; + private final String url; + private final HttpContent content; + private final Map headers; + + /** + * Creates an {@code OutgoingHttpRequest} from the HTTP method and URL. + * + * @param method HTTP method name. + * @param url Target HTTP URL of the request. + */ + public OutgoingHttpRequest(String method, String url) { + checkArgument(!Strings.isNullOrEmpty(method), "method must not be null or empty"); + checkArgument(!Strings.isNullOrEmpty(url), "url must not be empty"); + this.method = method; + this.url = url; + this.content = null; + this.headers = ImmutableMap.of(); + } + + OutgoingHttpRequest(HttpRequest request) { + checkNotNull(request, "request must not be null"); + this.method = request.getRequestMethod(); + this.url = request.getUrl().toString(); + this.content = request.getContent(); + this.headers = ImmutableMap.copyOf(request.getHeaders()); + } + + /** + * Returns the HTTP method of the request. + * + * @return An HTTP method string (e.g. GET). + */ + public String getMethod() { + return method; + } + + /** + * Returns the URL of the request. + * + * @return An absolute HTTP URL. + */ + public String getUrl() { + return url; + } + + /** + * Returns any content that was sent with the request. + * + * @return HTTP content or null. + */ + @Nullable + public HttpContent getContent() { + return content; + } + + /** + * Returns the headers set on the request. + * + * @return An immutable map of headers (possibly empty). + */ + public Map getHeaders() { + return headers; + } +} diff --git a/src/main/java/com/google/firebase/ThreadManager.java b/src/main/java/com/google/firebase/ThreadManager.java index 8903101f6..29e7b42d0 100644 --- a/src/main/java/com/google/firebase/ThreadManager.java +++ b/src/main/java/com/google/firebase/ThreadManager.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java new file mode 100644 index 000000000..44fe9b7d2 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java @@ -0,0 +1,1847 @@ +/* + * Copyright 2020 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.auth; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.firebase.auth.internal.Utils.isEmulatorMode; + +import com.google.api.client.json.JsonFactory; +import com.google.api.client.util.Clock; +import com.google.api.core.ApiFuture; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; +import com.google.firebase.FirebaseApp; +import com.google.firebase.auth.FirebaseUserManager.EmailLinkType; +import com.google.firebase.auth.FirebaseUserManager.UserImportRequest; +import com.google.firebase.auth.ListProviderConfigsPage.DefaultOidcProviderConfigSource; +import com.google.firebase.auth.ListProviderConfigsPage.DefaultSamlProviderConfigSource; +import com.google.firebase.auth.ListUsersPage.DefaultUserSource; +import com.google.firebase.auth.internal.FirebaseTokenFactory; +import com.google.firebase.internal.CallableOperation; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * This is the abstract class for server-side Firebase Authentication actions. + */ +public abstract class AbstractFirebaseAuth { + + private final Object lock = new Object(); + private final AtomicBoolean destroyed = new AtomicBoolean(false); + + private final FirebaseApp firebaseApp; + private final Supplier tokenFactory; + private final Supplier idTokenVerifier; + private final Supplier cookieVerifier; + private final Supplier userManager; + private final JsonFactory jsonFactory; + + protected AbstractFirebaseAuth(Builder builder) { + this.firebaseApp = checkNotNull(builder.firebaseApp); + this.tokenFactory = threadSafeMemoize(builder.tokenFactory); + this.idTokenVerifier = threadSafeMemoize(builder.idTokenVerifier); + this.cookieVerifier = threadSafeMemoize(builder.cookieVerifier); + this.userManager = threadSafeMemoize(builder.userManager); + this.jsonFactory = firebaseApp.getOptions().getJsonFactory(); + } + + /** + * Creates a Firebase custom token for the given UID. This token can then be sent back to a client + * application to be used with the signInWithCustomToken + * authentication API. + * + *

{@link FirebaseApp} must have been initialized with service account credentials to use call + * this method. + * + * @param uid The UID to store in the token. This identifies the user to other Firebase services + * (Realtime Database, Firebase Auth, etc.). Should be less than 128 characters. + * @return A Firebase custom token string. + * @throws IllegalArgumentException If the specified uid is null or empty, or if the app has not + * been initialized with service account credentials. + * @throws FirebaseAuthException If an error occurs while generating the custom token. + */ + public String createCustomToken(@NonNull String uid) throws FirebaseAuthException { + return createCustomToken(uid, null); + } + + /** + * Creates a Firebase custom token for the given UID, containing the specified additional claims. + * This token can then be sent back to a client application to be used with the signInWithCustomToken + * authentication API. + * + *

This method attempts to generate a token using: + * + *

    + *
  1. the private key of {@link FirebaseApp}'s service account credentials, if provided at + * initialization. + *
  2. the IAM + * service if a service account email was specified via {@link + * com.google.firebase.FirebaseOptions.Builder#setServiceAccountId(String)}. + *
  3. the App + * Identity service if the code is deployed in the Google App Engine standard + * environment. + *
  4. the local + * Metadata server if the code is deployed in a different GCP-managed environment like + * Google Compute Engine. + *
+ * + *

This method throws an exception when all the above fail. + * + * @param uid The UID to store in the token. This identifies the user to other Firebase services + * (Realtime Database, Firebase Auth, etc.). Should be less than 128 characters. + * @param developerClaims Additional claims to be stored in the token (and made available to + * security rules in Database, Storage, etc.). These must be able to be serialized to JSON + * (e.g. contain only Maps, Arrays, Strings, Booleans, Numbers, etc.) + * @return A Firebase custom token string. + * @throws IllegalArgumentException If the specified uid is null or empty. + * @throws IllegalStateException If the SDK fails to discover a viable approach for signing + * tokens. + * @throws FirebaseAuthException If an error occurs while generating the custom token. + */ + public String createCustomToken( + @NonNull String uid, @Nullable Map developerClaims) + throws FirebaseAuthException { + return createCustomTokenOp(uid, developerClaims).call(); + } + + /** + * Similar to {@link #createCustomToken(String)} but performs the operation asynchronously. + * + * @param uid The UID to store in the token. This identifies the user to other Firebase services + * (Realtime Database, Firebase Auth, etc.). Should be less than 128 characters. + * @return An {@code ApiFuture} which will complete successfully with the created Firebase custom + * token, or unsuccessfully with the failure Exception. + * @throws IllegalArgumentException If the specified uid is null or empty, or if the app has not + * been initialized with service account credentials. + */ + public ApiFuture createCustomTokenAsync(@NonNull String uid) { + return createCustomTokenAsync(uid, null); + } + + /** + * Similar to {@link #createCustomToken(String, Map)} but performs the operation asynchronously. + * + * @param uid The UID to store in the token. This identifies the user to other Firebase services + * (Realtime Database, Storage, etc.). Should be less than 128 characters. + * @param developerClaims Additional claims to be stored in the token (and made available to + * security rules in Database, Storage, etc.). These must be able to be serialized to JSON + * (e.g. contain only Maps, Arrays, Strings, Booleans, Numbers, etc.) + * @return An {@code ApiFuture} which will complete successfully with the created Firebase custom + * token, or unsuccessfully with the failure Exception. + * @throws IllegalArgumentException If the specified uid is null or empty, or if the app has not + * been initialized with service account credentials. + */ + public ApiFuture createCustomTokenAsync( + @NonNull String uid, @Nullable Map developerClaims) { + return createCustomTokenOp(uid, developerClaims).callAsync(firebaseApp); + } + + private CallableOperation createCustomTokenOp( + final String uid, final Map developerClaims) { + checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); + final FirebaseTokenFactory tokenFactory = this.tokenFactory.get(); + return new CallableOperation() { + @Override + public String execute() throws FirebaseAuthException { + return tokenFactory.createSignedCustomAuthTokenForUser(uid, developerClaims); + } + }; + } + + /** + * Creates a new Firebase session cookie from the given ID token and options. The returned JWT can + * be set as a server-side session cookie with a custom cookie policy. + * + * @param idToken The Firebase ID token to exchange for a session cookie. + * @param options Additional options required to create the cookie. + * @return A Firebase session cookie string. + * @throws IllegalArgumentException If the ID token is null or empty, or if options is null. + * @throws FirebaseAuthException If an error occurs while generating the session cookie. + */ + public String createSessionCookie(@NonNull String idToken, @NonNull SessionCookieOptions options) + throws FirebaseAuthException { + return createSessionCookieOp(idToken, options).call(); + } + + /** + * Similar to {@link #createSessionCookie(String, SessionCookieOptions)} but performs the + * operation asynchronously. + * + * @param idToken The Firebase ID token to exchange for a session cookie. + * @param options Additional options required to create the cookie. + * @return An {@code ApiFuture} which will complete successfully with a session cookie string. If + * an error occurs while generating the cookie or if the specified ID token is invalid, the + * future throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the ID token is null or empty, or if options is null. + */ + public ApiFuture createSessionCookieAsync( + @NonNull String idToken, @NonNull SessionCookieOptions options) { + return createSessionCookieOp(idToken, options).callAsync(firebaseApp); + } + + private CallableOperation createSessionCookieOp( + final String idToken, final SessionCookieOptions options) { + checkArgument(!Strings.isNullOrEmpty(idToken), "idToken must not be null or empty"); + checkNotNull(options, "options must not be null"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected String execute() throws FirebaseAuthException { + return userManager.createSessionCookie(idToken, options); + } + }; + } + + /** + * Parses and verifies a Firebase ID Token. + * + *

A Firebase application can identify itself to a trusted backend server by sending its + * Firebase ID Token (accessible via the {@code getToken} API in the Firebase Authentication + * client) with its requests. The backend server can then use the {@code verifyIdToken()} method + * to verify that the token is valid. This method ensures that the token is correctly signed, has + * not expired, and it was issued to the Firebase project associated with this {@link + * FirebaseAuth} instance. + * + *

This method does not check whether a token has been revoked. Use {@link + * #verifyIdToken(String, boolean)} to perform an additional revocation check. + * + * @param idToken A Firebase ID token string to parse and verify. + * @return A {@link FirebaseToken} representing the verified and decoded token. + * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} + * instance does not have a project ID associated with it. + * @throws FirebaseAuthException If an error occurs while parsing or validating the token. + */ + public FirebaseToken verifyIdToken(@NonNull String idToken) throws FirebaseAuthException { + return verifyIdToken(idToken, false); + } + + /** + * Parses and verifies a Firebase ID Token. + * + *

A Firebase application can identify itself to a trusted backend server by sending its + * Firebase ID Token (accessible via the {@code getToken} API in the Firebase Authentication + * client) with its requests. The backend server can then use the {@code verifyIdToken()} method + * to verify that the token is valid. This method ensures that the token is correctly signed, has + * not expired, and it was issued to the Firebase project associated with this {@link + * FirebaseAuth} instance. + * + *

If {@code checkRevoked} is set to true, this method performs an additional check to see if + * the ID token has been revoked since it was issues. This requires making an additional remote + * API call. + * + * @param idToken A Firebase ID token string to parse and verify. + * @param checkRevoked A boolean denoting whether to check if the tokens were revoked or if + * the user is disabled. + * @return A {@link FirebaseToken} representing the verified and decoded token. + * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} + * instance does not have a project ID associated with it. + * @throws FirebaseAuthException If an error occurs while parsing or validating the token, or if + * the user is disabled. + */ + public FirebaseToken verifyIdToken(@NonNull String idToken, boolean checkRevoked) + throws FirebaseAuthException { + return verifyIdTokenOp(idToken, checkRevoked).call(); + } + + /** + * Similar to {@link #verifyIdToken(String)} but performs the operation asynchronously. + * + * @param idToken A Firebase ID Token to verify and parse. + * @return An {@code ApiFuture} which will complete successfully with the parsed token, or + * unsuccessfully with a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} + * instance does not have a project ID associated with it. + */ + public ApiFuture verifyIdTokenAsync(@NonNull String idToken) { + return verifyIdTokenAsync(idToken, false); + } + + /** + * Similar to {@link #verifyIdToken(String, boolean)} but performs the operation asynchronously. + * + * @param idToken A Firebase ID Token to verify and parse. + * @param checkRevoked A boolean denoting whether to check if the tokens were revoked. + * @return An {@code ApiFuture} which will complete successfully with the parsed token, or + * unsuccessfully with a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} + * instance does not have a project ID associated with it. + */ + public ApiFuture + verifyIdTokenAsync(@NonNull String idToken, boolean checkRevoked) { + return verifyIdTokenOp(idToken, checkRevoked).callAsync(firebaseApp); + } + + private CallableOperation verifyIdTokenOp( + final String idToken, final boolean checkRevoked) { + checkArgument(!Strings.isNullOrEmpty(idToken), "ID token must not be null or empty"); + final FirebaseTokenVerifier verifier = getIdTokenVerifier(checkRevoked); + return new CallableOperation() { + @Override + protected FirebaseToken execute() throws FirebaseAuthException { + return verifier.verifyToken(idToken); + } + }; + } + + @VisibleForTesting + FirebaseTokenVerifier getIdTokenVerifier(boolean checkRevoked) { + FirebaseTokenVerifier verifier = idTokenVerifier.get(); + if (checkRevoked || isEmulatorMode()) { + FirebaseUserManager userManager = getUserManager(); + verifier = RevocationCheckDecorator.decorateIdTokenVerifier(verifier, userManager); + } + return verifier; + } + + /** + * Parses and verifies a Firebase session cookie. + * + *

If verified successfully, returns a parsed version of the cookie from which the UID and the + * other claims can be read. If the cookie is invalid, throws a {@link FirebaseAuthException}. + * + *

This method does not check whether the cookie has been revoked. See {@link + * #verifySessionCookie(String, boolean)}. + * + * @param cookie A Firebase session cookie string to verify and parse. + * @return A {@link FirebaseToken} representing the verified and decoded cookie. + */ + public FirebaseToken verifySessionCookie(String cookie) throws FirebaseAuthException { + return verifySessionCookie(cookie, false); + } + + /** + * Parses and verifies a Firebase session cookie. + * + *

If {@code checkRevoked} is true, additionally verifies that the cookie has not been revoked. + * + *

If verified successfully, returns a parsed version of the cookie from which the UID and the + * other claims can be read. If the cookie is invalid or has been revoked while {@code + * checkRevoked} is true, throws a {@link FirebaseAuthException}. + * + * @param cookie A Firebase session cookie string to verify and parse. + * @param checkRevoked A boolean indicating whether to check if the cookie was explicitly revoked + * or if the user is disabled. + * @return A {@link FirebaseToken} representing the verified and decoded cookie. + * @throws FirebaseAuthException If an error occurs while parsing or validating the token, or if + * the user is disabled. + */ + public FirebaseToken verifySessionCookie(String cookie, boolean checkRevoked) + throws FirebaseAuthException { + return verifySessionCookieOp(cookie, checkRevoked).call(); + } + + /** + * Similar to {@link #verifySessionCookie(String)} but performs the operation asynchronously. + * + * @param cookie A Firebase session cookie string to verify and parse. + * @return An {@code ApiFuture} which will complete successfully with the parsed cookie, or + * unsuccessfully with the failure Exception. + */ + public ApiFuture verifySessionCookieAsync(String cookie) { + return verifySessionCookieAsync(cookie, false); + } + + /** + * Similar to {@link #verifySessionCookie(String, boolean)} but performs the operation + * asynchronously. + * + * @param cookie A Firebase session cookie string to verify and parse. + * @param checkRevoked A boolean indicating whether to check if the cookie was explicitly revoked. + * @return An {@code ApiFuture} which will complete successfully with the parsed cookie, or + * unsuccessfully with the failure Exception. + */ + public ApiFuture verifySessionCookieAsync(String cookie, boolean checkRevoked) { + return verifySessionCookieOp(cookie, checkRevoked).callAsync(firebaseApp); + } + + private CallableOperation verifySessionCookieOp( + final String cookie, final boolean checkRevoked) { + checkArgument(!Strings.isNullOrEmpty(cookie), "Session cookie must not be null or empty"); + final FirebaseTokenVerifier sessionCookieVerifier = getSessionCookieVerifier(checkRevoked); + return new CallableOperation() { + @Override + public FirebaseToken execute() throws FirebaseAuthException { + return sessionCookieVerifier.verifyToken(cookie); + } + }; + } + + @VisibleForTesting + FirebaseTokenVerifier getSessionCookieVerifier(boolean checkRevoked) { + FirebaseTokenVerifier verifier = cookieVerifier.get(); + if (checkRevoked || isEmulatorMode()) { + FirebaseUserManager userManager = getUserManager(); + verifier = RevocationCheckDecorator.decorateSessionCookieVerifier(verifier, userManager); + } + return verifier; + } + + /** + * Revokes all refresh tokens for the specified user. + * + *

Updates the user's tokensValidAfterTimestamp to the current UTC time expressed in + * milliseconds since the epoch and truncated to 1 second accuracy. It is important that the + * server on which this is called has its clock set correctly and synchronized. + * + *

While this will revoke all sessions for a specified user and disable any new ID tokens for + * existing sessions from getting minted, existing ID tokens may remain active until their natural + * expiration (one hour). To verify that ID tokens are revoked, use {@link + * #verifyIdTokenAsync(String, boolean)}. + * + * @param uid The user id for which tokens are revoked. + * @throws IllegalArgumentException If the user ID is null or empty. + * @throws FirebaseAuthException If an error occurs while revoking tokens. + */ + public void revokeRefreshTokens(@NonNull String uid) throws FirebaseAuthException { + revokeRefreshTokensOp(uid).call(); + } + + /** + * Similar to {@link #revokeRefreshTokens(String)} but performs the operation asynchronously. + * + * @param uid The user id for which tokens are revoked. + * @return An {@code ApiFuture} which will complete successfully or fail with a {@link + * FirebaseAuthException} in the event of an error. + * @throws IllegalArgumentException If the user ID is null or empty. + */ + public ApiFuture revokeRefreshTokensAsync(@NonNull String uid) { + return revokeRefreshTokensOp(uid).callAsync(firebaseApp); + } + + private CallableOperation revokeRefreshTokensOp(final String uid) { + checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected Void execute() throws FirebaseAuthException { + int currentTimeSeconds = (int) (System.currentTimeMillis() / 1000); + UserRecord.UpdateRequest request = + new UserRecord.UpdateRequest(uid).setValidSince(currentTimeSeconds); + userManager.updateUser(request, jsonFactory); + return null; + } + }; + } + + /** + * Gets the user data corresponding to the specified user ID. + * + * @param uid A user ID string. + * @return A {@link UserRecord} instance. + * @throws IllegalArgumentException If the user ID string is null or empty. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public UserRecord getUser(@NonNull String uid) throws FirebaseAuthException { + return getUserOp(uid).call(); + } + + /** + * Similar to {@link #getUser(String)} but performs the operation asynchronously. + * + * @param uid A user ID string. + * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} + * instance. If an error occurs while retrieving user data or if the specified user ID does + * not exist, the future throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the user ID string is null or empty. + */ + public ApiFuture getUserAsync(@NonNull String uid) { + return getUserOp(uid).callAsync(firebaseApp); + } + + private CallableOperation getUserOp(final String uid) { + checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected UserRecord execute() throws FirebaseAuthException { + return userManager.getUserById(uid); + } + }; + } + + /** + * Gets the user data corresponding to the specified user email. + * + * @param email A user email address string. + * @return A {@link UserRecord} instance. + * @throws IllegalArgumentException If the email is null or empty. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public UserRecord getUserByEmail(@NonNull String email) throws FirebaseAuthException { + return getUserByEmailOp(email).call(); + } + + /** + * Similar to {@link #getUserByEmail(String)} but performs the operation asynchronously. + * + * @param email A user email address string. + * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} + * instance. If an error occurs while retrieving user data or if the email address does not + * correspond to a user, the future throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the email is null or empty. + */ + public ApiFuture getUserByEmailAsync(@NonNull String email) { + return getUserByEmailOp(email).callAsync(firebaseApp); + } + + private CallableOperation getUserByEmailOp( + final String email) { + checkArgument(!Strings.isNullOrEmpty(email), "email must not be null or empty"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected UserRecord execute() throws FirebaseAuthException { + return userManager.getUserByEmail(email); + } + }; + } + + /** + * Gets the user data corresponding to the specified user phone number. + * + * @param phoneNumber A user phone number string. + * @return A a {@link UserRecord} instance. + * @throws IllegalArgumentException If the phone number is null or empty. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public UserRecord getUserByPhoneNumber(@NonNull String phoneNumber) throws FirebaseAuthException { + return getUserByPhoneNumberOp(phoneNumber).call(); + } + + /** + * Gets the user data corresponding to the specified user phone number. + * + * @param phoneNumber A user phone number string. + * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} + * instance. If an error occurs while retrieving user data or if the phone number does not + * correspond to a user, the future throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the phone number is null or empty. + */ + public ApiFuture getUserByPhoneNumberAsync(@NonNull String phoneNumber) { + return getUserByPhoneNumberOp(phoneNumber).callAsync(firebaseApp); + } + + private CallableOperation getUserByPhoneNumberOp( + final String phoneNumber) { + checkArgument(!Strings.isNullOrEmpty(phoneNumber), "phone number must not be null or empty"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected UserRecord execute() throws FirebaseAuthException { + return userManager.getUserByPhoneNumber(phoneNumber); + } + }; + } + + /** + * Gets the user data for the user corresponding to a given provider ID. + * + * @param providerId Identifier for the given federated provider: for example, + * "google.com" for the Google provider. + * @param uid The user identifier with the given provider. + * @return A {@link UserRecord} instance. + * @throws IllegalArgumentException If the uid is null or empty, or if + * the providerId is null, empty, or does not belong to a federated provider. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public UserRecord getUserByProviderUid( + @NonNull String providerId, @NonNull String uid) throws FirebaseAuthException { + return getUserByProviderUidOp(providerId, uid).call(); + } + + /** + * Gets the user data for the user corresponding to a given provider ID. + * + * @param providerId Identifer for the given federated provider: for example, + * "google.com" for the Google provider. + * @param uid The user identifier with the given provider. + * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} + * instance. If an error occurs while retrieving user data or if the provider ID and uid + * do not correspond to a user, the future throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the uid is null or empty, or if + * the provider ID is null, empty, or does not belong to a federated provider. + */ + public ApiFuture getUserByProviderUidAsync( + @NonNull String providerId, @NonNull String uid) { + return getUserByProviderUidOp(providerId, uid).callAsync(firebaseApp); + } + + private CallableOperation getUserByProviderUidOp( + final String providerId, final String uid) { + checkArgument(!Strings.isNullOrEmpty(providerId), "providerId must not be null or empty"); + checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); + + // Although we don't really advertise it, we want to also handle + // non-federated idps with this call. So if we detect one of them, we'll + // reroute this request appropriately. + if ("phone".equals(providerId)) { + return this.getUserByPhoneNumberOp(uid); + } + if ("email".equals(providerId)) { + return this.getUserByEmailOp(uid); + } + + checkArgument(!providerId.equals("password") + && !providerId.equals("anonymous"), "providerId must belong to a federated provider"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected UserRecord execute() throws FirebaseAuthException { + return userManager.getUserByProviderUid(providerId, uid); + } + }; + } + + /** + * Gets a page of users starting from the specified {@code pageToken}. Page size is limited to + * 1000 users. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of users. + * @return A {@link ListUsersPage} instance. + * @throws IllegalArgumentException If the specified page token is empty. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public ListUsersPage listUsers(@Nullable String pageToken) throws FirebaseAuthException { + return listUsers(pageToken, FirebaseUserManager.MAX_LIST_USERS_RESULTS); + } + + /** + * Gets a page of users starting from the specified {@code pageToken}. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of users. + * @param maxResults Maximum number of users to include in the returned page. This may not exceed + * 1000. + * @return A {@link ListUsersPage} instance. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public ListUsersPage listUsers(@Nullable String pageToken, int maxResults) + throws FirebaseAuthException { + return listUsersOp(pageToken, maxResults).call(); + } + + /** + * Similar to {@link #listUsers(String)} but performs the operation asynchronously. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of users. + * @return An {@code ApiFuture} which will complete successfully with a {@link ListUsersPage} + * instance. If an error occurs while retrieving user data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty. + */ + public ApiFuture listUsersAsync(@Nullable String pageToken) { + return listUsersAsync(pageToken, FirebaseUserManager.MAX_LIST_USERS_RESULTS); + } + + /** + * Similar to {@link #listUsers(String, int)} but performs the operation asynchronously. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of users. + * @param maxResults Maximum number of users to include in the returned page. This may not exceed + * 1000. + * @return An {@code ApiFuture} which will complete successfully with a {@link ListUsersPage} + * instance. If an error occurs while retrieving user data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + */ + public ApiFuture listUsersAsync(@Nullable String pageToken, int maxResults) { + return listUsersOp(pageToken, maxResults).callAsync(firebaseApp); + } + + private CallableOperation listUsersOp( + @Nullable final String pageToken, final int maxResults) { + final FirebaseUserManager userManager = getUserManager(); + final DefaultUserSource source = new DefaultUserSource(userManager, jsonFactory); + final ListUsersPage.Factory factory = new ListUsersPage.Factory(source, maxResults, pageToken); + return new CallableOperation() { + @Override + protected ListUsersPage execute() throws FirebaseAuthException { + return factory.create(); + } + }; + } + + /** + * Creates a new user account with the attributes contained in the specified {@link + * UserRecord.CreateRequest}. + * + * @param request A non-null {@link UserRecord.CreateRequest} instance. + * @return A {@link UserRecord} instance corresponding to the newly created account. + * @throws NullPointerException if the provided request is null. + * @throws FirebaseAuthException if an error occurs while creating the user account. + */ + public UserRecord createUser(@NonNull UserRecord.CreateRequest request) + throws FirebaseAuthException { + return createUserOp(request).call(); + } + + /** + * Similar to {@link #createUser} but performs the operation asynchronously. + * + * @param request A non-null {@link UserRecord.CreateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} + * instance corresponding to the newly created account. If an error occurs while creating the + * user account, the future throws a {@link FirebaseAuthException}. + * @throws NullPointerException if the provided request is null. + */ + public ApiFuture createUserAsync(@NonNull UserRecord.CreateRequest request) { + return createUserOp(request).callAsync(firebaseApp); + } + + private CallableOperation createUserOp( + final UserRecord.CreateRequest request) { + checkNotNull(request, "create request must not be null"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected UserRecord execute() throws FirebaseAuthException { + String uid = userManager.createUser(request); + return userManager.getUserById(uid); + } + }; + } + + /** + * Updates an existing user account with the attributes contained in the specified {@link + * UserRecord.UpdateRequest}. + * + * @param request A non-null {@link UserRecord.UpdateRequest} instance. + * @return A {@link UserRecord} instance corresponding to the updated user account. + * @throws NullPointerException if the provided update request is null. + * @throws FirebaseAuthException if an error occurs while updating the user account. + */ + public UserRecord updateUser(@NonNull UserRecord.UpdateRequest request) + throws FirebaseAuthException { + return updateUserOp(request).call(); + } + + /** + * Similar to {@link #updateUser} but performs the operation asynchronously. + * + * @param request A non-null {@link UserRecord.UpdateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} + * instance corresponding to the updated user account. If an error occurs while updating the + * user account, the future throws a {@link FirebaseAuthException}. + */ + public ApiFuture updateUserAsync(@NonNull UserRecord.UpdateRequest request) { + return updateUserOp(request).callAsync(firebaseApp); + } + + private CallableOperation updateUserOp( + final UserRecord.UpdateRequest request) { + checkNotNull(request, "update request must not be null"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected UserRecord execute() throws FirebaseAuthException { + userManager.updateUser(request, jsonFactory); + return userManager.getUserById(request.getUid()); + } + }; + } + + /** + * Sets the specified custom claims on an existing user account. A null claims value removes any + * claims currently set on the user account. The claims should serialize into a valid JSON string. + * The serialized claims must not be larger than 1000 characters. + * + * @param uid A user ID string. + * @param claims A map of custom claims or null. + * @throws FirebaseAuthException If an error occurs while updating custom claims. + * @throws IllegalArgumentException If the user ID string is null or empty, or the claims payload + * is invalid or too large. + */ + public void setCustomUserClaims(@NonNull String uid, @Nullable Map claims) + throws FirebaseAuthException { + setCustomUserClaimsOp(uid, claims).call(); + } + + /** + * @deprecated Use {@link #setCustomUserClaims(String, Map)} instead. + */ + public void setCustomClaims(@NonNull String uid, @Nullable Map claims) + throws FirebaseAuthException { + setCustomUserClaims(uid, claims); + } + + /** + * Similar to {@link #setCustomUserClaims(String, Map)} but performs the operation asynchronously. + * + * @param uid A user ID string. + * @param claims A map of custom claims or null. + * @return An {@code ApiFuture} which will complete successfully when the user account has been + * updated. If an error occurs while deleting the user account, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the user ID string is null or empty. + */ + public ApiFuture setCustomUserClaimsAsync( + @NonNull String uid, @Nullable Map claims) { + return setCustomUserClaimsOp(uid, claims).callAsync(firebaseApp); + } + + private CallableOperation setCustomUserClaimsOp( + final String uid, final Map claims) { + checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected Void execute() throws FirebaseAuthException { + final UserRecord.UpdateRequest request = + new UserRecord.UpdateRequest(uid).setCustomClaims(claims); + userManager.updateUser(request, jsonFactory); + return null; + } + }; + } + + /** + * Deletes the user identified by the specified user ID. + * + * @param uid A user ID string. + * @throws IllegalArgumentException If the user ID string is null or empty. + * @throws FirebaseAuthException If an error occurs while deleting the user. + */ + public void deleteUser(@NonNull String uid) throws FirebaseAuthException { + deleteUserOp(uid).call(); + } + + /** + * Similar to {@link #deleteUser(String)} but performs the operation asynchronously. + * + * @param uid A user ID string. + * @return An {@code ApiFuture} which will complete successfully when the specified user account + * has been deleted. If an error occurs while deleting the user account, the future throws a + * {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the user ID string is null or empty. + */ + public ApiFuture deleteUserAsync(String uid) { + return deleteUserOp(uid).callAsync(firebaseApp); + } + + private CallableOperation deleteUserOp(final String uid) { + checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected Void execute() throws FirebaseAuthException { + userManager.deleteUser(uid); + return null; + } + }; + } + + /** + * Imports the provided list of users into Firebase Auth. At most 1000 users can be imported at a + * time. This operation is optimized for bulk imports and will ignore checks on identifier + * uniqueness which could result in duplications. + * + *

{@link UserImportOptions} is required to import users with passwords. See {@link + * #importUsers(List, UserImportOptions)}. + * + * @param users A non-empty list of users to be imported. Length must not exceed 1000. + * @return A {@link UserImportResult} instance. + * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 + * elements. Or if at least one user specifies a password. + * @throws FirebaseAuthException If an error occurs while importing users. + */ + public UserImportResult importUsers(List users) throws FirebaseAuthException { + return importUsers(users, null); + } + + /** + * Imports the provided list of users into Firebase Auth. At most 1000 users can be imported at a + * time. This operation is optimized for bulk imports and will ignore checks on identifier + * uniqueness which could result in duplications. + * + * @param users A non-empty list of users to be imported. Length must not exceed 1000. + * @param options a {@link UserImportOptions} instance or null. Required when importing users with + * passwords. + * @return A {@link UserImportResult} instance. + * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 + * elements. Or if at least one user specifies a password, and options is null. + * @throws FirebaseAuthException If an error occurs while importing users. + */ + public UserImportResult importUsers( + List users, @Nullable UserImportOptions options) + throws FirebaseAuthException { + return importUsersOp(users, options).call(); + } + + /** + * Similar to {@link #importUsers(List)} but performs the operation asynchronously. + * + * @param users A non-empty list of users to be imported. Length must not exceed 1000. + * @return An {@code ApiFuture} which will complete successfully when the user accounts are + * imported. If an error occurs while importing the users, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 + * elements. Or if at least one user specifies a password. + */ + public ApiFuture importUsersAsync(List users) { + return importUsersAsync(users, null); + } + + /** + * Similar to {@link #importUsers(List, UserImportOptions)} but performs the operation + * asynchronously. + * + * @param users A non-empty list of users to be imported. Length must not exceed 1000. + * @param options a {@link UserImportOptions} instance or null. Required when importing users with + * passwords. + * @return An {@code ApiFuture} which will complete successfully when the user accounts are + * imported. If an error occurs while importing the users, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 + * elements. Or if at least one user specifies a password, and options is null. + */ + public ApiFuture importUsersAsync( + List users, @Nullable UserImportOptions options) { + return importUsersOp(users, options).callAsync(firebaseApp); + } + + private CallableOperation importUsersOp( + final List users, final UserImportOptions options) { + final UserImportRequest request = new UserImportRequest(users, options, jsonFactory); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected UserImportResult execute() throws FirebaseAuthException { + return userManager.importUsers(request); + } + }; + } + + /** + * Gets the user data corresponding to the specified identifiers. + * + *

There are no ordering guarantees; in particular, the nth entry in the users result list is + * not guaranteed to correspond to the nth entry in the input parameters list. + * + *

A maximum of 100 identifiers may be specified. If more than 100 identifiers are + * supplied, this method throws an {@code IllegalArgumentException}. + * + * @param identifiers The identifiers used to indicate which user records should be returned. Must + * have 100 or fewer entries. + * @return The corresponding user records. + * @throws IllegalArgumentException If any of the identifiers are invalid or if more than 100 + * identifiers are specified. + * @throws NullPointerException If the identifiers parameter is null. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public GetUsersResult getUsers(@NonNull Collection identifiers) + throws FirebaseAuthException { + return getUsersOp(identifiers).call(); + } + + /** + * Gets the user data corresponding to the specified identifiers. + * + *

There are no ordering guarantees; in particular, the nth entry in the users result list is + * not guaranteed to correspond to the nth entry in the input parameters list. + * + *

A maximum of 100 identifiers may be specified. If more than 100 identifiers are + * supplied, this method throws an {@code IllegalArgumentException}. + * + * @param identifiers The identifiers used to indicate which user records should be returned. + * Must have 100 or fewer entries. + * @return An {@code ApiFuture} that resolves to the corresponding user records. + * @throws IllegalArgumentException If any of the identifiers are invalid or if more than 100 + * identifiers are specified. + * @throws NullPointerException If the identifiers parameter is null. + */ + public ApiFuture getUsersAsync(@NonNull Collection identifiers) { + return getUsersOp(identifiers).callAsync(firebaseApp); + } + + private CallableOperation getUsersOp( + @NonNull final Collection identifiers) { + checkNotNull(identifiers, "identifiers must not be null"); + checkArgument(identifiers.size() <= FirebaseUserManager.MAX_GET_ACCOUNTS_BATCH_SIZE, + "identifiers parameter must have <= " + FirebaseUserManager.MAX_GET_ACCOUNTS_BATCH_SIZE + + " entries."); + + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected GetUsersResult execute() throws FirebaseAuthException { + Set users = userManager.getAccountInfo(identifiers); + Set notFound = new HashSet<>(); + for (UserIdentifier id : identifiers) { + if (!isUserFound(id, users)) { + notFound.add(id); + } + } + return new GetUsersResult(users, notFound); + } + }; + } + + private boolean isUserFound(UserIdentifier id, Collection userRecords) { + for (UserRecord userRecord : userRecords) { + if (id.matches(userRecord)) { + return true; + } + } + return false; + } + + /** + * Deletes the users specified by the given identifiers. + * + *

Deleting a non-existing user does not generate an error (the method is idempotent). + * Non-existing users are considered to be successfully deleted and are therefore included in the + * DeleteUsersResult.getSuccessCount() value. + * + *

A maximum of 1000 identifiers may be supplied. If more than 1000 identifiers are + * supplied, this method throws an {@code IllegalArgumentException}. + * + *

This API has a rate limit of 1 QPS. Exceeding the limit may result in a quota exceeded + * error. If you want to delete more than 1000 users, we suggest adding a delay to ensure you + * don't exceed this limit. + * + * @param uids The uids of the users to be deleted. Must have <= 1000 entries. + * @return The total number of successful/failed deletions, as well as the array of errors that + * correspond to the failed deletions. + * @throws IllegalArgumentException If any of the identifiers are invalid or if more than 1000 + * identifiers are specified. + * @throws FirebaseAuthException If an error occurs while deleting users. + */ + public DeleteUsersResult deleteUsers(List uids) throws FirebaseAuthException { + return deleteUsersOp(uids).call(); + } + + /** + * Similar to {@link #deleteUsers(List)} but performs the operation asynchronously. + * + * @param uids The uids of the users to be deleted. Must have <= 1000 entries. + * @return An {@code ApiFuture} that resolves to the total number of successful/failed + * deletions, as well as the array of errors that correspond to the failed deletions. If an + * error occurs while deleting the user account, the future throws a + * {@link FirebaseAuthException}. + * @throws IllegalArgumentException If any of the identifiers are invalid or if more than 1000 + * identifiers are specified. + */ + public ApiFuture deleteUsersAsync(List uids) { + return deleteUsersOp(uids).callAsync(firebaseApp); + } + + private CallableOperation deleteUsersOp( + final List uids) { + checkNotNull(uids, "uids must not be null"); + for (String uid : uids) { + UserRecord.checkUid(uid); + } + checkArgument(uids.size() <= FirebaseUserManager.MAX_DELETE_ACCOUNTS_BATCH_SIZE, + "uids parameter must have <= " + FirebaseUserManager.MAX_DELETE_ACCOUNTS_BATCH_SIZE + + " entries."); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected DeleteUsersResult execute() throws FirebaseAuthException { + return userManager.deleteUsers(uids); + } + }; + } + + /** + * Generates the out-of-band email action link for password reset flows for the specified email + * address. + * + * @param email The email of the user whose password is to be reset. + * @return A password reset link. + * @throws IllegalArgumentException If the email address is null or empty. + * @throws FirebaseAuthException If an error occurs while generating the link. + */ + public String generatePasswordResetLink(@NonNull String email) throws FirebaseAuthException { + return generatePasswordResetLink(email, null); + } + + /** + * Generates the out-of-band email action link for password reset flows for the specified email + * address. + * + * @param email The email of the user whose password is to be reset. + * @param settings The action code settings object which defines whether the link is to be handled + * by a mobile app and the additional state information to be passed in the deep link. + * @return A password reset link. + * @throws IllegalArgumentException If the email address is null or empty. + * @throws FirebaseAuthException If an error occurs while generating the link. + */ + public String generatePasswordResetLink( + @NonNull String email, @Nullable ActionCodeSettings settings) throws FirebaseAuthException { + return generateEmailActionLinkOp(EmailLinkType.PASSWORD_RESET, email, settings).call(); + } + + /** + * Similar to {@link #generatePasswordResetLink(String)} but performs the operation + * asynchronously. + * + * @param email The email of the user whose password is to be reset. + * @return An {@code ApiFuture} which will complete successfully with the generated email action + * link. If an error occurs while generating the link, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the email address is null or empty. + */ + public ApiFuture generatePasswordResetLinkAsync(@NonNull String email) { + return generatePasswordResetLinkAsync(email, null); + } + + /** + * Similar to {@link #generatePasswordResetLink(String, ActionCodeSettings)} but performs the + * operation asynchronously. + * + * @param email The email of the user whose password is to be reset. + * @param settings The action code settings object which defines whether the link is to be handled + * by a mobile app and the additional state information to be passed in the deep link. + * @return An {@code ApiFuture} which will complete successfully with the generated email action + * link. If an error occurs while generating the link, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the email address is null or empty. + */ + public ApiFuture generatePasswordResetLinkAsync( + @NonNull String email, @Nullable ActionCodeSettings settings) { + return generateEmailActionLinkOp(EmailLinkType.PASSWORD_RESET, email, settings) + .callAsync(firebaseApp); + } + + /** + * Generates the out-of-band email action link for email verification flows for the specified + * email address. + * + * @param email The email of the user to be verified. + * @return An email verification link. + * @throws IllegalArgumentException If the email address is null or empty. + * @throws FirebaseAuthException If an error occurs while generating the link. + */ + public String generateEmailVerificationLink(@NonNull String email) throws FirebaseAuthException { + return generateEmailVerificationLink(email, null); + } + + /** + * Generates the out-of-band email action link for email verification flows for the specified + * email address, using the action code settings provided. + * + * @param email The email of the user to be verified. + * @return An email verification link. + * @throws IllegalArgumentException If the email address is null or empty. + * @throws FirebaseAuthException If an error occurs while generating the link. + */ + public String generateEmailVerificationLink( + @NonNull String email, @Nullable ActionCodeSettings settings) throws FirebaseAuthException { + return generateEmailActionLinkOp(EmailLinkType.VERIFY_EMAIL, email, settings).call(); + } + + /** + * Similar to {@link #generateEmailVerificationLink(String)} but performs the operation + * asynchronously. + * + * @param email The email of the user to be verified. + * @return An {@code ApiFuture} which will complete successfully with the generated email action + * link. If an error occurs while generating the link, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the email address is null or empty. + */ + public ApiFuture generateEmailVerificationLinkAsync(@NonNull String email) { + return generateEmailVerificationLinkAsync(email, null); + } + + /** + * Similar to {@link #generateEmailVerificationLink(String, ActionCodeSettings)} but performs the + * operation asynchronously. + * + * @param email The email of the user to be verified. + * @param settings The action code settings object which defines whether the link is to be handled + * by a mobile app and the additional state information to be passed in the deep link. + * @return An {@code ApiFuture} which will complete successfully with the generated email action + * link. If an error occurs while generating the link, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the email address is null or empty. + */ + public ApiFuture generateEmailVerificationLinkAsync( + @NonNull String email, @Nullable ActionCodeSettings settings) { + return generateEmailActionLinkOp(EmailLinkType.VERIFY_EMAIL, email, settings) + .callAsync(firebaseApp); + } + + /** + * Generates the out-of-band email action link for email link sign-in flows, using the action code + * settings provided. + * + * @param email The email of the user signing in. + * @param settings The action code settings object which defines whether the link is to be handled + * by a mobile app and the additional state information to be passed in the deep link. + * @return An email verification link. + * @throws IllegalArgumentException If the email address is null or empty. + * @throws FirebaseAuthException If an error occurs while generating the link. + */ + public String generateSignInWithEmailLink( + @NonNull String email, @NonNull ActionCodeSettings settings) throws FirebaseAuthException { + return generateEmailActionLinkOp(EmailLinkType.EMAIL_SIGNIN, email, settings).call(); + } + + /** + * Similar to {@link #generateSignInWithEmailLink(String, ActionCodeSettings)} but performs the + * operation asynchronously. + * + * @param email The email of the user signing in. + * @param settings The action code settings object which defines whether the link is to be handled + * by a mobile app and the additional state information to be passed in the deep link. + * @return An {@code ApiFuture} which will complete successfully with the generated email action + * link. If an error occurs while generating the link, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the email address is null or empty. + * @throws NullPointerException If the settings is null. + */ + public ApiFuture generateSignInWithEmailLinkAsync( + String email, @NonNull ActionCodeSettings settings) { + return generateEmailActionLinkOp(EmailLinkType.EMAIL_SIGNIN, email, settings) + .callAsync(firebaseApp); + } + + private CallableOperation generateEmailActionLinkOp( + final EmailLinkType type, final String email, final ActionCodeSettings settings) { + checkArgument(!Strings.isNullOrEmpty(email), "email must not be null or empty"); + if (type == EmailLinkType.EMAIL_SIGNIN) { + checkNotNull(settings, "ActionCodeSettings must not be null when generating sign-in links"); + } + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected String execute() throws FirebaseAuthException { + return userManager.getEmailActionLink(type, email, settings); + } + }; + } + + /** + * Creates a new OpenID Connect auth provider config with the attributes contained in the + * specified {@link OidcProviderConfig.CreateRequest}. + * + * @param request A non-null {@link OidcProviderConfig.CreateRequest} instance. + * @return An {@link OidcProviderConfig} instance corresponding to the newly created provider + * config. + * @throws NullPointerException if the provided request is null. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not + * prefixed with 'oidc.'. + * @throws FirebaseAuthException if an error occurs while creating the provider config. + */ + public OidcProviderConfig createOidcProviderConfig( + @NonNull OidcProviderConfig.CreateRequest request) throws FirebaseAuthException { + return createOidcProviderConfigOp(request).call(); + } + + /** + * Similar to {@link #createOidcProviderConfig} but performs the operation asynchronously. + * + * @param request A non-null {@link OidcProviderConfig.CreateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link OidcProviderConfig} + * instance corresponding to the newly created provider config. If an error occurs while + * creating the provider config, the future throws a {@link FirebaseAuthException}. + * @throws NullPointerException if the provided request is null. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not + * prefixed with 'oidc.'. + */ + public ApiFuture createOidcProviderConfigAsync( + @NonNull OidcProviderConfig.CreateRequest request) { + return createOidcProviderConfigOp(request).callAsync(firebaseApp); + } + + private CallableOperation + createOidcProviderConfigOp(final OidcProviderConfig.CreateRequest request) { + checkNotNull(request, "Create request must not be null."); + OidcProviderConfig.checkOidcProviderId(request.getProviderId()); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected OidcProviderConfig execute() throws FirebaseAuthException { + return userManager.createOidcProviderConfig(request); + } + }; + } + + /** + * Updates an existing OpenID Connect auth provider config with the attributes contained in the + * specified {@link OidcProviderConfig.UpdateRequest}. + * + * @param request A non-null {@link OidcProviderConfig.UpdateRequest} instance. + * @return A {@link OidcProviderConfig} instance corresponding to the updated provider config. + * @throws NullPointerException if the provided update request is null. + * @throws IllegalArgumentException If the provided update request is invalid. + * @throws FirebaseAuthException if an error occurs while updating the provider config. + */ + public OidcProviderConfig updateOidcProviderConfig( + @NonNull OidcProviderConfig.UpdateRequest request) throws FirebaseAuthException { + return updateOidcProviderConfigOp(request).call(); + } + + /** + * Similar to {@link #updateOidcProviderConfig} but performs the operation asynchronously. + * + * @param request A non-null {@link OidcProviderConfig.UpdateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link OidcProviderConfig} + * instance corresponding to the updated provider config. If an error occurs while updating + * the provider config, the future throws a {@link FirebaseAuthException}. + * @throws NullPointerException if the provided update request is null. + * @throws IllegalArgumentException If the provided update request is invalid. + */ + public ApiFuture updateOidcProviderConfigAsync( + @NonNull OidcProviderConfig.UpdateRequest request) { + return updateOidcProviderConfigOp(request).callAsync(firebaseApp); + } + + private CallableOperation updateOidcProviderConfigOp( + final OidcProviderConfig.UpdateRequest request) { + checkNotNull(request, "Update request must not be null."); + checkArgument(!request.getProperties().isEmpty(), + "Update request must have at least one property set."); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected OidcProviderConfig execute() throws FirebaseAuthException { + return userManager.updateOidcProviderConfig(request); + } + }; + } + + /** + * Gets the OpenID Connect auth provider corresponding to the specified provider ID. + * + * @param providerId A provider ID string. + * @return An {@link OidcProviderConfig} instance. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with 'oidc'. + * @throws FirebaseAuthException If an error occurs while retrieving the provider config. + */ + public OidcProviderConfig getOidcProviderConfig(@NonNull String providerId) + throws FirebaseAuthException { + return getOidcProviderConfigOp(providerId).call(); + } + + /** + * Similar to {@link #getOidcProviderConfig(String)} but performs the operation asynchronously. + * Page size is limited to 100 provider configs. + * + * @param providerId A provider ID string. + * @return An {@code ApiFuture} which will complete successfully with an + * {@link OidcProviderConfig} instance. If an error occurs while retrieving the provider + * config or if the specified provider ID does not exist, the future throws a + * {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not + * prefixed with 'oidc.'. + */ + public ApiFuture getOidcProviderConfigAsync(@NonNull String providerId) { + return getOidcProviderConfigOp(providerId).callAsync(firebaseApp); + } + + private CallableOperation + getOidcProviderConfigOp(final String providerId) { + OidcProviderConfig.checkOidcProviderId(providerId); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected OidcProviderConfig execute() throws FirebaseAuthException { + return userManager.getOidcProviderConfig(providerId); + } + }; + } + + /** + * Gets a page of OpenID Connect auth provider configs starting from the specified + * {@code pageToken}. Page size is limited to 100 provider configs. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @return A {@link ListProviderConfigsPage} instance. + * @throws IllegalArgumentException If the specified page token is empty + * @throws FirebaseAuthException If an error occurs while retrieving provider config data. + */ + public ListProviderConfigsPage listOidcProviderConfigs( + @Nullable String pageToken) throws FirebaseAuthException { + int maxResults = FirebaseUserManager.MAX_LIST_PROVIDER_CONFIGS_RESULTS; + return listOidcProviderConfigsOp(pageToken, maxResults).call(); + } + + /** + * Gets a page of OpenID Connect auth provider configs starting from the specified + * {@code pageToken}. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @param maxResults Maximum number of provider configs to include in the returned page. This may + * not exceed 100. + * @return A {@link ListProviderConfigsPage} instance. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + * @throws FirebaseAuthException If an error occurs while retrieving provider config data. + */ + public ListProviderConfigsPage listOidcProviderConfigs( + @Nullable String pageToken, int maxResults) throws FirebaseAuthException { + return listOidcProviderConfigsOp(pageToken, maxResults).call(); + } + + /** + * Similar to {@link #listOidcProviderConfigs(String)} but performs the operation asynchronously. + * Page size is limited to 100 provider configs. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @return An {@code ApiFuture} which will complete successfully with a + * {@link ListProviderConfigsPage} instance. If an error occurs while retrieving provider + * config data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty. + */ + public ApiFuture> listOidcProviderConfigsAsync( + @Nullable String pageToken) { + int maxResults = FirebaseUserManager.MAX_LIST_PROVIDER_CONFIGS_RESULTS; + return listOidcProviderConfigsAsync(pageToken, maxResults); + } + + /** + * Similar to {@link #listOidcProviderConfigs(String, int)} but performs the operation + * asynchronously. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @param maxResults Maximum number of provider configs to include in the returned page. This may + * not exceed 100. + * @return An {@code ApiFuture} which will complete successfully with a + * {@link ListProviderConfigsPage} instance. If an error occurs while retrieving provider + * config data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + */ + public ApiFuture> listOidcProviderConfigsAsync( + @Nullable String pageToken, + int maxResults) { + return listOidcProviderConfigsOp(pageToken, maxResults).callAsync(firebaseApp); + } + + private CallableOperation, FirebaseAuthException> + listOidcProviderConfigsOp(@Nullable final String pageToken, final int maxResults) { + final FirebaseUserManager userManager = getUserManager(); + final DefaultOidcProviderConfigSource source = new DefaultOidcProviderConfigSource(userManager); + final ListProviderConfigsPage.Factory factory = + new ListProviderConfigsPage.Factory(source, maxResults, pageToken); + return + new CallableOperation, FirebaseAuthException>() { + @Override + protected ListProviderConfigsPage execute() + throws FirebaseAuthException { + return factory.create(); + } + }; + } + + /** + * Deletes the OpenID Connect auth provider config identified by the specified provider ID. + * + * @param providerId A provider ID string. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with 'oidc'. + * @throws FirebaseAuthException If an error occurs while deleting the provider config. + */ + public void deleteOidcProviderConfig(@NonNull String providerId) throws FirebaseAuthException { + deleteOidcProviderConfigOp(providerId).call(); + } + + /** + * Similar to {@link #deleteOidcProviderConfig} but performs the operation asynchronously. + * + * @param providerId A provider ID string. + * @return An {@code ApiFuture} which will complete successfully when the specified provider + * config has been deleted. If an error occurs while deleting the provider config, the future + * throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with "oidc.". + */ + public ApiFuture deleteOidcProviderConfigAsync(String providerId) { + return deleteOidcProviderConfigOp(providerId).callAsync(firebaseApp); + } + + private CallableOperation deleteOidcProviderConfigOp( + final String providerId) { + OidcProviderConfig.checkOidcProviderId(providerId); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected Void execute() throws FirebaseAuthException { + userManager.deleteOidcProviderConfig(providerId); + return null; + } + }; + } + + /** + * Creates a new SAML Auth provider config with the attributes contained in the specified + * {@link SamlProviderConfig.CreateRequest}. + * + * @param request A non-null {@link SamlProviderConfig.CreateRequest} instance. + * @return An {@link SamlProviderConfig} instance corresponding to the newly created provider + * config. + * @throws NullPointerException if the provided request is null. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with 'saml'. + * @throws FirebaseAuthException if an error occurs while creating the provider config. + */ + public SamlProviderConfig createSamlProviderConfig( + @NonNull SamlProviderConfig.CreateRequest request) throws FirebaseAuthException { + return createSamlProviderConfigOp(request).call(); + } + + /** + * Similar to {@link #createSamlProviderConfig} but performs the operation asynchronously. + * + * @param request A non-null {@link SamlProviderConfig.CreateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link SamlProviderConfig} + * instance corresponding to the newly created provider config. If an error occurs while + * creating the provider config, the future throws a {@link FirebaseAuthException}. + * @throws NullPointerException if the provided request is null. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with 'saml'. + */ + public ApiFuture createSamlProviderConfigAsync( + @NonNull SamlProviderConfig.CreateRequest request) { + return createSamlProviderConfigOp(request).callAsync(firebaseApp); + } + + private CallableOperation + createSamlProviderConfigOp(final SamlProviderConfig.CreateRequest request) { + checkNotNull(request, "Create request must not be null."); + SamlProviderConfig.checkSamlProviderId(request.getProviderId()); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected SamlProviderConfig execute() throws FirebaseAuthException { + return userManager.createSamlProviderConfig(request); + } + }; + } + + /** + * Updates an existing SAML Auth provider config with the attributes contained in the specified + * {@link SamlProviderConfig.UpdateRequest}. + * + * @param request A non-null {@link SamlProviderConfig.UpdateRequest} instance. + * @return A {@link SamlProviderConfig} instance corresponding to the updated provider config. + * @throws NullPointerException if the provided update request is null. + * @throws IllegalArgumentException If the provided update request is invalid. + * @throws FirebaseAuthException if an error occurs while updating the provider config. + */ + public SamlProviderConfig updateSamlProviderConfig( + @NonNull SamlProviderConfig.UpdateRequest request) throws FirebaseAuthException { + return updateSamlProviderConfigOp(request).call(); + } + + /** + * Similar to {@link #updateSamlProviderConfig} but performs the operation asynchronously. + * + * @param request A non-null {@link SamlProviderConfig.UpdateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link SamlProviderConfig} + * instance corresponding to the updated provider config. If an error occurs while updating + * the provider config, the future throws a {@link FirebaseAuthException}. + * @throws NullPointerException if the provided update request is null. + * @throws IllegalArgumentException If the provided update request is invalid. + */ + public ApiFuture updateSamlProviderConfigAsync( + @NonNull SamlProviderConfig.UpdateRequest request) { + return updateSamlProviderConfigOp(request).callAsync(firebaseApp); + } + + private CallableOperation updateSamlProviderConfigOp( + final SamlProviderConfig.UpdateRequest request) { + checkNotNull(request, "Update request must not be null."); + checkArgument(!request.getProperties().isEmpty(), + "Update request must have at least one property set."); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected SamlProviderConfig execute() throws FirebaseAuthException { + return userManager.updateSamlProviderConfig(request); + } + }; + } + + /** + * Gets the SAML Auth provider config corresponding to the specified provider ID. + * + * @param providerId A provider ID string. + * @return An {@link SamlProviderConfig} instance. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with 'saml'. + * @throws FirebaseAuthException If an error occurs while retrieving the provider config. + */ + public SamlProviderConfig getSamlProviderConfig(@NonNull String providerId) + throws FirebaseAuthException { + return getSamlProviderConfigOp(providerId).call(); + } + + /** + * Similar to {@link #getSamlProviderConfig(String)} but performs the operation asynchronously. + * Page size is limited to 100 provider configs. + * + * @param providerId A provider ID string. + * @return An {@code ApiFuture} which will complete successfully with an + * {@link SamlProviderConfig} instance. If an error occurs while retrieving the provider + * config or if the specified provider ID does not exist, the future throws a + * {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with 'saml'. + */ + public ApiFuture getSamlProviderConfigAsync(@NonNull String providerId) { + return getSamlProviderConfigOp(providerId).callAsync(firebaseApp); + } + + private CallableOperation + getSamlProviderConfigOp(final String providerId) { + SamlProviderConfig.checkSamlProviderId(providerId); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected SamlProviderConfig execute() throws FirebaseAuthException { + return userManager.getSamlProviderConfig(providerId); + } + }; + } + + /** + * Gets a page of SAML Auth provider configs starting from the specified {@code pageToken}. Page + * size is limited to 100 provider configs. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @return A {@link ListProviderConfigsPage} instance. + * @throws IllegalArgumentException If the specified page token is empty. + * @throws FirebaseAuthException If an error occurs while retrieving provider config data. + */ + public ListProviderConfigsPage listSamlProviderConfigs( + @Nullable String pageToken) throws FirebaseAuthException { + return listSamlProviderConfigs( + pageToken, + FirebaseUserManager.MAX_LIST_PROVIDER_CONFIGS_RESULTS); + } + + /** + * Gets a page of SAML Auth provider configs starting from the specified {@code pageToken}. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @param maxResults Maximum number of provider configs to include in the returned page. This may + * not exceed 100. + * @return A {@link ListProviderConfigsPage} instance. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + * @throws FirebaseAuthException If an error occurs while retrieving provider config data. + */ + public ListProviderConfigsPage listSamlProviderConfigs( + @Nullable String pageToken, int maxResults) throws FirebaseAuthException { + return listSamlProviderConfigsOp(pageToken, maxResults).call(); + } + + /** + * Similar to {@link #listSamlProviderConfigs(String)} but performs the operation asynchronously. + * Page size is limited to 100 provider configs. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @return An {@code ApiFuture} which will complete successfully with a + * {@link ListProviderConfigsPage} instance. If an error occurs while retrieving provider + * config data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty. + */ + public ApiFuture> listSamlProviderConfigsAsync( + @Nullable String pageToken) { + int maxResults = FirebaseUserManager.MAX_LIST_PROVIDER_CONFIGS_RESULTS; + return listSamlProviderConfigsAsync(pageToken, maxResults); + } + + /** + * Similar to {@link #listSamlProviderConfigs(String, int)} but performs the operation + * asynchronously. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @param maxResults Maximum number of provider configs to include in the returned page. This may + * not exceed 100. + * @return An {@code ApiFuture} which will complete successfully with a + * {@link ListProviderConfigsPage} instance. If an error occurs while retrieving provider + * config data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + */ + public ApiFuture> listSamlProviderConfigsAsync( + @Nullable String pageToken, + int maxResults) { + return listSamlProviderConfigsOp(pageToken, maxResults).callAsync(firebaseApp); + } + + private CallableOperation, FirebaseAuthException> + listSamlProviderConfigsOp(@Nullable final String pageToken, final int maxResults) { + final FirebaseUserManager userManager = getUserManager(); + final DefaultSamlProviderConfigSource source = new DefaultSamlProviderConfigSource(userManager); + final ListProviderConfigsPage.Factory factory = + new ListProviderConfigsPage.Factory(source, maxResults, pageToken); + return + new CallableOperation, FirebaseAuthException>() { + @Override + protected ListProviderConfigsPage execute() + throws FirebaseAuthException { + return factory.create(); + } + }; + } + + /** + * Deletes the SAML Auth provider config identified by the specified provider ID. + * + * @param providerId A provider ID string. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with "saml.". + * @throws FirebaseAuthException If an error occurs while deleting the provider config. + */ + public void deleteSamlProviderConfig(@NonNull String providerId) throws FirebaseAuthException { + deleteSamlProviderConfigOp(providerId).call(); + } + + /** + * Similar to {@link #deleteSamlProviderConfig} but performs the operation asynchronously. + * + * @param providerId A provider ID string. + * @return An {@code ApiFuture} which will complete successfully when the specified provider + * config has been deleted. If an error occurs while deleting the provider config, the future + * throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with "saml.". + */ + public ApiFuture deleteSamlProviderConfigAsync(String providerId) { + return deleteSamlProviderConfigOp(providerId).callAsync(firebaseApp); + } + + private CallableOperation deleteSamlProviderConfigOp( + final String providerId) { + SamlProviderConfig.checkSamlProviderId(providerId); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected Void execute() throws FirebaseAuthException { + userManager.deleteSamlProviderConfig(providerId); + return null; + } + }; + } + + FirebaseUserManager getUserManager() { + return this.userManager.get(); + } + + Supplier threadSafeMemoize(final Supplier supplier) { + return Suppliers.memoize( + new Supplier() { + @Override + public T get() { + checkNotNull(supplier); + synchronized (lock) { + return supplier.get(); + } + } + }); + } + + protected abstract static class Builder> { + + private FirebaseApp firebaseApp; + private Supplier tokenFactory; + private Supplier idTokenVerifier; + private Supplier cookieVerifier; + private Supplier userManager; + + protected abstract T getThis(); + + public FirebaseApp getFirebaseApp() { + return firebaseApp; + } + + public T setFirebaseApp(FirebaseApp firebaseApp) { + this.firebaseApp = firebaseApp; + return getThis(); + } + + public T setIdTokenVerifier(Supplier idTokenVerifier) { + this.idTokenVerifier = idTokenVerifier; + return getThis(); + } + + public T setCookieVerifier(Supplier cookieVerifier) { + this.cookieVerifier = cookieVerifier; + return getThis(); + } + + T setUserManager(Supplier userManager) { + this.userManager = userManager; + return getThis(); + } + + T setTokenFactory(Supplier tokenFactory) { + this.tokenFactory = tokenFactory; + return getThis(); + } + } + + protected static > T populateBuilderFromApp( + Builder builder, final FirebaseApp app, @Nullable final String tenantId) { + return builder.setFirebaseApp(app) + .setTokenFactory( + new Supplier() { + @Override + public FirebaseTokenFactory get() { + return FirebaseTokenUtils.createTokenFactory(app, Clock.SYSTEM, tenantId); + } + }) + .setIdTokenVerifier( + new Supplier() { + @Override + public FirebaseTokenVerifier get() { + return FirebaseTokenUtils.createIdTokenVerifier(app, Clock.SYSTEM, tenantId); + } + }) + .setCookieVerifier( + new Supplier() { + @Override + public FirebaseTokenVerifier get() { + return FirebaseTokenUtils.createSessionCookieVerifier(app, Clock.SYSTEM, tenantId); + } + }) + .setUserManager( + new Supplier() { + @Override + public FirebaseUserManager get() { + return FirebaseUserManager.createUserManager(app, tenantId); + } + }); + } +} diff --git a/src/main/java/com/google/firebase/auth/ActionCodeSettings.java b/src/main/java/com/google/firebase/auth/ActionCodeSettings.java new file mode 100644 index 000000000..22dfda8f2 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/ActionCodeSettings.java @@ -0,0 +1,221 @@ +/* + * Copyright 2018 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.auth; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; + +import com.google.firebase.internal.NonNull; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Map; + +/** + * Defines the required continue/state URL with optional Android and iOS settings. Used when + * invoking the email action link generation APIs in {@link FirebaseAuth}. + */ +public final class ActionCodeSettings { + + private final Map properties; + + private ActionCodeSettings(Builder builder) { + checkArgument(!Strings.isNullOrEmpty(builder.url), "URL must not be null or empty"); + try { + new URL(builder.url); + } catch (MalformedURLException e) { + throw new IllegalArgumentException("Malformed URL string", e); + } + if (builder.androidInstallApp || !Strings.isNullOrEmpty(builder.androidMinimumVersion)) { + checkArgument(!Strings.isNullOrEmpty(builder.androidPackageName), + "Android package name is required when specifying other Android settings"); + } + ImmutableMap.Builder properties = ImmutableMap.builder() + .put("continueUrl", builder.url) + .put("canHandleCodeInApp", builder.handleCodeInApp); + if (!Strings.isNullOrEmpty(builder.dynamicLinkDomain)) { + properties.put("dynamicLinkDomain", builder.dynamicLinkDomain); + } + if (!Strings.isNullOrEmpty(builder.linkDomain)) { + properties.put("linkDomain", builder.linkDomain); + } + if (!Strings.isNullOrEmpty(builder.iosBundleId)) { + properties.put("iOSBundleId", builder.iosBundleId); + } + if (!Strings.isNullOrEmpty(builder.androidPackageName)) { + properties.put("androidPackageName", builder.androidPackageName); + if (!Strings.isNullOrEmpty(builder.androidMinimumVersion)) { + properties.put("androidMinimumVersion", builder.androidMinimumVersion); + } + if (builder.androidInstallApp) { + properties.put("androidInstallApp", builder.androidInstallApp); + } + } + this.properties = properties.build(); + } + + Map getProperties() { + return this.properties; + } + + /** + * Creates a new {@link ActionCodeSettings.Builder}. + * + * @return A {@link ActionCodeSettings.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String url; + private boolean handleCodeInApp; + private String dynamicLinkDomain; + private String linkDomain; + private String iosBundleId; + private String androidPackageName; + private String androidMinimumVersion; + private boolean androidInstallApp; + + private Builder() { } + + /** + * Sets the link continue/state URL. + * + *

This parameter has different meanings in different contexts: + * + *

    + *
  • When the link is handled in the web action widgets, this is the deep link in the + * {@code continueUrl} query parameter.
  • + *
  • When the link is handled in the app directly, this is the {@code continueUrl} query + * parameter in the deep link of the Dynamic Link.
  • + *
+ * + *

This parameter must be specified when creating a new {@link ActionCodeSettings} instance. + * + * @param url Continue/state URL string. + * @return This builder. + */ + public Builder setUrl(@NonNull String url) { + this.url = url; + return this; + } + + /** + * Specifies whether to open the link via a mobile app or a browser. The default is false. + * When set to true, the action code link is sent as a Universal Link or an Android App Link + * and is opened by the app if installed. In the false case, the code is sent to the web widget + * first and then redirects to the app if installed. + * + * @param handleCodeInApp true to open the link in the app, and false otherwise. + * @return This builder. + */ + public Builder setHandleCodeInApp(boolean handleCodeInApp) { + this.handleCodeInApp = handleCodeInApp; + return this; + } + + /** + * Sets the dynamic link domain to use for the current link if it is to be opened using + * Firebase Dynamic Links, as multiple dynamic link domains can be configured per project. This + * setting provides the ability to explicitly choose one. If none is provided, the oldest + * domain is used by default. + * + * @param dynamicLinkDomain Firebase Dynamic Link domain string. + * @return This builder. + * @deprecated Use {@link #setLinkDomain(String)} instead. + */ + @Deprecated + public Builder setDynamicLinkDomain(String dynamicLinkDomain) { + this.dynamicLinkDomain = dynamicLinkDomain; + return this; + } + + /** + * Sets the link domain to use for the current link if it is to be opened using + * {@code handleCodeInApp}, as multiple link domains can be configured per project. This + * setting provides the ability to explicitly choose one. If none is provided, the default + * Firebase Hosting domain will be used. + * + * @param linkDomain Link domain string. + * @return This builder. + */ + public Builder setLinkDomain(String linkDomain) { + this.linkDomain = linkDomain; + return this; + } + + /** + * Sets the bundle ID of the iOS app where the link should be handled if the + * application is already installed on the device. + * + * @param iosBundleId The iOS bundle ID string. + */ + public Builder setIosBundleId(String iosBundleId) { + this.iosBundleId = iosBundleId; + return this; + } + + /** + * Sets the Android package name of the app where the link should be handled if the + * Android app is installed. Must be specified when setting other Android-specific settings. + * + * @param androidPackageName Package name string. Must be specified, and must not be null + * or empty. + * @return This builder. + */ + public Builder setAndroidPackageName(String androidPackageName) { + this.androidPackageName = androidPackageName; + return this; + } + + /** + * Sets the minimum version for Android app. If the installed app is an older version, the user + * is taken to the Play Store to upgrade the app. + * + * @param androidMinimumVersion Minimum version string. + * @return This builder. + */ + public Builder setAndroidMinimumVersion(String androidMinimumVersion) { + this.androidMinimumVersion = androidMinimumVersion; + return this; + } + + /** + * Specifies whether to install the Android app if the device supports it and the app is not + * already installed. + * + * @param androidInstallApp true to install the app, and false otherwise. + * @return This builder. + */ + public Builder setAndroidInstallApp(boolean androidInstallApp) { + this.androidInstallApp = androidInstallApp; + return this; + } + + /** + * Builds a new {@link ActionCodeSettings}. + * + * @return A non-null {@link ActionCodeSettings}. + */ + public ActionCodeSettings build() { + return new ActionCodeSettings(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/AuthErrorCode.java b/src/main/java/com/google/firebase/auth/AuthErrorCode.java new file mode 100644 index 000000000..e612d9f7e --- /dev/null +++ b/src/main/java/com/google/firebase/auth/AuthErrorCode.java @@ -0,0 +1,121 @@ +/* + * Copyright 2020 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.auth; + +/** + * Error codes that can be raised by the Firebase Auth APIs. + */ +public enum AuthErrorCode { + + /** + * Failed to retrieve public key certificates required to verify JWTs. + */ + CERTIFICATE_FETCH_FAILED, + + /** + * No IdP configuration found for the given identifier. + */ + CONFIGURATION_NOT_FOUND, + + /** + * A user already exists with the provided email. + */ + EMAIL_ALREADY_EXISTS, + + /** + * No user record found for the given email, typically raised when + * generating a password reset link using an email for a user that + * is not already registered. + */ + EMAIL_NOT_FOUND, + + /** + * The specified ID token is expired. + */ + EXPIRED_ID_TOKEN, + + /** + * The specified session cookie is expired. + */ + EXPIRED_SESSION_COOKIE, + + /** + * The provided dynamic link domain is not configured or authorized for the current project. + */ + INVALID_DYNAMIC_LINK_DOMAIN, + + /** + * The provided hosting link domain is not configured or authorized for the current project. + */ + INVALID_HOSTING_LINK_DOMAIN, + + /** + * The specified ID token is invalid. + */ + INVALID_ID_TOKEN, + + /** + * The specified session cookie is invalid. + */ + INVALID_SESSION_COOKIE, + + /** + * A user already exists with the provided phone number. + */ + PHONE_NUMBER_ALREADY_EXISTS, + + /** + * The specified ID token has been revoked. + */ + REVOKED_ID_TOKEN, + + /** + * The specified session cookie has been revoked. + */ + REVOKED_SESSION_COOKIE, + + /** + * Tenant ID in the JWT does not match. + */ + TENANT_ID_MISMATCH, + + /** + * No tenant found for the given identifier. + */ + TENANT_NOT_FOUND, + + /** + * A user already exists with the provided UID. + */ + UID_ALREADY_EXISTS, + + /** + * The domain of the continue URL is not whitelisted. Whitelist the domain in the Firebase + * console. + */ + UNAUTHORIZED_CONTINUE_URL, + + /** + * No user record found for the given identifier. + */ + USER_NOT_FOUND, + + /** + * The user record is disabled. + */ + USER_DISABLED, +} diff --git a/src/main/java/com/google/firebase/auth/DeleteUsersResult.java b/src/main/java/com/google/firebase/auth/DeleteUsersResult.java new file mode 100644 index 000000000..0b911e1aa --- /dev/null +++ b/src/main/java/com/google/firebase/auth/DeleteUsersResult.java @@ -0,0 +1,74 @@ +/* + * Copyright 2020 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.auth; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.collect.ImmutableList; +import com.google.firebase.auth.internal.BatchDeleteResponse; +import com.google.firebase.internal.NonNull; +import java.util.List; + +/** + * Represents the result of the {@link FirebaseAuth#deleteUsersAsync(List)} API. + */ +public final class DeleteUsersResult { + + private final int successCount; + private final List errors; + + DeleteUsersResult(int users, BatchDeleteResponse response) { + ImmutableList.Builder errorsBuilder = ImmutableList.builder(); + List responseErrors = response.getErrors(); + if (responseErrors != null) { + checkArgument(users >= responseErrors.size()); + for (BatchDeleteResponse.ErrorInfo error : responseErrors) { + errorsBuilder.add(new ErrorInfo(error.getIndex(), error.getMessage())); + } + } + errors = errorsBuilder.build(); + successCount = users - errors.size(); + } + + /** + * Returns the number of users that were deleted successfully (possibly zero). Users that did not + * exist prior to calling {@link FirebaseAuth#deleteUsersAsync(List)} are considered to be + * successfully deleted. + */ + public int getSuccessCount() { + return successCount; + } + + /** + * Returns the number of users that failed to be deleted (possibly zero). + */ + public int getFailureCount() { + return errors.size(); + } + + /** + * A list of {@link ErrorInfo} instances describing the errors that were encountered during + * the deletion. Length of this list is equal to the return value of + * {@link #getFailureCount()}. + * + * @return A non-null list (possibly empty). + */ + @NonNull + public List getErrors() { + return errors; + } +} diff --git a/src/main/java/com/google/firebase/auth/EmailIdentifier.java b/src/main/java/com/google/firebase/auth/EmailIdentifier.java new file mode 100644 index 000000000..2786d5117 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/EmailIdentifier.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020 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.auth; + +import com.google.firebase.auth.internal.GetAccountInfoRequest; +import com.google.firebase.internal.NonNull; + +/** + * Used for looking up an account by email. + * + * @see {FirebaseAuth#getUsers} + */ +public final class EmailIdentifier extends UserIdentifier { + private final String email; + + public EmailIdentifier(@NonNull String email) { + UserRecord.checkEmail(email); + this.email = email; + } + + @Override + public String toString() { + return "EmailIdentifier(" + email + ")"; + } + + @Override + void populate(@NonNull GetAccountInfoRequest payload) { + payload.addEmail(email); + } + + @Override + boolean matches(@NonNull UserRecord userRecord) { + return email.equals(userRecord.getEmail()); + } +} diff --git a/src/main/java/com/google/firebase/auth/ErrorInfo.java b/src/main/java/com/google/firebase/auth/ErrorInfo.java index 7f7a5e346..cd150b63b 100644 --- a/src/main/java/com/google/firebase/auth/ErrorInfo.java +++ b/src/main/java/com/google/firebase/auth/ErrorInfo.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/ExportedUserRecord.java b/src/main/java/com/google/firebase/auth/ExportedUserRecord.java index e367f55ae..341080da3 100644 --- a/src/main/java/com/google/firebase/auth/ExportedUserRecord.java +++ b/src/main/java/com/google/firebase/auth/ExportedUserRecord.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package com.google.firebase.auth; import com.google.api.client.json.JsonFactory; +import com.google.common.io.BaseEncoding; import com.google.firebase.auth.internal.DownloadAccountResponse.User; import com.google.firebase.internal.Nullable; @@ -28,10 +29,17 @@ public class ExportedUserRecord extends UserRecord { private final String passwordHash; private final String passwordSalt; + private static final String REDACTED_BASE64 = BaseEncoding.base64Url().encode( + "REDACTED".getBytes()); ExportedUserRecord(User response, JsonFactory jsonFactory) { super(response, jsonFactory); - this.passwordHash = response.getPasswordHash(); + String passwordHash = response.getPasswordHash(); + if (passwordHash != null && !passwordHash.equals(REDACTED_BASE64)) { + this.passwordHash = passwordHash; + } else { + this.passwordHash = null; + } this.passwordSalt = response.getPasswordSalt(); } diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuth.java b/src/main/java/com/google/firebase/auth/FirebaseAuth.java index eb258952a..27e79960d 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseAuth.java +++ b/src/main/java/com/google/firebase/auth/FirebaseAuth.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,34 +16,11 @@ package com.google.firebase.auth; -import static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Preconditions.checkNotNull; -import static com.google.common.base.Preconditions.checkState; - -import com.google.api.client.json.JsonFactory; -import com.google.api.client.util.Clock; -import com.google.api.core.ApiFuture; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Strings; +import com.google.common.base.Supplier; import com.google.firebase.FirebaseApp; import com.google.firebase.ImplFirebaseTrampolines; -import com.google.firebase.auth.FirebaseUserManager.UserImportRequest; -import com.google.firebase.auth.ListUsersPage.DefaultUserSource; -import com.google.firebase.auth.ListUsersPage.PageFactory; -import com.google.firebase.auth.UserRecord.CreateRequest; -import com.google.firebase.auth.UserRecord.UpdateRequest; -import com.google.firebase.auth.internal.FirebaseTokenFactory; -import com.google.firebase.auth.internal.FirebaseTokenVerifier; -import com.google.firebase.auth.internal.KeyManagers; -import com.google.firebase.internal.CallableOperation; +import com.google.firebase.auth.multitenancy.TenantManager; import com.google.firebase.internal.FirebaseService; -import com.google.firebase.internal.NonNull; -import com.google.firebase.internal.Nullable; -import java.io.IOException; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; /** * This class is the entry point for all server-side Firebase Authentication actions. @@ -53,42 +30,19 @@ * custom tokens for use by client-side code, verifying Firebase ID Tokens received from clients, or * creating new FirebaseApp instances that are scoped to a particular authentication UID. */ -public class FirebaseAuth { - - private static final String ERROR_CUSTOM_TOKEN = "ERROR_CUSTOM_TOKEN"; - private static final String ERROR_INVALID_ID_TOKEN = "ERROR_INVALID_CREDENTIAL"; - private static final String ERROR_INVALID_SESSION_COOKIE = "ERROR_INVALID_COOKIE"; +public final class FirebaseAuth extends AbstractFirebaseAuth { - private final Clock clock; + private static final String SERVICE_ID = FirebaseAuth.class.getName(); - private final FirebaseApp firebaseApp; - private final KeyManagers keyManagers; - private final String projectId; - private final JsonFactory jsonFactory; - private final FirebaseUserManager userManager; - private final AtomicReference tokenFactory; - private final AtomicBoolean destroyed; - private final Object lock; + private final Supplier tenantManager; - private FirebaseAuth(FirebaseApp firebaseApp) { - this(firebaseApp, KeyManagers.getDefault(firebaseApp, Clock.SYSTEM), Clock.SYSTEM); + private FirebaseAuth(final Builder builder) { + super(builder); + tenantManager = threadSafeMemoize(builder.tenantManager); } - /** - * Constructor for injecting a GooglePublicKeysManager, which is used to verify tokens are - * correctly signed. This should only be used for testing to override the default key manager. - */ - @VisibleForTesting - FirebaseAuth(FirebaseApp firebaseApp, KeyManagers keyManagers, Clock clock) { - this.firebaseApp = checkNotNull(firebaseApp); - this.keyManagers = checkNotNull(keyManagers); - this.clock = checkNotNull(clock); - this.projectId = ImplFirebaseTrampolines.getProjectId(firebaseApp); - this.jsonFactory = firebaseApp.getOptions().getJsonFactory(); - this.userManager = new FirebaseUserManager(firebaseApp); - this.tokenFactory = new AtomicReference<>(null); - this.destroyed = new AtomicBoolean(false); - this.lock = new Object(); + public TenantManager getTenantManager() { + return tenantManager.get(); } /** @@ -107,897 +61,54 @@ public static FirebaseAuth getInstance() { * @return A FirebaseAuth instance. */ public static synchronized FirebaseAuth getInstance(FirebaseApp app) { - FirebaseAuthService service = ImplFirebaseTrampolines.getService(app, SERVICE_ID, - FirebaseAuthService.class); + FirebaseAuthService service = + ImplFirebaseTrampolines.getService(app, SERVICE_ID, FirebaseAuthService.class); if (service == null) { service = ImplFirebaseTrampolines.addService(app, new FirebaseAuthService(app)); } return service.getInstance(); } - /** - * Creates a new Firebase session cookie from the given ID token and options. The returned JWT - * can be set as a server-side session cookie with a custom cookie policy. - * - * @param idToken The Firebase ID token to exchange for a session cookie. - * @param options Additional options required to create the cookie. - * @return A Firebase session cookie string. - * @throws IllegalArgumentException If the ID token is null or empty, or if options is null. - * @throws FirebaseAuthException If an error occurs while generating the session cookie. - */ - public String createSessionCookie( - @NonNull String idToken, @NonNull SessionCookieOptions options) throws FirebaseAuthException { - return createSessionCookieOp(idToken, options).call(); - } - - /** - * Similar to {@link #createSessionCookie(String, SessionCookieOptions)} but performs the - * operation asynchronously. - * - * @param idToken The Firebase ID token to exchange for a session cookie. - * @param options Additional options required to create the cookie. - * @return An {@code ApiFuture} which will complete successfully with a session cookie string. - * If an error occurs while generating the cookie or if the specified ID token is invalid, - * the future throws a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the ID token is null or empty, or if options is null. - */ - public ApiFuture createSessionCookieAsync( - @NonNull String idToken, @NonNull SessionCookieOptions options) { - return createSessionCookieOp(idToken, options).callAsync(firebaseApp); - } - - private CallableOperation createSessionCookieOp( - final String idToken, final SessionCookieOptions options) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(idToken), "idToken must not be null or empty"); - checkNotNull(options, "options must not be null"); - return new CallableOperation() { - @Override - protected String execute() throws FirebaseAuthException { - return userManager.createSessionCookie(idToken, options); - } - }; - } - - /** - * Parses and verifies a Firebase session cookie. - * - *

If verified successfully, returns a parsed version of the cookie from which the UID and the - * other claims can be read. If the cookie is invalid, throws a {@link FirebaseAuthException}. - * - *

This method does not check whether the cookie has been revoked. See - * {@link #verifySessionCookie(String, boolean)}. - * - * @param cookie A Firebase session cookie string to verify and parse. - * @return A {@link FirebaseToken} representing the verified and decoded cookie. - */ - public FirebaseToken verifySessionCookie(String cookie) throws FirebaseAuthException { - return verifySessionCookie(cookie, false); - } - - /** - * Parses and verifies a Firebase session cookie. - * - *

If {@code checkRevoked} is true, additionally verifies that the cookie has not been - * revoked. - * - *

If verified successfully, returns a parsed version of the cookie from which the UID and the - * other claims can be read. If the cookie is invalid or has been revoked while - * {@code checkRevoked} is true, throws a {@link FirebaseAuthException}. - * - * @param cookie A Firebase session cookie string to verify and parse. - * @param checkRevoked A boolean indicating whether to check if the cookie was explicitly - * revoked. - * @return A {@link FirebaseToken} representing the verified and decoded cookie. - */ - public FirebaseToken verifySessionCookie( - String cookie, boolean checkRevoked) throws FirebaseAuthException { - return verifySessionCookieOp(cookie, checkRevoked).call(); - } - - /** - * Similar to {@link #verifySessionCookie(String)} but performs the operation asynchronously. - * - * @param cookie A Firebase session cookie string to verify and parse. - * @return An {@code ApiFuture} which will complete successfully with the parsed cookie, or - * unsuccessfully with the failure Exception. - */ - public ApiFuture verifySessionCookieAsync(String cookie) { - return verifySessionCookieAsync(cookie, false); - } - - /** - * Similar to {@link #verifySessionCookie(String, boolean)} but performs the operation - * asynchronously. - * - * @param cookie A Firebase session cookie string to verify and parse. - * @param checkRevoked A boolean indicating whether to check if the cookie was explicitly - * revoked. - * @return An {@code ApiFuture} which will complete successfully with the parsed cookie, or - * unsuccessfully with the failure Exception. - */ - public ApiFuture verifySessionCookieAsync(String cookie, boolean checkRevoked) { - return verifySessionCookieOp(cookie, checkRevoked).callAsync(firebaseApp); - } - - private CallableOperation verifySessionCookieOp( - final String cookie, final boolean checkRevoked) { - checkNotDestroyed(); - checkState(!Strings.isNullOrEmpty(projectId), - "Must initialize FirebaseApp with a project ID to call verifySessionCookie()"); - return new CallableOperation() { - @Override - public FirebaseToken execute() throws FirebaseAuthException { - FirebaseTokenVerifier firebaseTokenVerifier = - FirebaseTokenVerifier.createSessionCookieVerifier(projectId, keyManagers, clock); - FirebaseToken firebaseToken; - try { - firebaseToken = FirebaseToken.parse(jsonFactory, cookie); - } catch (IOException e) { - throw new FirebaseAuthException(ERROR_INVALID_SESSION_COOKIE, - "Failed to parse cookie", e); - } - // This will throw a FirebaseAuthException with details on how the token is invalid. - firebaseTokenVerifier.verifyTokenAndSignature(firebaseToken.getToken()); - - if (checkRevoked) { - checkRevoked(firebaseToken, "session cookie", - FirebaseUserManager.SESSION_COOKIE_REVOKED_ERROR); - } - return firebaseToken; - } - }; - } - - private void checkRevoked( - FirebaseToken firebaseToken, String label, String errorCode) throws FirebaseAuthException { - String uid = firebaseToken.getUid(); - UserRecord user = userManager.getUserById(uid); - long issuedAt = (long) firebaseToken.getClaims().get("iat"); - if (user.getTokensValidAfterTimestamp() > issuedAt * 1000) { - throw new FirebaseAuthException(errorCode, "Firebase " + label + " revoked"); - } - } - - /** - * Creates a Firebase custom token for the given UID. This token can then be sent back to a client - * application to be used with the - * signInWithCustomToken - * authentication API. - * - *

{@link FirebaseApp} must have been initialized with service account credentials to use - * call this method. - * - * @param uid The UID to store in the token. This identifies the user to other Firebase services - * (Realtime Database, Firebase Auth, etc.). Should be less than 128 characters. - * @return A Firebase custom token string. - * @throws IllegalArgumentException If the specified uid is null or empty, or if the app has not - * been initialized with service account credentials. - * @throws FirebaseAuthException If an error occurs while generating the custom token. - */ - public String createCustomToken(@NonNull String uid) throws FirebaseAuthException { - return createCustomToken(uid, null); - } - - /** - * Creates a Firebase custom token for the given UID, containing the specified additional - * claims. This token can then be sent back to a client application to be used with the - * signInWithCustomToken - * authentication API. - * - *

This method attempts to generate a token using: - *

    - *
  1. the private key of {@link FirebaseApp}'s service account credentials, if provided at - * initialization. - *
  2. the IAM service - * if a service account email was specified via - * {@link com.google.firebase.FirebaseOptions.Builder#setServiceAccountId(String)}. - *
  3. the App Identity - * service if the code is deployed in the Google App Engine standard environment. - *
  4. the - * local Metadata server if the code is deployed in a different GCP-managed environment - * like Google Compute Engine. - *
- * - *

This method throws an exception when all the above fail. - * - * @param uid The UID to store in the token. This identifies the user to other Firebase services - * (Realtime Database, Firebase Auth, etc.). Should be less than 128 characters. - * @param developerClaims Additional claims to be stored in the token (and made available to - * security rules in Database, Storage, etc.). These must be able to be serialized to JSON - * (e.g. contain only Maps, Arrays, Strings, Booleans, Numbers, etc.) - * @return A Firebase custom token string. - * @throws IllegalArgumentException If the specified uid is null or empty. - * @throws IllegalStateException If the SDK fails to discover a viable approach for signing - * tokens. - * @throws FirebaseAuthException If an error occurs while generating the custom token. - */ - public String createCustomToken(@NonNull String uid, - @Nullable Map developerClaims) throws FirebaseAuthException { - return createCustomTokenOp(uid, developerClaims).call(); - } - - /** - * Similar to {@link #createCustomToken(String)} but performs the operation asynchronously. - * - * @param uid The UID to store in the token. This identifies the user to other Firebase services - * (Realtime Database, Firebase Auth, etc.). Should be less than 128 characters. - * @return An {@code ApiFuture} which will complete successfully with the created Firebase custom - * token, or unsuccessfully with the failure Exception. - * @throws IllegalArgumentException If the specified uid is null or empty, or if the app has not - * been initialized with service account credentials. - */ - public ApiFuture createCustomTokenAsync(@NonNull String uid) { - return createCustomTokenAsync(uid, null); - } - - /** - * Similar to {@link #createCustomToken(String, Map)} but performs the operation - * asynchronously. - * - * @param uid The UID to store in the token. This identifies the user to other Firebase services - * (Realtime Database, Storage, etc.). Should be less than 128 characters. - * @param developerClaims Additional claims to be stored in the token (and made available to - * security rules in Database, Storage, etc.). These must be able to be serialized to JSON - * (e.g. contain only Maps, Arrays, Strings, Booleans, Numbers, etc.) - * @return An {@code ApiFuture} which will complete successfully with the created Firebase custom - * token, or unsuccessfully with the failure Exception. - * @throws IllegalArgumentException If the specified uid is null or empty, or if the app has not - * been initialized with service account credentials. - */ - public ApiFuture createCustomTokenAsync( - @NonNull String uid, @Nullable Map developerClaims) { - return createCustomTokenOp(uid, developerClaims).callAsync(firebaseApp); - } - - private CallableOperation createCustomTokenOp( - final String uid, final Map developerClaims) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); - final FirebaseTokenFactory tokenFactory = ensureTokenFactory(); - return new CallableOperation() { - @Override - public String execute() throws FirebaseAuthException { - try { - return tokenFactory.createSignedCustomAuthTokenForUser(uid, developerClaims); - } catch (IOException e) { - throw new FirebaseAuthException(ERROR_CUSTOM_TOKEN, - "Failed to generate a custom token", e); - } - } - }; - } - - private FirebaseTokenFactory ensureTokenFactory() { - FirebaseTokenFactory result = this.tokenFactory.get(); - if (result == null) { - synchronized (lock) { - result = this.tokenFactory.get(); - if (result == null) { - try { - result = FirebaseTokenFactory.fromApp(firebaseApp, clock); - this.tokenFactory.set(result); - } catch (IOException e) { - throw new IllegalStateException( - "Failed to initialize FirebaseTokenFactory. Make sure to initialize the SDK " - + "with service account credentials or specify a service account " - + "ID with iam.serviceAccounts.signBlob permission. Please refer to " - + "https://firebase.google.com/docs/auth/admin/create-custom-tokens for more " - + "details on creating custom tokens.", e); + private static FirebaseAuth fromApp(final FirebaseApp app) { + return populateBuilderFromApp(builder(), app, null) + .setTenantManager(new Supplier() { + @Override + public TenantManager get() { + return new TenantManager(app); } - } - } - } - return result; - } - - /** - * Parses and verifies a Firebase ID Token. - * - *

A Firebase application can identify itself to a trusted backend server by sending its - * Firebase ID Token (accessible via the {@code getToken} API in the Firebase Authentication - * client) with its requests. The backend server can then use the {@code verifyIdToken()} method - * to verify that the token is valid. This method ensures that the token is correctly signed, - * has not expired, and it was issued to the Firebase project associated with this - * {@link FirebaseAuth} instance. - * - *

This method does not check whether a token has been revoked. Use - * {@link #verifyIdToken(String, boolean)} to perform an additional revocation check. - * - * @param token A Firebase ID token string to parse and verify. - * @return A {@link FirebaseToken} representing the verified and decoded token. - * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} - * instance does not have a project ID associated with it. - * @throws FirebaseAuthException If an error occurs while parsing or validating the token. - */ - public FirebaseToken verifyIdToken(@NonNull String token) throws FirebaseAuthException { - return verifyIdToken(token, false); - } - - /** - * Parses and verifies a Firebase ID Token. - * - *

A Firebase application can identify itself to a trusted backend server by sending its - * Firebase ID Token (accessible via the {@code getToken} API in the Firebase Authentication - * client) with its requests. The backend server can then use the {@code verifyIdToken()} method - * to verify that the token is valid. This method ensures that the token is correctly signed, - * has not expired, and it was issued to the Firebase project associated with this - * {@link FirebaseAuth} instance. - * - *

If {@code checkRevoked} is set to true, this method performs an additional check to see - * if the ID token has been revoked since it was issues. This requires making an additional - * remote API call. - * - * @param token A Firebase ID token string to parse and verify. - * @param checkRevoked A boolean denoting whether to check if the tokens were revoked. - * @return A {@link FirebaseToken} representing the verified and decoded token. - * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} - * instance does not have a project ID associated with it. - * @throws FirebaseAuthException If an error occurs while parsing or validating the token. - */ - public FirebaseToken verifyIdToken( - @NonNull String token, boolean checkRevoked) throws FirebaseAuthException { - return verifyIdTokenOp(token, checkRevoked).call(); - } - - /** - * Similar to {@link #verifyIdToken(String)} but performs the operation asynchronously. - * - * @param token A Firebase ID Token to verify and parse. - * @return An {@code ApiFuture} which will complete successfully with the parsed token, or - * unsuccessfully with a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} - * instance does not have a project ID associated with it. - */ - public ApiFuture verifyIdTokenAsync(@NonNull String token) { - return verifyIdTokenAsync(token, false); - } - - /** - * Similar to {@link #verifyIdToken(String, boolean)} but performs the operation asynchronously. - * - * @param token A Firebase ID Token to verify and parse. - * @param checkRevoked A boolean denoting whether to check if the tokens were revoked. - * @return An {@code ApiFuture} which will complete successfully with the parsed token, or - * unsuccessfully with a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} - * instance does not have a project ID associated with it. - */ - public ApiFuture verifyIdTokenAsync(@NonNull String token, boolean checkRevoked) { - return verifyIdTokenOp(token, checkRevoked).callAsync(firebaseApp); - } - - private CallableOperation verifyIdTokenOp( - final String token, final boolean checkRevoked) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(token), "ID token must not be null or empty"); - checkArgument(!Strings.isNullOrEmpty(projectId), - "Must initialize FirebaseApp with a project ID to call verifyIdToken()"); - return new CallableOperation() { - @Override - protected FirebaseToken execute() throws FirebaseAuthException { - FirebaseTokenVerifier firebaseTokenVerifier = - FirebaseTokenVerifier.createIdTokenVerifier(projectId, keyManagers, clock); - FirebaseToken firebaseToken; - try { - firebaseToken = FirebaseToken.parse(jsonFactory, token); - } catch (IOException e) { - throw new FirebaseAuthException(ERROR_INVALID_ID_TOKEN, "Failed to parse token", e); - } - - // This will throw a FirebaseAuthException with details on how the token is invalid. - firebaseTokenVerifier.verifyTokenAndSignature(firebaseToken.getToken()); - - if (checkRevoked) { - checkRevoked(firebaseToken, "auth token", FirebaseUserManager.ID_TOKEN_REVOKED_ERROR); - } - return firebaseToken; - } - }; - } - - /** - * Revokes all refresh tokens for the specified user. - * - *

Updates the user's tokensValidAfterTimestamp to the current UTC time expressed in - * milliseconds since the epoch and truncated to 1 second accuracy. It is important that the - * server on which this is called has its clock set correctly and synchronized. - * - *

While this will revoke all sessions for a specified user and disable any new ID tokens for - * existing sessions from getting minted, existing ID tokens may remain active until their - * natural expiration (one hour). - * To verify that ID tokens are revoked, use {@link #verifyIdTokenAsync(String, boolean)}. - * - * @param uid The user id for which tokens are revoked. - * @throws IllegalArgumentException If the user ID is null or empty. - * @throws FirebaseAuthException If an error occurs while revoking tokens. - */ - public void revokeRefreshTokens(@NonNull String uid) throws FirebaseAuthException { - revokeRefreshTokensOp(uid).call(); - } - - /** - * Similar to {@link #revokeRefreshTokens(String)} but performs the operation asynchronously. - * - * @param uid The user id for which tokens are revoked. - * @return An {@code ApiFuture} which will complete successfully or fail with a - * {@link FirebaseAuthException} in the event of an error. - * @throws IllegalArgumentException If the user ID is null or empty. - */ - public ApiFuture revokeRefreshTokensAsync(@NonNull String uid) { - return revokeRefreshTokensOp(uid).callAsync(firebaseApp); + }) + .build(); } - private CallableOperation revokeRefreshTokensOp(final String uid) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); - return new CallableOperation() { - @Override - protected Void execute() throws FirebaseAuthException { - int currentTimeSeconds = (int) (System.currentTimeMillis() / 1000); - UpdateRequest request = new UpdateRequest(uid).setValidSince(currentTimeSeconds); - userManager.updateUser(request, jsonFactory); - return null; - } - }; - } - - /** - * Gets the user data corresponding to the specified user ID. - * - * @param uid A user ID string. - * @return A {@link UserRecord} instance. - * @throws IllegalArgumentException If the user ID string is null or empty. - * @throws FirebaseAuthException If an error occurs while retrieving user data. - */ - public UserRecord getUser(@NonNull String uid) throws FirebaseAuthException { - return getUserOp(uid).call(); - } - - /** - * Similar to {@link #getUser(String)} but performs the operation asynchronously. - * - * @param uid A user ID string. - * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} - * instance. If an error occurs while retrieving user data or if the specified user ID does - * not exist, the future throws a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the user ID string is null or empty. - */ - public ApiFuture getUserAsync(@NonNull String uid) { - return getUserOp(uid).callAsync(firebaseApp); - } - - private CallableOperation getUserOp(final String uid) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); - return new CallableOperation() { - @Override - protected UserRecord execute() throws FirebaseAuthException { - return userManager.getUserById(uid); - } - }; - } - - /** - * Gets the user data corresponding to the specified user email. - * - * @param email A user email address string. - * @return A {@link UserRecord} instance. - * @throws IllegalArgumentException If the email is null or empty. - * @throws FirebaseAuthException If an error occurs while retrieving user data. - */ - public UserRecord getUserByEmail(@NonNull String email) throws FirebaseAuthException { - return getUserByEmailOp(email).call(); - } - - /** - * Similar to {@link #getUserByEmail(String)} but performs the operation asynchronously. - * - * @param email A user email address string. - * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} - * instance. If an error occurs while retrieving user data or if the email address does not - * correspond to a user, the future throws a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the email is null or empty. - */ - public ApiFuture getUserByEmailAsync(@NonNull String email) { - return getUserByEmailOp(email).callAsync(firebaseApp); - } - - private CallableOperation getUserByEmailOp( - final String email) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(email), "email must not be null or empty"); - return new CallableOperation() { - @Override - protected UserRecord execute() throws FirebaseAuthException { - return userManager.getUserByEmail(email); - } - }; - } - - /** - * Gets the user data corresponding to the specified user phone number. - * - * @param phoneNumber A user phone number string. - * @return A a {@link UserRecord} instance. - * @throws IllegalArgumentException If the phone number is null or empty. - * @throws FirebaseAuthException If an error occurs while retrieving user data. - */ - public UserRecord getUserByPhoneNumber(@NonNull String phoneNumber) throws FirebaseAuthException { - return getUserByPhoneNumberOp(phoneNumber).call(); - } - - /** - * Gets the user data corresponding to the specified user phone number. - * - * @param phoneNumber A user phone number string. - * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} - * instance. If an error occurs while retrieving user data or if the phone number does not - * correspond to a user, the future throws a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the phone number is null or empty. - */ - public ApiFuture getUserByPhoneNumberAsync(@NonNull String phoneNumber) { - return getUserByPhoneNumberOp(phoneNumber).callAsync(firebaseApp); - } - - private CallableOperation getUserByPhoneNumberOp( - final String phoneNumber) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(phoneNumber), "phone number must not be null or empty"); - return new CallableOperation() { - @Override - protected UserRecord execute() throws FirebaseAuthException { - return userManager.getUserByPhoneNumber(phoneNumber); - } - }; - } - - /** - * Gets a page of users starting from the specified {@code pageToken}. Page size will be - * limited to 1000 users. - * - * @param pageToken A non-empty page token string, or null to retrieve the first page of users. - * @return A {@link ListUsersPage} instance. - * @throws IllegalArgumentException If the specified page token is empty. - * @throws FirebaseAuthException If an error occurs while retrieving user data. - */ - public ListUsersPage listUsers(@Nullable String pageToken) throws FirebaseAuthException { - return listUsers(pageToken, FirebaseUserManager.MAX_LIST_USERS_RESULTS); - } - - /** - * Gets a page of users starting from the specified {@code pageToken}. - * - * @param pageToken A non-empty page token string, or null to retrieve the first page of users. - * @param maxResults Maximum number of users to include in the returned page. This may not - * exceed 1000. - * @return A {@link ListUsersPage} instance. - * @throws IllegalArgumentException If the specified page token is empty, or max results value - * is invalid. - * @throws FirebaseAuthException If an error occurs while retrieving user data. - */ - public ListUsersPage listUsers( - @Nullable String pageToken, int maxResults) throws FirebaseAuthException { - return listUsersOp(pageToken, maxResults).call(); - } - - /** - * Similar to {@link #listUsers(String)} but performs the operation asynchronously. - * - * @param pageToken A non-empty page token string, or null to retrieve the first page of users. - * @return An {@code ApiFuture} which will complete successfully with a {@link ListUsersPage} - * instance. If an error occurs while retrieving user data, the future throws an exception. - * @throws IllegalArgumentException If the specified page token is empty. - */ - public ApiFuture listUsersAsync(@Nullable String pageToken) { - return listUsersAsync(pageToken, FirebaseUserManager.MAX_LIST_USERS_RESULTS); - } - - /** - * Similar to {@link #listUsers(String, int)} but performs the operation asynchronously. - * - * @param pageToken A non-empty page token string, or null to retrieve the first page of users. - * @param maxResults Maximum number of users to include in the returned page. This may not - * exceed 1000. - * @return An {@code ApiFuture} which will complete successfully with a {@link ListUsersPage} - * instance. If an error occurs while retrieving user data, the future throws an exception. - * @throws IllegalArgumentException If the specified page token is empty, or max results value - * is invalid. - */ - public ApiFuture listUsersAsync( - @Nullable final String pageToken, final int maxResults) { - return listUsersOp(pageToken, maxResults).callAsync(firebaseApp); - } - - private CallableOperation listUsersOp( - @Nullable String pageToken, int maxResults) { - checkNotDestroyed(); - final PageFactory factory = new PageFactory( - new DefaultUserSource(userManager, jsonFactory), maxResults, pageToken); - return new CallableOperation() { - @Override - protected ListUsersPage execute() throws FirebaseAuthException { - return factory.create(); - } - }; - } - - /** - * Creates a new user account with the attributes contained in the specified - * {@link CreateRequest}. - * - * @param request A non-null {@link CreateRequest} instance. - * @return A {@link UserRecord} instance corresponding to the newly created account. - * @throws NullPointerException if the provided request is null. - * @throws FirebaseAuthException if an error occurs while creating the user account. - */ - public UserRecord createUser(@NonNull CreateRequest request) throws FirebaseAuthException { - return createUserOp(request).call(); - } - - /** - * Similar to {@link #createUser(CreateRequest)} but performs the operation asynchronously. - * - * @param request A non-null {@link CreateRequest} instance. - * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} - * instance corresponding to the newly created account. If an error occurs while creating the - * user account, the future throws a {@link FirebaseAuthException}. - * @throws NullPointerException if the provided request is null. - */ - public ApiFuture createUserAsync(@NonNull CreateRequest request) { - return createUserOp(request).callAsync(firebaseApp); - } - - private CallableOperation createUserOp( - final CreateRequest request) { - checkNotDestroyed(); - checkNotNull(request, "create request must not be null"); - return new CallableOperation() { - @Override - protected UserRecord execute() throws FirebaseAuthException { - String uid = userManager.createUser(request); - return userManager.getUserById(uid); - } - }; - } - - /** - * Updates an existing user account with the attributes contained in the specified - * {@link UpdateRequest}. - * - * @param request A non-null {@link UpdateRequest} instance. - * @return A {@link UserRecord} instance corresponding to the updated user account. - * account, the task fails with a {@link FirebaseAuthException}. - * @throws NullPointerException if the provided update request is null. - * @throws FirebaseAuthException if an error occurs while updating the user account. - */ - public UserRecord updateUser(@NonNull UpdateRequest request) throws FirebaseAuthException { - return updateUserOp(request).call(); - } - - /** - * Similar to {@link #updateUser(UpdateRequest)} but performs the operation asynchronously. - * - * @param request A non-null {@link UpdateRequest} instance. - * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} - * instance corresponding to the updated user account. If an error occurs while updating the - * user account, the future throws a {@link FirebaseAuthException}. - */ - public ApiFuture updateUserAsync(@NonNull UpdateRequest request) { - return updateUserOp(request).callAsync(firebaseApp); - } - - private CallableOperation updateUserOp( - final UpdateRequest request) { - checkNotDestroyed(); - checkNotNull(request, "update request must not be null"); - return new CallableOperation() { - @Override - protected UserRecord execute() throws FirebaseAuthException { - userManager.updateUser(request, jsonFactory); - return userManager.getUserById(request.getUid()); - } - }; - } - - /** - * Sets the specified custom claims on an existing user account. A null claims value removes - * any claims currently set on the user account. The claims should serialize into a valid JSON - * string. The serialized claims must not be larger than 1000 characters. - * - * @param uid A user ID string. - * @param claims A map of custom claims or null. - * @throws FirebaseAuthException If an error occurs while updating custom claims. - * @throws IllegalArgumentException If the user ID string is null or empty, or the claims - * payload is invalid or too large. - */ - public void setCustomUserClaims(@NonNull String uid, - @Nullable Map claims) throws FirebaseAuthException { - setCustomUserClaimsOp(uid, claims).call(); - } - - /** - * @deprecated Use {@link #setCustomUserClaims(String, Map)} instead. - */ - public void setCustomClaims(@NonNull String uid, - @Nullable Map claims) throws FirebaseAuthException { - setCustomUserClaims(uid, claims); - } - - /** - * Similar to {@link #setCustomUserClaims(String, Map)} but performs the operation asynchronously. - * - * @param uid A user ID string. - * @param claims A map of custom claims or null. - * @return An {@code ApiFuture} which will complete successfully when the user account has been - * updated. If an error occurs while deleting the user account, the future throws a - * {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the user ID string is null or empty. - */ - public ApiFuture setCustomUserClaimsAsync( - @NonNull String uid, @Nullable Map claims) { - return setCustomUserClaimsOp(uid, claims).callAsync(firebaseApp); - } - - private CallableOperation setCustomUserClaimsOp( - final String uid, final Map claims) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); - return new CallableOperation() { - @Override - protected Void execute() throws FirebaseAuthException { - final UpdateRequest request = new UpdateRequest(uid).setCustomClaims(claims); - userManager.updateUser(request, jsonFactory); - return null; - } - }; - } - - /** - * Deletes the user identified by the specified user ID. - * - * @param uid A user ID string. - * @throws IllegalArgumentException If the user ID string is null or empty. - * @throws FirebaseAuthException If an error occurs while deleting the user. - */ - public void deleteUser(@NonNull String uid) throws FirebaseAuthException { - deleteUserOp(uid).call(); - } - - /** - * Similar to {@link #deleteUser(String)} but performs the operation asynchronously. - * - * @param uid A user ID string. - * @return An {@code ApiFuture} which will complete successfully when the specified user account - * has been deleted. If an error occurs while deleting the user account, the future throws a - * {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the user ID string is null or empty. - */ - public ApiFuture deleteUserAsync(final String uid) { - return deleteUserOp(uid).callAsync(firebaseApp); - } - - private CallableOperation deleteUserOp(final String uid) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); - return new CallableOperation() { - @Override - protected Void execute() throws FirebaseAuthException { - userManager.deleteUser(uid); - return null; - } - }; - } - - /** - * Imports the provided list of users into Firebase Auth. At most 1000 users can be imported at a - * time. This operation is optimized for bulk imports and will ignore checks on identifier - * uniqueness which could result in duplications. - * - *

{@link UserImportOptions} is required to import users with passwords. See - * {@link #importUsers(List, UserImportOptions)}. - * - * @param users A non-empty list of users to be imported. Length must not exceed 1000. - * @return A {@link UserImportResult} instance. - * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 - * elements. Or if at least one user specifies a password. - * @throws FirebaseAuthException If an error occurs while importing users. - */ - public UserImportResult importUsers(List users) throws FirebaseAuthException { - return importUsers(users, null); - } + private static class FirebaseAuthService extends FirebaseService { - /** - * Imports the provided list of users into Firebase Auth. At most 1000 users can be imported at a - * time. This operation is optimized for bulk imports and will ignore checks on identifier - * uniqueness which could result in duplications. - * - * @param users A non-empty list of users to be imported. Length must not exceed 1000. - * @param options a {@link UserImportOptions} instance or null. Required when importing users - * with passwords. - * @return A {@link UserImportResult} instance. - * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 - * elements. Or if at least one user specifies a password, and options is null. - * @throws FirebaseAuthException If an error occurs while importing users. - */ - public UserImportResult importUsers(List users, - @Nullable UserImportOptions options) throws FirebaseAuthException { - return importUsersOp(users, options).call(); + FirebaseAuthService(FirebaseApp app) { + super(SERVICE_ID, FirebaseAuth.fromApp(app)); + } } - /** - * Similar to {@link #importUsers(List)} but performs the operation asynchronously. - * - * @param users A non-empty list of users to be imported. Length must not exceed 1000. - * @return An {@code ApiFuture} which will complete successfully when the user accounts are - * imported. If an error occurs while importing the users, the future throws a - * {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 - * elements. Or if at least one user specifies a password. - */ - public ApiFuture importUsersAsync(List users) { - return importUsersAsync(users, null); + static Builder builder() { + return new Builder(); } - /** - * Similar to {@link #importUsers(List, UserImportOptions)} but performs the operation - * asynchronously. - * - * @param users A non-empty list of users to be imported. Length must not exceed 1000. - * @param options a {@link UserImportOptions} instance or null. Required when importing users - * with passwords. - * @return An {@code ApiFuture} which will complete successfully when the user accounts are - * imported. If an error occurs while importing the users, the future throws a - * {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 - * elements. Or if at least one user specifies a password, and options is null. - */ - public ApiFuture importUsersAsync(List users, - @Nullable UserImportOptions options) { - return importUsersOp(users, options).callAsync(firebaseApp); - } + static class Builder extends AbstractFirebaseAuth.Builder { - private CallableOperation importUsersOp( - List users, UserImportOptions options) { - checkNotDestroyed(); - final UserImportRequest request = new UserImportRequest(users, options, jsonFactory); - return new CallableOperation() { - @Override - protected UserImportResult execute() throws FirebaseAuthException { - return userManager.importUsers(request); - } - }; - } + private Supplier tenantManager; - @VisibleForTesting - FirebaseUserManager getUserManager() { - return this.userManager; - } + private Builder() { } - private void checkNotDestroyed() { - synchronized (lock) { - checkState(!destroyed.get(), "FirebaseAuth instance is no longer alive. This happens when " - + "the parent FirebaseApp instance has been deleted."); + @Override + protected Builder getThis() { + return this; } - } - private void destroy() { - synchronized (lock) { - destroyed.set(true); + public Builder setTenantManager(Supplier tenantManager) { + this.tenantManager = tenantManager; + return this; } - } - private static final String SERVICE_ID = FirebaseAuth.class.getName(); - - private static class FirebaseAuthService extends FirebaseService { - - FirebaseAuthService(FirebaseApp app) { - super(SERVICE_ID, new FirebaseAuth(app)); - } - - @Override - public void destroy() { - instance.destroy(); + public FirebaseAuth build() { + return new FirebaseAuth(this); } } } diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuthException.java b/src/main/java/com/google/firebase/auth/FirebaseAuthException.java index 2314a69d2..915abe62e 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseAuthException.java +++ b/src/main/java/com/google/firebase/auth/FirebaseAuthException.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,17 +16,11 @@ package com.google.firebase.auth; -// TODO: Move it out from firebase-common. Temporary host it their for -// database's integration.http://b/27624510. - -// TODO: Decide if changing this not enforcing an error code. Need to align -// with the decision in http://b/27677218. Also, need to turn this into abstract later. - -import static com.google.common.base.Preconditions.checkArgument; - -import com.google.common.base.Strings; +import com.google.firebase.ErrorCode; import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; /** * Generic exception related to Firebase Authentication. Check the error code and message for more @@ -34,22 +28,24 @@ */ public class FirebaseAuthException extends FirebaseException { - private final String errorCode; + private final AuthErrorCode errorCode; - public FirebaseAuthException(@NonNull String errorCode, @NonNull String detailMessage) { - this(errorCode, detailMessage, null); + public FirebaseAuthException( + @NonNull ErrorCode errorCode, + @NonNull String message, + Throwable cause, + IncomingHttpResponse response, + AuthErrorCode authErrorCode) { + super(errorCode, message, cause, response); + this.errorCode = authErrorCode; } - public FirebaseAuthException(@NonNull String errorCode, @NonNull String detailMessage, - Throwable throwable) { - super(detailMessage, throwable); - checkArgument(!Strings.isNullOrEmpty(errorCode)); - this.errorCode = errorCode; + public FirebaseAuthException(FirebaseException base) { + this(base.getErrorCode(), base.getMessage(), base.getCause(), base.getHttpResponse(), null); } - /** Returns an error code that may provide more information about the error. */ - @NonNull - public String getErrorCode() { + @Nullable + public AuthErrorCode getAuthErrorCode() { return errorCode; } } diff --git a/src/main/java/com/google/firebase/auth/FirebaseToken.java b/src/main/java/com/google/firebase/auth/FirebaseToken.java index 1726250a4..ec1827b2d 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseToken.java +++ b/src/main/java/com/google/firebase/auth/FirebaseToken.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,164 +16,73 @@ package com.google.firebase.auth; -import com.google.api.client.auth.openidconnect.IdToken; -import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.webtoken.JsonWebSignature; -import com.google.api.client.util.Key; +import static com.google.common.base.Preconditions.checkArgument; -import java.io.IOException; +import com.google.common.collect.ImmutableMap; import java.util.Map; /** - * Implementation of a Parsed Firebase Token returned by {@link FirebaseAuth#verifyIdToken(String)}. - * It can used to get the uid and other attributes of the user provided in the Token. + * A decoded and verified Firebase token. Can be used to get the uid and other user attributes + * available in the token. See {@link FirebaseAuth#verifyIdToken(String)} and + * {@link FirebaseAuth#verifySessionCookie(String)} for details on how to obtain an instance of + * this class. */ public final class FirebaseToken { - private final FirebaseTokenImpl token; + private final Map claims; - FirebaseToken(FirebaseTokenImpl token) { - this.token = token; + FirebaseToken(Map claims) { + checkArgument(claims != null && claims.containsKey("sub"), + "Claims map must at least contain sub"); + this.claims = ImmutableMap.copyOf(claims); } - static FirebaseToken parse(JsonFactory jsonFactory, String tokenString) throws IOException { - try { - JsonWebSignature jws = - JsonWebSignature.parser(jsonFactory) - .setPayloadClass(FirebaseTokenImpl.Payload.class) - .parse(tokenString); - return new FirebaseToken( - new FirebaseTokenImpl( - jws.getHeader(), - (FirebaseTokenImpl.Payload) jws.getPayload(), - jws.getSignatureBytes(), - jws.getSignedContentBytes())); - } catch (IOException e) { - throw new IOException( - "Decoding Firebase ID token failed. Make sure you passed the entire string JWT " - + "which represents an ID token. See https://firebase.google.com/docs/auth/admin/" - + "verify-id-tokens for details on how to retrieve an ID token.", - e); - } + /** Returns the Uid for this token. */ + public String getUid() { + return (String) claims.get("sub"); } - /** Returns the Uid for the this token. */ - public String getUid() { - return token.getPayload().getSubject(); + /** Returns the tenant ID for this token. */ + public String getTenantId() { + Map firebase = (Map) claims.get("firebase"); + if (firebase == null) { + return null; + } + return (String) firebase.get("tenant"); } - /** Returns the Issuer for the this token. */ + /** Returns the Issuer for this token. */ public String getIssuer() { - return token.getPayload().getIssuer(); + return (String) claims.get("iss"); } /** Returns the user's display name. */ public String getName() { - return token.getPayload().getName(); + return (String) claims.get("name"); } /** Returns the Uri string of the user's profile photo. */ public String getPicture() { - return token.getPayload().getPicture(); + return (String) claims.get("picture"); } - /** + /** * Returns the e-mail address for this user, or {@code null} if it's unavailable. */ public String getEmail() { - return token.getPayload().getEmail(); + return (String) claims.get("email"); } - /** + /** * Indicates if the email address returned by {@link #getEmail()} has been verified as good. */ public boolean isEmailVerified() { - return token.getPayload().isEmailVerified(); + Object emailVerified = claims.get("email_verified"); + return emailVerified != null && (Boolean) emailVerified; } /** Returns a map of all of the claims on this token. */ public Map getClaims() { - return token.getPayload(); - } - - FirebaseTokenImpl getToken() { - return token; - } - - static class FirebaseTokenImpl extends IdToken { - - FirebaseTokenImpl( - Header header, Payload payload, byte[] signatureBytes, byte[] signedContentBytes) { - super(header, payload, signatureBytes, signedContentBytes); - } - - @Override - public Payload getPayload() { - return (Payload) super.getPayload(); - } - - /** Represents a FirebaseWebToken Payload. */ - public static class Payload extends IdToken.Payload { - - /** - * Timestamp of the last time this user authenticated with Firebase on the device receiving - * this token. - */ - @Key("auth_time") - private long authTime; - - /** User's primary email address. */ - @Key private String email; - - /** Indicates whether or not the e-mail field is verified to be a known-good address. */ - @Key("email_verified") - private boolean emailVerified; - - /** User's Display Name. */ - @Key private String name; - - /** URI of the User's profile picture. */ - @Key private String picture; - - /** - * Returns the UID of the user represented by this token. This is an alias for {@link - * #getSubject()} - */ - public String getUid() { - return getSubject(); - } - - /** - * Returns the time in seconds from the Unix Epoch that this user last authenticated with - * Firebase on this device. - */ - public long getAuthTime() { - return authTime; - } - - /** - * Returns the e-mail address for this user, or {@code null} if it's unavailable. - */ - public String getEmail() { - return email; - } - - /** - * Indicates if the email address returned by {@link #getEmail()} has been verified as good. - */ - public boolean isEmailVerified() { - return emailVerified; - } - - /** Returns the user's display name. */ - public String getName() { - return name; - } - - /** Returns the Uri string of the user's profile photo. */ - public String getPicture() { - return picture; - } - } + return this.claims; } } diff --git a/src/main/java/com/google/firebase/auth/FirebaseTokenUtils.java b/src/main/java/com/google/firebase/auth/FirebaseTokenUtils.java new file mode 100644 index 000000000..fb2e2d279 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/FirebaseTokenUtils.java @@ -0,0 +1,146 @@ +/* + * Copyright 2019 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.auth; + +import static com.google.common.base.Preconditions.checkState; + +import com.google.api.client.auth.openidconnect.IdTokenVerifier; +import com.google.api.client.googleapis.auth.oauth2.GooglePublicKeysManager; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.client.util.Clock; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.auth.internal.CryptoSigners; +import com.google.firebase.auth.internal.FirebaseTokenFactory; +import com.google.firebase.internal.Nullable; + +import java.io.IOException; + +final class FirebaseTokenUtils { + + private static final String ID_TOKEN_CERT_URL = + "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com"; + private static final String ID_TOKEN_ISSUER_PREFIX = "https://securetoken.google.com/"; + + private static final String SESSION_COOKIE_CERT_URL = + "https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys"; + private static final String SESSION_COOKIE_ISSUER_PREFIX = "https://session.firebase.google.com/"; + + // The default JsonFactory implementation we get from Google API client does not support parsing + // JSON strings with control characters in text. The public key certificates we get from Google + // auth servers contain some control characters, and therefore we must use a JsonFactory that is + // capable of parsing such text. + static final JsonFactory UNQUOTED_CTRL_CHAR_JSON_FACTORY = new GsonFactory(); + + private FirebaseTokenUtils() { } + + static FirebaseTokenFactory createTokenFactory(FirebaseApp firebaseApp, Clock clock) { + return createTokenFactory(firebaseApp, clock, null); + } + + static FirebaseTokenFactory createTokenFactory( + FirebaseApp firebaseApp, Clock clock, @Nullable String tenantId) { + try { + return new FirebaseTokenFactory( + firebaseApp.getOptions().getJsonFactory(), + clock, + CryptoSigners.getCryptoSigner(firebaseApp), + tenantId); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to initialize FirebaseTokenFactory. Make sure to initialize the SDK " + + "with service account credentials or specify a service account " + + "ID with iam.serviceAccounts.signBlob permission. Please refer to " + + "https://firebase.google.com/docs/auth/admin/create-custom-tokens for more " + + "details on creating custom tokens.", e); + } + } + + static FirebaseTokenVerifierImpl createIdTokenVerifier(FirebaseApp app, Clock clock) { + return createIdTokenVerifier(app, clock, null); + } + + static FirebaseTokenVerifierImpl createIdTokenVerifier( + FirebaseApp app, Clock clock, @Nullable String tenantId) { + String projectId = ImplFirebaseTrampolines.getProjectId(app); + checkState(!Strings.isNullOrEmpty(projectId), + "Must initialize FirebaseApp with a project ID to call verifyIdToken()"); + IdTokenVerifier idTokenVerifier = newIdTokenVerifier( + clock, ID_TOKEN_ISSUER_PREFIX, projectId); + GooglePublicKeysManager publicKeysManager = newPublicKeysManager( + app.getOptions(), clock, ID_TOKEN_CERT_URL); + return FirebaseTokenVerifierImpl.builder() + .setShortName("ID token") + .setMethod("verifyIdToken()") + .setDocUrl("https://firebase.google.com/docs/auth/admin/verify-id-tokens") + .setJsonFactory(app.getOptions().getJsonFactory()) + .setPublicKeysManager(publicKeysManager) + .setIdTokenVerifier(idTokenVerifier) + .setInvalidTokenErrorCode(AuthErrorCode.INVALID_ID_TOKEN) + .setExpiredTokenErrorCode(AuthErrorCode.EXPIRED_ID_TOKEN) + .setTenantId(tenantId) + .build(); + } + + static FirebaseTokenVerifierImpl createSessionCookieVerifier(FirebaseApp app, Clock clock) { + return createSessionCookieVerifier(app, clock, null); + } + + static FirebaseTokenVerifierImpl createSessionCookieVerifier( + FirebaseApp app, Clock clock, @Nullable String tenantId) { + String projectId = ImplFirebaseTrampolines.getProjectId(app); + checkState(!Strings.isNullOrEmpty(projectId), + "Must initialize FirebaseApp with a project ID to call verifySessionCookie()"); + IdTokenVerifier idTokenVerifier = newIdTokenVerifier( + clock, SESSION_COOKIE_ISSUER_PREFIX, projectId); + GooglePublicKeysManager publicKeysManager = newPublicKeysManager( + app.getOptions(), clock, SESSION_COOKIE_CERT_URL); + return FirebaseTokenVerifierImpl.builder() + .setShortName("session cookie") + .setMethod("verifySessionCookie()") + .setDocUrl("https://firebase.google.com/docs/auth/admin/manage-cookies") + .setInvalidTokenErrorCode(AuthErrorCode.INVALID_SESSION_COOKIE) + .setExpiredTokenErrorCode(AuthErrorCode.EXPIRED_SESSION_COOKIE) + .setJsonFactory(app.getOptions().getJsonFactory()) + .setPublicKeysManager(publicKeysManager) + .setIdTokenVerifier(idTokenVerifier) + .setTenantId(tenantId) + .build(); + } + + private static GooglePublicKeysManager newPublicKeysManager( + FirebaseOptions options, Clock clock, String certUrl) { + return new GooglePublicKeysManager.Builder( + options.getHttpTransport(), UNQUOTED_CTRL_CHAR_JSON_FACTORY) + .setClock(clock) + .setPublicCertsEncodedUrl(certUrl) + .build(); + } + + private static IdTokenVerifier newIdTokenVerifier( + Clock clock, String issuerPrefix, String projectId) { + return new IdTokenVerifier.Builder() + .setClock(clock) + .setAudience(ImmutableList.of(projectId)) + .setIssuer(issuerPrefix + projectId) + .build(); + } +} diff --git a/src/main/java/com/google/firebase/auth/FirebaseTokenVerifier.java b/src/main/java/com/google/firebase/auth/FirebaseTokenVerifier.java new file mode 100644 index 000000000..334fa60b9 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/FirebaseTokenVerifier.java @@ -0,0 +1,34 @@ +/* + * Copyright 2019 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.auth; + +/** + * An interface for verifying Firebase token strings. Exists mainly to facilitate easy testing + * and extension/decoration of the token verification functionality. + */ +interface FirebaseTokenVerifier { + + /** + * Verifies that the given token string is a valid Firebase JWT. + * + * @param token The token string to be verified. + * @return A decoded representation of the input token string. + * @throws FirebaseAuthException If the input token string fails to verify due to any reason. + */ + FirebaseToken verifyToken(String token) throws FirebaseAuthException; + +} diff --git a/src/main/java/com/google/firebase/auth/FirebaseTokenVerifierImpl.java b/src/main/java/com/google/firebase/auth/FirebaseTokenVerifierImpl.java new file mode 100644 index 000000000..421ea2f8c --- /dev/null +++ b/src/main/java/com/google/firebase/auth/FirebaseTokenVerifierImpl.java @@ -0,0 +1,408 @@ +/* + * Copyright 2019 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.auth; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.auth.openidconnect.IdToken; +import com.google.api.client.auth.openidconnect.IdToken.Payload; +import com.google.api.client.auth.openidconnect.IdTokenVerifier; +import com.google.api.client.googleapis.auth.oauth2.GooglePublicKeysManager; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.webtoken.JsonWebSignature.Header; +import com.google.api.client.util.ArrayMap; +import com.google.common.base.Joiner; +import com.google.common.base.Strings; +import com.google.firebase.ErrorCode; +import com.google.firebase.auth.internal.Utils; +import com.google.firebase.internal.Nullable; +import java.io.IOException; +import java.math.BigDecimal; +import java.security.GeneralSecurityException; +import java.security.PublicKey; +import java.util.List; + +/** + * The default implementation of the {@link FirebaseTokenVerifier} interface. Uses the Google API + * client's {@code IdToken} API to decode and verify token strings. Can be customized to verify + * both Firebase ID tokens and session cookies. + */ +final class FirebaseTokenVerifierImpl implements FirebaseTokenVerifier { + + private static final String RS256 = "RS256"; + private static final String FIREBASE_AUDIENCE = + "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit"; + + private final JsonFactory jsonFactory; + private final GooglePublicKeysManager publicKeysManager; + private final IdTokenVerifier idTokenVerifier; + private final String method; + private final String shortName; + private final String articledShortName; + private final String docUrl; + private final AuthErrorCode invalidTokenErrorCode; + private final AuthErrorCode expiredTokenErrorCode; + private final String tenantId; + + private FirebaseTokenVerifierImpl(Builder builder) { + this.jsonFactory = checkNotNull(builder.jsonFactory); + this.publicKeysManager = checkNotNull(builder.publicKeysManager); + this.idTokenVerifier = checkNotNull(builder.idTokenVerifier); + checkArgument(!Strings.isNullOrEmpty(builder.method), "method name must be specified"); + checkArgument(!Strings.isNullOrEmpty(builder.shortName), "shortName must be specified"); + checkArgument(!Strings.isNullOrEmpty(builder.docUrl), "docUrl must be specified"); + this.method = builder.method; + this.shortName = builder.shortName; + this.articledShortName = prefixWithIndefiniteArticle(this.shortName); + this.docUrl = builder.docUrl; + this.invalidTokenErrorCode = checkNotNull(builder.invalidTokenErrorCode); + this.expiredTokenErrorCode = checkNotNull(builder.expiredTokenErrorCode); + this.tenantId = builder.tenantId; + } + + /** + * Verifies that the given token string is a valid Firebase JWT. This implementation considers + * a token string to be valid if all the following conditions are met: + *

    + *
  1. The token string is a valid RS256 JWT.
  2. + *
  3. The JWT contains a valid key ID (kid) claim.
  4. + *
  5. The JWT is not expired, and it has been issued some time in the past.
  6. + *
  7. The JWT contains valid issuer (iss) and audience (aud) claims as determined by the + * {@code IdTokenVerifier}.
  8. + *
  9. The JWT contains a valid subject (sub) claim.
  10. + *
  11. The JWT is signed by a Firebase Auth backend server.
  12. + *
+ * + * @param token The token string to be verified. + * @return A decoded representation of the input token string. + * @throws FirebaseAuthException If the input token string does not meet any of the conditions + * listed above. + */ + @Override + public FirebaseToken verifyToken(String token) throws FirebaseAuthException { + boolean isEmulatorMode = Utils.isEmulatorMode(); + IdToken idToken = parse(token); + checkContents(idToken, isEmulatorMode); + if (!isEmulatorMode) { + checkSignature(idToken); + } + FirebaseToken firebaseToken = new FirebaseToken(idToken.getPayload()); + checkTenantId(firebaseToken); + return firebaseToken; + } + + GooglePublicKeysManager getPublicKeysManager() { + return publicKeysManager; + } + + IdTokenVerifier getIdTokenVerifier() { + return idTokenVerifier; + } + + String getMethod() { + return method; + } + + String getShortName() { + return shortName; + } + + String getArticledShortName() { + return articledShortName; + } + + String getDocUrl() { + return docUrl; + } + + private String prefixWithIndefiniteArticle(String word) { + if ("aeiouAEIOU".indexOf(word.charAt(0)) < 0) { + return "a " + word; + } else { + return "an " + word; + } + } + + private IdToken parse(String token) throws FirebaseAuthException { + try { + return IdToken.parse(jsonFactory, token); + } catch (IllegalArgumentException | IOException e) { + // Old versions of guava throw an IOException for invalid strings, while new versions + // might throw an IllegalArgumentException + String detailedError = String.format( + "Failed to parse Firebase %s. Make sure you passed a string that represents a complete " + + "and valid JWT. See %s for details on how to retrieve %s.", + shortName, + docUrl, + articledShortName); + throw newException(detailedError, invalidTokenErrorCode, e); + } + } + + private void checkSignature(IdToken token) throws FirebaseAuthException { + if (!isSignatureValid(token)) { + String message = String.format( + "Failed to verify the signature of Firebase %s. %s", + shortName, + getVerifyTokenMessage()); + throw newException(message, invalidTokenErrorCode); + } + } + + private void checkContents(final IdToken idToken, boolean isEmulatorMode) + throws FirebaseAuthException { + final Header header = idToken.getHeader(); + final Payload payload = idToken.getPayload(); + + final long currentTimeMillis = idTokenVerifier.getClock().currentTimeMillis(); + String errorMessage = null; + AuthErrorCode errorCode = invalidTokenErrorCode; + + if (!isEmulatorMode && header.getKeyId() == null) { + errorMessage = getErrorForTokenWithoutKid(header, payload); + } else if (!isEmulatorMode && !RS256.equals(header.getAlgorithm())) { + errorMessage = String.format( + "Firebase %s has incorrect algorithm. Expected \"%s\" but got \"%s\".", + shortName, + RS256, + header.getAlgorithm()); + } else if (!idToken.verifyAudience(idTokenVerifier.getAudience())) { + errorMessage = String.format( + "Firebase %s has incorrect \"aud\" (audience) claim. Expected \"%s\" but got \"%s\". %s", + shortName, + joinWithComma(idTokenVerifier.getAudience()), + joinWithComma(payload.getAudienceAsList()), + getProjectIdMatchMessage()); + } else if (!idToken.verifyIssuer(idTokenVerifier.getIssuers())) { + errorMessage = String.format( + "Firebase %s has incorrect \"iss\" (issuer) claim. Expected \"%s\" but got \"%s\". %s", + shortName, + joinWithComma(idTokenVerifier.getIssuers()), + payload.getIssuer(), + getProjectIdMatchMessage()); + } else if (payload.getSubject() == null) { + errorMessage = String.format( + "Firebase %s has no \"sub\" (subject) claim.", + shortName); + } else if (payload.getSubject().isEmpty()) { + errorMessage = String.format( + "Firebase %s has an empty string \"sub\" (subject) claim.", + shortName); + } else if (payload.getSubject().length() > 128) { + errorMessage = String.format( + "Firebase %s has \"sub\" (subject) claim longer than 128 characters.", + shortName); + } else if (!idToken.verifyExpirationTime( + currentTimeMillis, idTokenVerifier.getAcceptableTimeSkewSeconds())) { + errorMessage = String.format( + "Firebase %s has expired. Get a fresh %s and try again.", + shortName, + shortName); + // Also set the expired error code. + errorCode = expiredTokenErrorCode; + } else if (!idToken.verifyIssuedAtTime( + currentTimeMillis, idTokenVerifier.getAcceptableTimeSkewSeconds())) { + errorMessage = String.format( + "Firebase %s is not yet valid.", + shortName); + } + + if (errorMessage != null) { + String detailedError = String.format("%s %s", errorMessage, getVerifyTokenMessage()); + throw newException(detailedError, errorCode); + } + } + + private FirebaseAuthException newException(String message, AuthErrorCode errorCode) { + return newException(message, errorCode, null); + } + + private FirebaseAuthException newException( + String message, AuthErrorCode errorCode, Throwable cause) { + return new FirebaseAuthException( + ErrorCode.INVALID_ARGUMENT, message, cause, null, errorCode); + } + + private String getVerifyTokenMessage() { + return String.format( + "See %s for details on how to retrieve %s.", + docUrl, + articledShortName); + } + + /** + * Verifies the cryptographic signature on the FirebaseToken. Can block on a web request to fetch + * the keys if they have expired. + */ + private boolean isSignatureValid(IdToken token) throws FirebaseAuthException { + for (PublicKey key : fetchPublicKeys()) { + if (isSignatureValid(token, key)) { + return true; + } + } + + return false; + } + + private boolean isSignatureValid(IdToken token, PublicKey key) throws FirebaseAuthException { + try { + return token.verifySignature(key); + } catch (GeneralSecurityException e) { + // This doesn't happen under usual circumstances. Seems to only happen if the crypto + // setup of the runtime is incorrect in some way. + throw new FirebaseAuthException( + ErrorCode.UNKNOWN, + String.format("Unexpected error while verifying %s: %s", shortName, e.getMessage()), + e, + null, + invalidTokenErrorCode); + } + } + + private List fetchPublicKeys() throws FirebaseAuthException { + try { + return publicKeysManager.getPublicKeys(); + } catch (GeneralSecurityException | IOException e) { + throw new FirebaseAuthException( + ErrorCode.UNKNOWN, + "Error while fetching public key certificates: " + e.getMessage(), + e, + null, + AuthErrorCode.CERTIFICATE_FETCH_FAILED); + } + } + + private String getErrorForTokenWithoutKid(IdToken.Header header, IdToken.Payload payload) { + if (isCustomToken(payload)) { + return String.format("%s expects %s, but was given a custom token.", + method, articledShortName); + } else if (isLegacyCustomToken(header, payload)) { + return String.format("%s expects %s, but was given a legacy custom token.", + method, articledShortName); + } + return String.format("Firebase %s has no \"kid\" claim.", shortName); + } + + private String joinWithComma(Iterable strings) { + return Joiner.on(',').join(strings); + } + + private String getProjectIdMatchMessage() { + return String.format( + "Make sure the %s comes from the same Firebase project as the service account used to " + + "authenticate this SDK.", + shortName); + } + + private boolean isCustomToken(IdToken.Payload payload) { + return FIREBASE_AUDIENCE.equals(payload.getAudience()); + } + + private boolean isLegacyCustomToken(IdToken.Header header, IdToken.Payload payload) { + return "HS256".equals(header.getAlgorithm()) + && new BigDecimal(0).equals(payload.get("v")) + && containsLegacyUidField(payload); + } + + private boolean containsLegacyUidField(IdToken.Payload payload) { + Object dataField = payload.get("d"); + if (dataField instanceof ArrayMap) { + return ((ArrayMap) dataField).get("uid") != null; + } + return false; + } + + private void checkTenantId(final FirebaseToken firebaseToken) throws FirebaseAuthException { + String tokenTenantId = firebaseToken.getTenantId(); + if (this.tenantId != null && !this.tenantId.equals(tokenTenantId)) { + String message = String.format( + "The tenant ID ('%s') of the token did not match the expected value ('%s')", + Strings.nullToEmpty(tokenTenantId), + tenantId); + throw newException(message, AuthErrorCode.TENANT_ID_MISMATCH); + } + } + + static Builder builder() { + return new Builder(); + } + + static final class Builder { + + private JsonFactory jsonFactory; + private GooglePublicKeysManager publicKeysManager; + private String method; + private String shortName; + private IdTokenVerifier idTokenVerifier; + private String docUrl; + private AuthErrorCode invalidTokenErrorCode; + private AuthErrorCode expiredTokenErrorCode; + private String tenantId; + + private Builder() { } + + Builder setJsonFactory(JsonFactory jsonFactory) { + this.jsonFactory = jsonFactory; + return this; + } + + Builder setPublicKeysManager(GooglePublicKeysManager publicKeysManager) { + this.publicKeysManager = publicKeysManager; + return this; + } + + Builder setMethod(String method) { + this.method = method; + return this; + } + + Builder setShortName(String shortName) { + this.shortName = shortName; + return this; + } + + Builder setIdTokenVerifier(IdTokenVerifier idTokenVerifier) { + this.idTokenVerifier = idTokenVerifier; + return this; + } + + Builder setDocUrl(String docUrl) { + this.docUrl = docUrl; + return this; + } + + Builder setInvalidTokenErrorCode(AuthErrorCode invalidTokenErrorCode) { + this.invalidTokenErrorCode = invalidTokenErrorCode; + return this; + } + + Builder setExpiredTokenErrorCode(AuthErrorCode expiredTokenErrorCode) { + this.expiredTokenErrorCode = expiredTokenErrorCode; + return this; + } + + Builder setTenantId(@Nullable String tenantId) { + this.tenantId = tenantId; + return this; + } + + FirebaseTokenVerifierImpl build() { + return new FirebaseTokenVerifierImpl(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java index 39b3e3780..0647084c8 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java +++ b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,36 +19,38 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; -import com.google.api.client.http.GenericUrl; -import com.google.api.client.http.HttpRequest; import com.google.api.client.http.HttpRequestFactory; -import com.google.api.client.http.HttpResponse; -import com.google.api.client.http.HttpResponseException; import com.google.api.client.http.HttpResponseInterceptor; -import com.google.api.client.http.HttpTransport; -import com.google.api.client.http.json.JsonHttpContent; import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.JsonObjectParser; import com.google.api.client.util.Key; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Joiner; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.firebase.ErrorCode; import com.google.firebase.FirebaseApp; -import com.google.firebase.auth.UserRecord.CreateRequest; -import com.google.firebase.auth.UserRecord.UpdateRequest; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.auth.internal.AuthHttpClient; +import com.google.firebase.auth.internal.BatchDeleteResponse; import com.google.firebase.auth.internal.DownloadAccountResponse; +import com.google.firebase.auth.internal.GetAccountInfoRequest; import com.google.firebase.auth.internal.GetAccountInfoResponse; - -import com.google.firebase.auth.internal.HttpErrorResponse; +import com.google.firebase.auth.internal.ListOidcProviderConfigsResponse; +import com.google.firebase.auth.internal.ListSamlProviderConfigsResponse; import com.google.firebase.auth.internal.UploadAccountResponse; -import com.google.firebase.internal.FirebaseRequestInitializer; +import com.google.firebase.auth.internal.Utils; +import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.HttpRequestInfo; import com.google.firebase.internal.NonNull; -import com.google.firebase.internal.SdkUtils; -import java.io.IOException; +import com.google.firebase.internal.Nullable; +import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * FirebaseUserManager provides methods for interacting with the Google Identity Toolkit via its @@ -57,32 +59,11 @@ * @see * Google Identity Toolkit */ -class FirebaseUserManager { - - static final String USER_NOT_FOUND_ERROR = "user-not-found"; - static final String INTERNAL_ERROR = "internal-error"; - static final String ID_TOKEN_REVOKED_ERROR = "id-token-revoked"; - static final String SESSION_COOKIE_REVOKED_ERROR = "session-cookie-revoked"; - - // Map of server-side error codes to SDK error codes. - // SDK error codes defined at: https://firebase.google.com/docs/auth/admin/errors - private static final Map ERROR_CODES = ImmutableMap.builder() - .put("CLAIMS_TOO_LARGE", "claims-too-large") - .put("CONFIGURATION_NOT_FOUND", "project-not-found") - .put("INSUFFICIENT_PERMISSION", "insufficient-permission") - .put("DUPLICATE_EMAIL", "email-already-exists") - .put("DUPLICATE_LOCAL_ID", "uid-already-exists") - .put("EMAIL_EXISTS", "email-already-exists") - .put("INVALID_CLAIMS", "invalid-claims") - .put("INVALID_EMAIL", "invalid-email") - .put("INVALID_PAGE_SELECTION", "invalid-page-token") - .put("INVALID_PHONE_NUMBER", "invalid-phone-number") - .put("PHONE_NUMBER_EXISTS", "phone-number-already-exists") - .put("PROJECT_NOT_FOUND", "project-not-found") - .put("USER_NOT_FOUND", USER_NOT_FOUND_ERROR) - .put("WEAK_PASSWORD", "invalid-password") - .build(); +final class FirebaseUserManager { + static final int MAX_LIST_PROVIDER_CONFIGS_RESULTS = 100; + static final int MAX_GET_ACCOUNTS_BATCH_SIZE = 100; + static final int MAX_DELETE_ACCOUNTS_BATCH_SIZE = 1000; static final int MAX_LIST_USERS_RESULTS = 1000; static final int MAX_IMPORT_USERS = 1000; @@ -91,95 +72,120 @@ class FirebaseUserManager { "iss", "jti", "nbf", "nonce", "sub", "firebase"); private static final String ID_TOOLKIT_URL = - "https://www.googleapis.com/identitytoolkit/v3/relyingparty/"; - private static final String CLIENT_VERSION_HEADER = "X-Client-Version"; + "https://identitytoolkit.googleapis.com/%s/projects/%s"; + private static final String ID_TOOLKIT_URL_EMULATOR = + "http://%s/identitytoolkit.googleapis.com/%s/projects/%s"; + private final String userMgtBaseUrl; + private final String idpConfigMgtBaseUrl; private final JsonFactory jsonFactory; - private final HttpRequestFactory requestFactory; - private final String clientVersion = "Java/Admin/" + SdkUtils.getVersion(); + private final AuthHttpClient httpClient; + + private FirebaseUserManager(Builder builder) { + String projectId = builder.projectId; + checkArgument(!Strings.isNullOrEmpty(projectId), + "Project ID is required to access the auth service. Use a service account credential or " + + "set the project ID explicitly via FirebaseOptions. Alternatively you can also " + + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable."); + this.jsonFactory = checkNotNull(builder.jsonFactory, "JsonFactory must not be null"); + final String idToolkitUrlV1 = getIdToolkitUrl(projectId, "v1"); + final String idToolkitUrlV2 = getIdToolkitUrl(projectId, "v2"); + final String tenantId = builder.tenantId; + if (tenantId == null) { + this.userMgtBaseUrl = idToolkitUrlV1; + this.idpConfigMgtBaseUrl = idToolkitUrlV2; + } else { + checkArgument(!tenantId.isEmpty(), "Tenant ID must not be empty."); + this.userMgtBaseUrl = idToolkitUrlV1 + "/tenants/" + tenantId; + this.idpConfigMgtBaseUrl = idToolkitUrlV2 + "/tenants/" + tenantId; + } - private HttpResponseInterceptor interceptor; + this.httpClient = new AuthHttpClient(jsonFactory, builder.requestFactory); + } - /** - * Creates a new FirebaseUserManager instance. - * - * @param app A non-null {@link FirebaseApp}. - */ - FirebaseUserManager(@NonNull FirebaseApp app) { - checkNotNull(app, "FirebaseApp must not be null"); - this.jsonFactory = app.getOptions().getJsonFactory(); - HttpTransport transport = app.getOptions().getHttpTransport(); - this.requestFactory = transport.createRequestFactory(new FirebaseRequestInitializer(app)); + private String getIdToolkitUrl(String projectId, String version) { + if (Utils.isEmulatorMode()) { + return String.format(ID_TOOLKIT_URL_EMULATOR, Utils.getEmulatorHost(), version, projectId); + } + return String.format(ID_TOOLKIT_URL, version, projectId); } @VisibleForTesting void setInterceptor(HttpResponseInterceptor interceptor) { - this.interceptor = interceptor; + httpClient.setInterceptor(interceptor); } UserRecord getUserById(String uid) throws FirebaseAuthException { final Map payload = ImmutableMap.of( "localId", ImmutableList.of(uid)); - GetAccountInfoResponse response = post( - "getAccountInfo", payload, GetAccountInfoResponse.class); - if (response == null || response.getUsers() == null || response.getUsers().isEmpty()) { - throw new FirebaseAuthException(USER_NOT_FOUND_ERROR, - "No user record found for the provided user ID: " + uid); - } - return new UserRecord(response.getUsers().get(0), jsonFactory); + return lookupUserAccount(payload, "user ID: " + uid); } UserRecord getUserByEmail(String email) throws FirebaseAuthException { final Map payload = ImmutableMap.of( "email", ImmutableList.of(email)); - GetAccountInfoResponse response = post( - "getAccountInfo", payload, GetAccountInfoResponse.class); - if (response == null || response.getUsers() == null || response.getUsers().isEmpty()) { - throw new FirebaseAuthException(USER_NOT_FOUND_ERROR, - "No user record found for the provided email: " + email); - } - return new UserRecord(response.getUsers().get(0), jsonFactory); + return lookupUserAccount(payload, "email: " + email); } UserRecord getUserByPhoneNumber(String phoneNumber) throws FirebaseAuthException { final Map payload = ImmutableMap.of( "phoneNumber", ImmutableList.of(phoneNumber)); - GetAccountInfoResponse response = post( - "getAccountInfo", payload, GetAccountInfoResponse.class); - if (response == null || response.getUsers() == null || response.getUsers().isEmpty()) { - throw new FirebaseAuthException(USER_NOT_FOUND_ERROR, - "No user record found for the provided phone number: " + phoneNumber); - } - return new UserRecord(response.getUsers().get(0), jsonFactory); + return lookupUserAccount(payload, "phone number: " + phoneNumber); } - String createUser(CreateRequest request) throws FirebaseAuthException { - GenericJson response = post( - "signupNewUser", request.getProperties(), GenericJson.class); - if (response != null) { - String uid = (String) response.get("localId"); - if (!Strings.isNullOrEmpty(uid)) { - return uid; + Set getAccountInfo(@NonNull Collection identifiers) + throws FirebaseAuthException { + if (identifiers.isEmpty()) { + return new HashSet<>(); + } + + GetAccountInfoRequest payload = new GetAccountInfoRequest(); + for (UserIdentifier id : identifiers) { + id.populate(payload); + } + + GetAccountInfoResponse response = post( + "/accounts:lookup", payload, GetAccountInfoResponse.class); + Set results = new HashSet<>(); + if (response.getUsers() != null) { + for (GetAccountInfoResponse.User user : response.getUsers()) { + results.add(new UserRecord(user, jsonFactory)); } } - throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to create new user"); + return results; } - void updateUser(UpdateRequest request, JsonFactory jsonFactory) throws FirebaseAuthException { - GenericJson response = post( - "setAccountInfo", request.getProperties(jsonFactory), GenericJson.class); - if (response == null || !request.getUid().equals(response.get("localId"))) { - throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to update user: " + request.getUid()); - } + UserRecord getUserByProviderUid( + String providerId, String uid) throws FirebaseAuthException { + final Map payload = ImmutableMap.of( + "federatedUserId", ImmutableList.of( + ImmutableMap.builder() + .put("rawId", uid).put("providerId", providerId).build())); + return lookupUserAccount(payload, uid); + } + + String createUser(UserRecord.CreateRequest request) throws FirebaseAuthException { + GenericJson response = post("/accounts", request.getProperties(), GenericJson.class); + return (String) response.get("localId"); + } + + void updateUser(UserRecord.UpdateRequest request, JsonFactory jsonFactory) + throws FirebaseAuthException { + post("/accounts:update", request.getProperties(jsonFactory), GenericJson.class); } void deleteUser(String uid) throws FirebaseAuthException { final Map payload = ImmutableMap.of("localId", uid); - GenericJson response = post( - "deleteAccount", payload, GenericJson.class); - if (response == null || !response.containsKey("kind")) { - throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to delete user: " + uid); - } + post("/accounts:delete", payload, GenericJson.class); + } + + DeleteUsersResult deleteUsers(@NonNull List uids) throws FirebaseAuthException { + final Map payload = ImmutableMap.of( + "localIds", uids, + "force", true); + BatchDeleteResponse response = post( + "/accounts:batchDelete", payload, BatchDeleteResponse.class); + return new DeleteUsersResult(uids.size(), response); } DownloadAccountResponse listUsers(int maxResults, String pageToken) throws FirebaseAuthException { @@ -190,20 +196,16 @@ DownloadAccountResponse listUsers(int maxResults, String pageToken) throws Fireb builder.put("nextPageToken", pageToken); } - DownloadAccountResponse response = post( - "downloadAccount", builder.build(), DownloadAccountResponse.class); - if (response == null) { - throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to retrieve users."); - } - return response; + String url = userMgtBaseUrl + "/accounts:batchGet"; + HttpRequestInfo requestInfo = HttpRequestInfo.buildGetRequest(url) + .addAllParameters(builder.build()); + return httpClient.sendRequest(requestInfo, DownloadAccountResponse.class); } UserImportResult importUsers(UserImportRequest request) throws FirebaseAuthException { checkNotNull(request); - UploadAccountResponse response = post("uploadAccount", request, UploadAccountResponse.class); - if (response == null) { - throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to import users."); - } + UploadAccountResponse response = post( + "/accounts:batchCreate", request, UploadAccountResponse.class); return new UserImportResult(request.getUsersCount(), response); } @@ -211,63 +213,142 @@ String createSessionCookie(String idToken, SessionCookieOptions options) throws FirebaseAuthException { final Map payload = ImmutableMap.of( "idToken", idToken, "validDuration", options.getExpiresInSeconds()); - GenericJson response = post("createSessionCookie", payload, GenericJson.class); - if (response != null) { - String cookie = (String) response.get("sessionCookie"); - if (!Strings.isNullOrEmpty(cookie)) { - return cookie; - } + GenericJson response = post(":createSessionCookie", payload, GenericJson.class); + return (String) response.get("sessionCookie"); + } + + String getEmailActionLink(EmailLinkType type, String email, + @Nullable ActionCodeSettings settings) throws FirebaseAuthException { + ImmutableMap.Builder payload = ImmutableMap.builder() + .put("requestType", type.name()) + .put("email", email) + .put("returnOobLink", true); + if (settings != null) { + payload.putAll(settings.getProperties()); } - throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to create session cookie"); + + GenericJson response = post("/accounts:sendOobCode", payload.build(), GenericJson.class); + return (String) response.get("oobLink"); } - private T post(String path, Object content, Class clazz) throws FirebaseAuthException { - checkArgument(!Strings.isNullOrEmpty(path), "path must not be null or empty"); - checkNotNull(content, "content must not be null"); - checkNotNull(clazz, "response class must not be null"); - - GenericUrl url = new GenericUrl(ID_TOOLKIT_URL + path); - HttpResponse response = null; - try { - HttpRequest request = requestFactory.buildPostRequest(url, - new JsonHttpContent(jsonFactory, content)); - request.setParser(new JsonObjectParser(jsonFactory)); - request.getHeaders().set(CLIENT_VERSION_HEADER, clientVersion); - request.setResponseInterceptor(interceptor); - response = request.execute(); - return response.parseAs(clazz); - } catch (HttpResponseException e) { - // Server responded with an HTTP error - handleHttpError(e); - return null; - } catch (IOException e) { - // All other IO errors (Connection refused, reset, parse error etc.) - throw new FirebaseAuthException( - INTERNAL_ERROR, "Error while calling user management backend service", e); - } finally { - if (response != null) { - try { - response.disconnect(); - } catch (IOException ignored) { - // Ignored - } - } + private UserRecord lookupUserAccount( + Map payload, String identifier) throws FirebaseAuthException { + HttpRequestInfo requestInfo = HttpRequestInfo.buildJsonPostRequest( + userMgtBaseUrl + "/accounts:lookup", payload); + IncomingHttpResponse response = httpClient.sendRequest(requestInfo); + GetAccountInfoResponse parsed = httpClient.parse(response, GetAccountInfoResponse.class); + if (parsed.getUsers() == null || parsed.getUsers().isEmpty()) { + throw new FirebaseAuthException(ErrorCode.NOT_FOUND, + "No user record found for the provided " + identifier, + null, + response, + AuthErrorCode.USER_NOT_FOUND); } + + return new UserRecord(parsed.getUsers().get(0), jsonFactory); } - private void handleHttpError(HttpResponseException e) throws FirebaseAuthException { - try { - HttpErrorResponse response = jsonFactory.fromString(e.getContent(), HttpErrorResponse.class); - String code = ERROR_CODES.get(response.getErrorCode()); - if (code != null) { - throw new FirebaseAuthException(code, "User management service responded with an error", e); - } - } catch (IOException ignored) { - // Ignored + OidcProviderConfig createOidcProviderConfig( + OidcProviderConfig.CreateRequest request) throws FirebaseAuthException { + String url = idpConfigMgtBaseUrl + "/oauthIdpConfigs"; + HttpRequestInfo requestInfo = HttpRequestInfo.buildJsonPostRequest(url, request.getProperties()) + .addParameter("oauthIdpConfigId", request.getProviderId()); + return httpClient.sendRequest(requestInfo, OidcProviderConfig.class); + } + + SamlProviderConfig createSamlProviderConfig( + SamlProviderConfig.CreateRequest request) throws FirebaseAuthException { + String url = idpConfigMgtBaseUrl + "/inboundSamlConfigs"; + HttpRequestInfo requestInfo = HttpRequestInfo.buildJsonPostRequest(url, request.getProperties()) + .addParameter("inboundSamlConfigId", request.getProviderId()); + return httpClient.sendRequest(requestInfo, SamlProviderConfig.class); + } + + OidcProviderConfig updateOidcProviderConfig(OidcProviderConfig.UpdateRequest request) + throws FirebaseAuthException { + Map properties = request.getProperties(); + String url = idpConfigMgtBaseUrl + getOidcUrlSuffix(request.getProviderId()); + HttpRequestInfo requestInfo = HttpRequestInfo.buildJsonPatchRequest(url, properties) + .addParameter("updateMask", Joiner.on(",").join(AuthHttpClient.generateMask(properties))); + return httpClient.sendRequest(requestInfo, OidcProviderConfig.class); + } + + SamlProviderConfig updateSamlProviderConfig(SamlProviderConfig.UpdateRequest request) + throws FirebaseAuthException { + Map properties = request.getProperties(); + String url = idpConfigMgtBaseUrl + getSamlUrlSuffix(request.getProviderId()); + HttpRequestInfo requestInfo = HttpRequestInfo.buildJsonPatchRequest(url, properties) + .addParameter("updateMask", Joiner.on(",").join(AuthHttpClient.generateMask(properties))); + return httpClient.sendRequest(requestInfo, SamlProviderConfig.class); + } + + OidcProviderConfig getOidcProviderConfig(String providerId) throws FirebaseAuthException { + String url = idpConfigMgtBaseUrl + getOidcUrlSuffix(providerId); + return httpClient.sendRequest(HttpRequestInfo.buildGetRequest(url), OidcProviderConfig.class); + } + + SamlProviderConfig getSamlProviderConfig(String providerId) throws FirebaseAuthException { + String url = idpConfigMgtBaseUrl + getSamlUrlSuffix(providerId); + return httpClient.sendRequest(HttpRequestInfo.buildGetRequest(url), SamlProviderConfig.class); + } + + ListOidcProviderConfigsResponse listOidcProviderConfigs(int maxResults, String pageToken) + throws FirebaseAuthException { + ImmutableMap.Builder builder = + ImmutableMap.builder().put("pageSize", maxResults); + if (pageToken != null) { + checkArgument(!pageToken.equals( + ListProviderConfigsPage.END_OF_LIST), "Invalid end of list page token."); + builder.put("nextPageToken", pageToken); + } + + String url = idpConfigMgtBaseUrl + "/oauthIdpConfigs"; + HttpRequestInfo requestInfo = HttpRequestInfo.buildGetRequest(url) + .addAllParameters(builder.build()); + return httpClient.sendRequest(requestInfo, ListOidcProviderConfigsResponse.class); + } + + ListSamlProviderConfigsResponse listSamlProviderConfigs(int maxResults, String pageToken) + throws FirebaseAuthException { + ImmutableMap.Builder builder = + ImmutableMap.builder().put("pageSize", maxResults); + if (pageToken != null) { + checkArgument(!pageToken.equals( + ListProviderConfigsPage.END_OF_LIST), "Invalid end of list page token."); + builder.put("nextPageToken", pageToken); } - String msg = String.format( - "Unexpected HTTP response with status: %d; body: %s", e.getStatusCode(), e.getContent()); - throw new FirebaseAuthException(INTERNAL_ERROR, msg, e); + + String url = idpConfigMgtBaseUrl + "/inboundSamlConfigs"; + HttpRequestInfo requestInfo = HttpRequestInfo.buildGetRequest(url) + .addAllParameters(builder.build()); + return httpClient.sendRequest(requestInfo, ListSamlProviderConfigsResponse.class); + } + + void deleteOidcProviderConfig(String providerId) throws FirebaseAuthException { + String url = idpConfigMgtBaseUrl + getOidcUrlSuffix(providerId); + httpClient.sendRequest(HttpRequestInfo.buildDeleteRequest(url)); + } + + void deleteSamlProviderConfig(String providerId) throws FirebaseAuthException { + String url = idpConfigMgtBaseUrl + getSamlUrlSuffix(providerId); + httpClient.sendRequest(HttpRequestInfo.buildDeleteRequest(url)); + } + + private static String getOidcUrlSuffix(String providerId) { + checkArgument(!Strings.isNullOrEmpty(providerId), "Provider ID must not be null or empty."); + return "/oauthIdpConfigs/" + providerId; + } + + private static String getSamlUrlSuffix(String providerId) { + checkArgument(!Strings.isNullOrEmpty(providerId), "Provider ID must not be null or empty."); + return "/inboundSamlConfigs/" + providerId; + } + + private T post(String path, Object content, Class clazz) throws FirebaseAuthException { + checkArgument(!Strings.isNullOrEmpty(path), "path must not be null or empty"); + checkNotNull(content, "content must not be null for POST requests"); + String url = userMgtBaseUrl + path; + return httpClient.sendRequest(HttpRequestInfo.buildJsonPostRequest(url, content), clazz); } static class UserImportRequest extends GenericJson { @@ -303,4 +384,57 @@ int getUsersCount() { return users.size(); } } + + enum EmailLinkType { + VERIFY_EMAIL, + EMAIL_SIGNIN, + PASSWORD_RESET, + } + + static FirebaseUserManager createUserManager(FirebaseApp app, String tenantId) { + return FirebaseUserManager.builder() + .setProjectId(ImplFirebaseTrampolines.getProjectId(app)) + .setTenantId(tenantId) + .setHttpRequestFactory(ApiClientUtils.newAuthorizedRequestFactory(app)) + .setJsonFactory(app.getOptions().getJsonFactory()) + .build(); + } + + static Builder builder() { + return new Builder(); + } + + static class Builder { + + private String projectId; + private String tenantId; + private HttpRequestFactory requestFactory; + private JsonFactory jsonFactory; + + private Builder() { } + + public Builder setProjectId(String projectId) { + this.projectId = projectId; + return this; + } + + Builder setTenantId(String tenantId) { + this.tenantId = tenantId; + return this; + } + + Builder setHttpRequestFactory(HttpRequestFactory requestFactory) { + this.requestFactory = requestFactory; + return this; + } + + public Builder setJsonFactory(JsonFactory jsonFactory) { + this.jsonFactory = jsonFactory; + return this; + } + + FirebaseUserManager build() { + return new FirebaseUserManager(this); + } + } } diff --git a/src/main/java/com/google/firebase/auth/GetUsersResult.java b/src/main/java/com/google/firebase/auth/GetUsersResult.java new file mode 100644 index 000000000..a98b87b2e --- /dev/null +++ b/src/main/java/com/google/firebase/auth/GetUsersResult.java @@ -0,0 +1,52 @@ +/* + * Copyright 2020 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.auth; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.firebase.internal.NonNull; +import java.util.Set; + +/** + * Represents the result of the {@link FirebaseAuth#getUsersAsync(Collection)} API. + */ +public final class GetUsersResult { + private final Set users; + private final Set notFound; + + GetUsersResult(@NonNull Set users, @NonNull Set notFound) { + this.users = checkNotNull(users); + this.notFound = checkNotNull(notFound); + } + + /** + * Set of user records corresponding to the set of users that were requested. Only users + * that were found are listed here. The result set is unordered. + */ + @NonNull + public Set getUsers() { + return this.users; + } + + /** + * Set of identifiers that were requested, but not found. + */ + @NonNull + public Set getNotFound() { + return this.notFound; + } +} diff --git a/src/main/java/com/google/firebase/auth/ImportUserRecord.java b/src/main/java/com/google/firebase/auth/ImportUserRecord.java index 5ce57fcc9..edde53bb5 100644 --- a/src/main/java/com/google/firebase/auth/ImportUserRecord.java +++ b/src/main/java/com/google/firebase/auth/ImportUserRecord.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/ListProviderConfigsPage.java b/src/main/java/com/google/firebase/auth/ListProviderConfigsPage.java new file mode 100644 index 000000000..0e35337a9 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/ListProviderConfigsPage.java @@ -0,0 +1,268 @@ +/* + * Copyright 2020 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.auth; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.gax.paging.Page; +import com.google.common.collect.ImmutableList; +import com.google.firebase.auth.internal.ListOidcProviderConfigsResponse; +import com.google.firebase.auth.internal.ListProviderConfigsResponse; +import com.google.firebase.auth.internal.ListSamlProviderConfigsResponse; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; + +/** + * Represents a page of {@link ProviderConfig} instances. + * + *

Provides methods for iterating over the provider configs in the current page, and calling up + * subsequent pages of provider configs. + * + *

Instances of this class are thread-safe and immutable. + */ +public class ListProviderConfigsPage implements Page { + + static final String END_OF_LIST = ""; + + private final ListProviderConfigsResponse currentBatch; + private final ProviderConfigSource source; + private final int maxResults; + + private ListProviderConfigsPage( + @NonNull ListProviderConfigsResponse currentBatch, + @NonNull ProviderConfigSource source, + int maxResults) { + this.currentBatch = checkNotNull(currentBatch); + this.source = checkNotNull(source); + this.maxResults = maxResults; + } + + /** + * Checks if there is another page of provider configs available to retrieve. + * + * @return true if another page is available, or false otherwise. + */ + @Override + public boolean hasNextPage() { + return !END_OF_LIST.equals(currentBatch.getPageToken()); + } + + /** + * Returns the string token that identifies the next page. + * + *

Never returns null. Returns empty string if there are no more pages available to be + * retrieved. + * + * @return A non-null string token (possibly empty, representing no more pages) + */ + @NonNull + @Override + public String getNextPageToken() { + return currentBatch.getPageToken(); + } + + /** + * Returns the next page of provider configs. + * + * @return A new {@link ListProviderConfigsPage} instance, or null if there are no more pages. + */ + @Nullable + @Override + public ListProviderConfigsPage getNextPage() { + if (hasNextPage()) { + Factory factory = new Factory<>(source, maxResults, currentBatch.getPageToken()); + try { + return factory.create(); + } catch (FirebaseAuthException e) { + throw new RuntimeException(e); + } + } + return null; + } + + /** + * Returns an {@code Iterable} that facilitates transparently iterating over all the provider + * configs in the current Firebase project, starting from this page. + * + *

The {@code Iterator} instances produced by the returned {@code Iterable} never buffers more + * than one page of provider configs at a time. It is safe to abandon the iterators (i.e. break + * the loops) at any time. + * + * @return a new {@code Iterable} instance. + */ + @NonNull + @Override + public Iterable iterateAll() { + return new ProviderConfigIterable<>(this); + } + + /** + * Returns an {@code Iterable} over the provider configs in this page. + * + * @return a {@code Iterable} instance. + */ + @NonNull + @Override + public Iterable getValues() { + return currentBatch.getProviderConfigs(); + } + + private static class ProviderConfigIterable implements Iterable { + + private final ListProviderConfigsPage startingPage; + + ProviderConfigIterable(@NonNull ListProviderConfigsPage startingPage) { + this.startingPage = checkNotNull(startingPage, "starting page must not be null"); + } + + @Override + @NonNull + public Iterator iterator() { + return new ProviderConfigIterator<>(startingPage); + } + + /** + * An {@link Iterator} that cycles through provider configs, one at a time. + * + *

It buffers the last retrieved batch of provider configs in memory. The {@code maxResults} + * parameter is an upper bound on the batch size. + */ + private static class ProviderConfigIterator implements Iterator { + + private ListProviderConfigsPage currentPage; + private List batch; + private int index = 0; + + private ProviderConfigIterator(ListProviderConfigsPage startingPage) { + setCurrentPage(startingPage); + } + + @Override + public boolean hasNext() { + if (index == batch.size()) { + if (currentPage.hasNextPage()) { + setCurrentPage(currentPage.getNextPage()); + } else { + return false; + } + } + + return index < batch.size(); + } + + @Override + public T next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return batch.get(index++); + } + + @Override + public void remove() { + throw new UnsupportedOperationException("remove operation not supported"); + } + + private void setCurrentPage(ListProviderConfigsPage page) { + this.currentPage = checkNotNull(page); + this.batch = ImmutableList.copyOf(page.getValues()); + this.index = 0; + } + } + } + + /** + * Represents a source of provider config data that can be queried to load a batch of provider + * configs. + */ + interface ProviderConfigSource { + @NonNull + ListProviderConfigsResponse fetch(int maxResults, String pageToken) + throws FirebaseAuthException; + } + + static class DefaultOidcProviderConfigSource implements ProviderConfigSource { + + private final FirebaseUserManager userManager; + + DefaultOidcProviderConfigSource(FirebaseUserManager userManager) { + this.userManager = checkNotNull(userManager, "User manager must not be null."); + } + + @Override + public ListOidcProviderConfigsResponse fetch(int maxResults, String pageToken) + throws FirebaseAuthException { + return userManager.listOidcProviderConfigs(maxResults, pageToken); + } + } + + static class DefaultSamlProviderConfigSource implements ProviderConfigSource { + + private final FirebaseUserManager userManager; + + DefaultSamlProviderConfigSource(FirebaseUserManager userManager) { + this.userManager = checkNotNull(userManager, "User manager must not be null."); + } + + @Override + public ListSamlProviderConfigsResponse fetch(int maxResults, String pageToken) + throws FirebaseAuthException { + return userManager.listSamlProviderConfigs(maxResults, pageToken); + } + } + + /** + * A simple factory class for {@link ListProviderConfigsPage} instances. + * + *

Performs argument validation before attempting to load any provider config data (which is + * expensive, and hence may be performed asynchronously on a separate thread). + */ + static class Factory { + + private final ProviderConfigSource source; + private final int maxResults; + private final String pageToken; + + Factory(@NonNull ProviderConfigSource source) { + this(source, FirebaseUserManager.MAX_LIST_PROVIDER_CONFIGS_RESULTS, null); + } + + Factory( + @NonNull ProviderConfigSource source, + int maxResults, + @Nullable String pageToken) { + checkArgument( + maxResults > 0 && maxResults <= FirebaseUserManager.MAX_LIST_PROVIDER_CONFIGS_RESULTS, + "maxResults must be a positive integer that does not exceed %s", + FirebaseUserManager.MAX_LIST_PROVIDER_CONFIGS_RESULTS); + checkArgument(!END_OF_LIST.equals(pageToken), "invalid end of list page token"); + this.source = checkNotNull(source, "source must not be null"); + this.maxResults = maxResults; + this.pageToken = pageToken; + } + + ListProviderConfigsPage create() throws FirebaseAuthException { + ListProviderConfigsResponse batch = source.fetch(maxResults, pageToken); + return new ListProviderConfigsPage<>(batch, source, maxResults); + } + } +} + diff --git a/src/main/java/com/google/firebase/auth/ListUsersPage.java b/src/main/java/com/google/firebase/auth/ListUsersPage.java index f406366ba..f442bf85b 100644 --- a/src/main/java/com/google/firebase/auth/ListUsersPage.java +++ b/src/main/java/com/google/firebase/auth/ListUsersPage.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -80,7 +80,7 @@ public String getNextPageToken() { @Override public ListUsersPage getNextPage() { if (hasNextPage()) { - PageFactory factory = new PageFactory(source, maxResults, currentBatch.getNextPageToken()); + Factory factory = new Factory(source, maxResults, currentBatch.getNextPageToken()); try { return factory.create(); } catch (FirebaseAuthException e) { @@ -237,17 +237,17 @@ String getNextPageToken() { * before attempting to load any user data (which is expensive, and hence may be performed * asynchronously on a separate thread). */ - static class PageFactory { + static class Factory { private final UserSource source; private final int maxResults; private final String pageToken; - PageFactory(@NonNull UserSource source) { + Factory(@NonNull UserSource source) { this(source, FirebaseUserManager.MAX_LIST_USERS_RESULTS, null); } - PageFactory(@NonNull UserSource source, int maxResults, @Nullable String pageToken) { + Factory(@NonNull UserSource source, int maxResults, @Nullable String pageToken) { checkArgument(maxResults > 0 && maxResults <= FirebaseUserManager.MAX_LIST_USERS_RESULTS, "maxResults must be a positive integer that does not exceed %s", FirebaseUserManager.MAX_LIST_USERS_RESULTS); diff --git a/src/main/java/com/google/firebase/auth/OidcProviderConfig.java b/src/main/java/com/google/firebase/auth/OidcProviderConfig.java new file mode 100644 index 000000000..0a4f7c0b8 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/OidcProviderConfig.java @@ -0,0 +1,291 @@ +/* + * Copyright 2020 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.auth; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.util.Key; +import com.google.common.base.Strings; +import java.util.HashMap; +import java.util.Map; + +/** + * Contains metadata associated with an OIDC Auth provider. + * + *

Instances of this class are immutable and thread safe. + */ +public final class OidcProviderConfig extends ProviderConfig { + + @Key("clientId") + private String clientId; + + @Key("clientSecret") + private String clientSecret; + + @Key("issuer") + private String issuer; + + @Key("responseType") + private GenericJson responseType; + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getIssuer() { + return issuer; + } + + public boolean isCodeResponseType() { + return (responseType.containsKey("code") && (boolean) responseType.get("code")); + } + + public boolean isIdTokenResponseType() { + return (responseType.containsKey("idToken") && (boolean) responseType.get("idToken")); + } + + /** + * Returns a new {@link UpdateRequest}, which can be used to update the attributes of this + * provider config. + * + * @return A non-null {@link UpdateRequest} instance. + */ + public UpdateRequest updateRequest() { + return new UpdateRequest(getProviderId()); + } + + static void checkOidcProviderId(String providerId) { + checkArgument(!Strings.isNullOrEmpty(providerId), "Provider ID must not be null or empty."); + checkArgument(providerId.startsWith("oidc."), + "Invalid OIDC provider ID (must be prefixed with 'oidc.'): " + providerId); + } + + static Map ensureResponseType(Map properties) { + if (properties.get("responseType") == null) { + properties.put("responseType", new HashMap()); + } + return (Map) properties.get("responseType"); + } + + /** + * A specification class for creating a new OIDC Auth provider. + * + *

Set the initial attributes of the new provider by calling various setter methods available + * in this class. + */ + public static final class CreateRequest extends AbstractCreateRequest { + + /** + * Creates a new {@link CreateRequest}, which can be used to create a new OIDC Auth provider. + * + *

The returned object should be passed to + * {@link AbstractFirebaseAuth#createOidcProviderConfig(CreateRequest)} to save the config. + */ + public CreateRequest() { } + + /** + * Sets the ID for the new provider. + * + * @param providerId A non-null, non-empty provider ID string. + * @throws IllegalArgumentException If the provider ID is null or empty, or is not prefixed with + * 'oidc.'. + */ + @Override + public CreateRequest setProviderId(String providerId) { + checkOidcProviderId(providerId); + return super.setProviderId(providerId); + } + + /** + * Sets the client ID for the new provider. + * + * @param clientId A non-null, non-empty client ID string. + * @throws IllegalArgumentException If the client ID is null or empty. + */ + public CreateRequest setClientId(String clientId) { + checkArgument(!Strings.isNullOrEmpty(clientId), "Client ID must not be null or empty."); + properties.put("clientId", clientId); + return this; + } + + /** + * Sets the client secret for the new provider. This is required for the code flow. + * + * @param clientSecret A non-null, non-empty client secret string. + * @throws IllegalArgumentException If the client secret is null or empty. + */ + public CreateRequest setClientSecret(String clientSecret) { + checkArgument(!Strings.isNullOrEmpty(clientSecret), + "Client Secret must not be null or empty."); + properties.put("clientSecret", clientSecret); + return this; + } + + /** + * Sets the issuer for the new provider. + * + * @param issuer A non-null, non-empty issuer URL string. + * @throws IllegalArgumentException If the issuer URL is null or empty, or if the format is + * invalid. + */ + public CreateRequest setIssuer(String issuer) { + checkArgument(!Strings.isNullOrEmpty(issuer), "Issuer must not be null or empty."); + assertValidUrl(issuer); + properties.put("issuer", issuer); + return this; + } + + /** + * Sets whether to enable the code response flow for the new provider. By default, this is not + * enabled if no response type is specified. + * + *

A client secret must be set for this response type. + * + *

Having both the code and ID token response flows is currently not supported. + * + * @param enabled A boolean signifying whether the code response type is supported. + */ + public CreateRequest setCodeResponseType(boolean enabled) { + Map map = ensureResponseType(properties); + map.put("code", enabled); + return this; + } + + /** + * Sets whether to enable the ID token response flow for the new provider. By default, this is + * enabled if no response type is specified. + * + *

Having both the code and ID token response flows is currently not supported. + * + * @param enabled A boolean signifying whether the ID token response type is supported. + */ + public CreateRequest setIdTokenResponseType(boolean enabled) { + Map map = ensureResponseType(properties); + map.put("idToken", enabled); + return this; + } + + CreateRequest getThis() { + return this; + } + } + + /** + * A specification class for updating an existing OIDC Auth provider. + * + *

An instance of this class can be obtained via a {@link OidcProviderConfig} object, or from + * a provider ID string. Specify the changes to be made to the provider config by calling the + * various setter methods available in this class. + */ + public static final class UpdateRequest extends AbstractUpdateRequest { + + /** + * Creates a new {@link UpdateRequest}, which can be used to updates an existing OIDC Auth + * provider. + * + *

The returned object should be passed to + * {@link AbstractFirebaseAuth#updateOidcProviderConfig(UpdateRequest)} to save the updated + * config. + * + * @param providerId A non-null, non-empty provider ID string. + * @throws IllegalArgumentException If the provider ID is null or empty, or is not prefixed with + * "oidc.". + */ + public UpdateRequest(String providerId) { + super(providerId); + checkOidcProviderId(providerId); + } + + /** + * Sets the client ID for the exsting provider. + * + * @param clientId A non-null, non-empty client ID string. + * @throws IllegalArgumentException If the client ID is null or empty. + */ + public UpdateRequest setClientId(String clientId) { + checkArgument(!Strings.isNullOrEmpty(clientId), "Client ID must not be null or empty."); + properties.put("clientId", clientId); + return this; + } + + /** + * Sets the client secret for the new provider. This is required for the code flow. + * + * @param clientSecret A non-null, non-empty client secret string. + * @throws IllegalArgumentException If the client secret is null or empty. + */ + public UpdateRequest setClientSecret(String clientSecret) { + checkArgument(!Strings.isNullOrEmpty(clientSecret), + "Client Secret must not be null or empty."); + properties.put("clientSecret", clientSecret); + return this; + } + + /** + * Sets the issuer for the existing provider. + * + * @param issuer A non-null, non-empty issuer URL string. + * @throws IllegalArgumentException If the issuer URL is null or empty, or if the format is + * invalid. + */ + public UpdateRequest setIssuer(String issuer) { + checkArgument(!Strings.isNullOrEmpty(issuer), "Issuer must not be null or empty."); + assertValidUrl(issuer); + properties.put("issuer", issuer); + return this; + } + + /** + * Sets whether to enable the code response flow for the new provider. By default, this is not + * enabled if no response type is specified. + * + *

A client secret must be set for this response type. + * + *

Having both the code and ID token response flows is currently not supported. + * + * @param enabled A boolean signifying whether the code response type is supported. + */ + public UpdateRequest setCodeResponseType(boolean enabled) { + Map map = ensureResponseType(properties); + map.put("code", enabled); + return this; + } + + /** + * Sets whether to enable the ID token response flow for the new provider. By default, this is + * enabled if no response type is specified. + * + *

Having both the code and ID token response flows is currently not supported. + * + * @param enabled A boolean signifying whether the ID token response type is supported. + */ + public UpdateRequest setIdTokenResponseType(boolean enabled) { + Map map = ensureResponseType(properties); + map.put("idToken", enabled); + return this; + } + + UpdateRequest getThis() { + return this; + } + } +} diff --git a/src/main/java/com/google/firebase/auth/PhoneIdentifier.java b/src/main/java/com/google/firebase/auth/PhoneIdentifier.java new file mode 100644 index 000000000..f387a8abd --- /dev/null +++ b/src/main/java/com/google/firebase/auth/PhoneIdentifier.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020 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.auth; + +import com.google.firebase.auth.internal.GetAccountInfoRequest; +import com.google.firebase.internal.NonNull; + +/** + * Used for looking up an account by phone number. + * + * @see {FirebaseAuth#getUsers} + */ +public final class PhoneIdentifier extends UserIdentifier { + private final String phoneNumber; + + public PhoneIdentifier(@NonNull String phoneNumber) { + UserRecord.checkPhoneNumber(phoneNumber); + this.phoneNumber = phoneNumber; + } + + @Override + public String toString() { + return "PhoneIdentifier(" + phoneNumber + ")"; + } + + @Override + void populate(@NonNull GetAccountInfoRequest payload) { + payload.addPhoneNumber(phoneNumber); + } + + @Override + boolean matches(@NonNull UserRecord userRecord) { + return phoneNumber.equals(userRecord.getPhoneNumber()); + } +} diff --git a/src/main/java/com/google/firebase/auth/ProviderConfig.java b/src/main/java/com/google/firebase/auth/ProviderConfig.java new file mode 100644 index 000000000..921a07b5b --- /dev/null +++ b/src/main/java/com/google/firebase/auth/ProviderConfig.java @@ -0,0 +1,157 @@ +/* + * Copyright 2020 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.auth; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.client.util.Key; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; + +/** + * The base class for Auth providers. + */ +public abstract class ProviderConfig { + + @Key("name") + private String resourceName; + + @Key("displayName") + private String displayName; + + @Key("enabled") + private boolean enabled; + + public String getProviderId() { + return resourceName.substring(resourceName.lastIndexOf("/") + 1); + } + + public String getDisplayName() { + return displayName; + } + + public boolean isEnabled() { + return enabled; + } + + static void assertValidUrl(String url) throws IllegalArgumentException { + try { + new URL(url); + } catch (MalformedURLException e) { + throw new IllegalArgumentException(url + " is a malformed URL.", e); + } + } + + /** + * A base specification class for creating a new provider. + * + *

Set the initial attributes of the new provider by calling various setter methods available + * in this class. + */ + public abstract static class AbstractCreateRequest> { + + final Map properties = new HashMap<>(); + String providerId; + + T setProviderId(String providerId) { + this.providerId = providerId; + return getThis(); + } + + String getProviderId() { + return providerId; + } + + /** + * Sets the display name for the new provider. + * + * @param displayName A non-null, non-empty display name string. + * @throws IllegalArgumentException If the display name is null or empty. + */ + public T setDisplayName(String displayName) { + checkArgument(!Strings.isNullOrEmpty(displayName), "Display name must not be null or empty."); + properties.put("displayName", displayName); + return getThis(); + } + + /** + * Sets whether to allow the user to sign in with the provider. + * + * @param enabled A boolean indicating whether the user can sign in with the provider. + */ + public T setEnabled(boolean enabled) { + properties.put("enabled", enabled); + return getThis(); + } + + Map getProperties() { + return ImmutableMap.copyOf(properties); + } + + abstract T getThis(); + } + + /** + * A base class for updating the attributes of an existing provider. + */ + public abstract static class AbstractUpdateRequest> { + + final String providerId; + final Map properties = new HashMap<>(); + + AbstractUpdateRequest(String providerId) { + checkArgument(!Strings.isNullOrEmpty(providerId), "Provider ID must not be null or empty."); + this.providerId = providerId; + } + + String getProviderId() { + return providerId; + } + + /** + * Sets the display name for the existing provider. + * + * @param displayName A non-null, non-empty display name string. + * @throws IllegalArgumentException If the display name is null or empty. + */ + public T setDisplayName(String displayName) { + checkArgument(!Strings.isNullOrEmpty(displayName), "Display name must not be null or empty."); + properties.put("displayName", displayName); + return getThis(); + } + + /** + * Sets whether to allow the user to sign in with the provider. + * + * @param enabled A boolean indicating whether the user can sign in with the provider. + */ + public T setEnabled(boolean enabled) { + properties.put("enabled", enabled); + return getThis(); + } + + Map getProperties() { + return ImmutableMap.copyOf(properties); + } + + abstract T getThis(); + } +} diff --git a/src/main/java/com/google/firebase/auth/ProviderIdentifier.java b/src/main/java/com/google/firebase/auth/ProviderIdentifier.java new file mode 100644 index 000000000..cb44e0c0a --- /dev/null +++ b/src/main/java/com/google/firebase/auth/ProviderIdentifier.java @@ -0,0 +1,56 @@ +/* + * Copyright 2020 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.auth; + +import com.google.firebase.auth.internal.GetAccountInfoRequest; +import com.google.firebase.internal.NonNull; + +/** + * Used for looking up an account by provider. + * + * @see {FirebaseAuth#getUsers} + */ +public final class ProviderIdentifier extends UserIdentifier { + private final String providerId; + private final String providerUid; + + public ProviderIdentifier(@NonNull String providerId, @NonNull String providerUid) { + UserRecord.checkProvider(providerId, providerUid); + this.providerId = providerId; + this.providerUid = providerUid; + } + + @Override + public String toString() { + return "ProviderIdentifier(" + providerId + ", " + providerUid + ")"; + } + + @Override + void populate(@NonNull GetAccountInfoRequest payload) { + payload.addFederatedUserId(providerId, providerUid); + } + + @Override + boolean matches(@NonNull UserRecord userRecord) { + for (UserInfo userInfo : userRecord.getProviderData()) { + if (providerId.equals(userInfo.getProviderId()) && providerUid.equals(userInfo.getUid())) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/google/firebase/auth/ProviderUserInfo.java b/src/main/java/com/google/firebase/auth/ProviderUserInfo.java index d9663e2d3..d37e24244 100644 --- a/src/main/java/com/google/firebase/auth/ProviderUserInfo.java +++ b/src/main/java/com/google/firebase/auth/ProviderUserInfo.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/RevocationCheckDecorator.java b/src/main/java/com/google/firebase/auth/RevocationCheckDecorator.java new file mode 100644 index 000000000..b693cab6c --- /dev/null +++ b/src/main/java/com/google/firebase/auth/RevocationCheckDecorator.java @@ -0,0 +1,89 @@ +/* + * Copyright 2019 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.auth; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Strings; +import com.google.firebase.ErrorCode; + +/** + * A decorator for adding token revocation checks to an existing {@link FirebaseTokenVerifier}. + */ +class RevocationCheckDecorator implements FirebaseTokenVerifier { + + private final FirebaseTokenVerifier tokenVerifier; + private final FirebaseUserManager userManager; + private final AuthErrorCode errorCode; + private final String shortName; + + private RevocationCheckDecorator( + FirebaseTokenVerifier tokenVerifier, + FirebaseUserManager userManager, + AuthErrorCode errorCode, + String shortName) { + this.tokenVerifier = checkNotNull(tokenVerifier); + this.userManager = checkNotNull(userManager); + this.errorCode = checkNotNull(errorCode); + checkArgument(!Strings.isNullOrEmpty(shortName)); + this.shortName = shortName; + } + + /** + * If the wrapped {@link FirebaseTokenVerifier} deems the input token string is valid, checks + * whether the token has been revoked. + */ + @Override + public FirebaseToken verifyToken(String token) throws FirebaseAuthException { + FirebaseToken firebaseToken = tokenVerifier.verifyToken(token); + validateDisabledOrRevoked(firebaseToken); + return firebaseToken; + } + + private void validateDisabledOrRevoked(FirebaseToken firebaseToken) throws FirebaseAuthException { + UserRecord user = userManager.getUserById(firebaseToken.getUid()); + if (user.isDisabled()) { + throw new FirebaseAuthException(ErrorCode.INVALID_ARGUMENT, + "The user record is disabled.", + /* cause= */ null, + /* response= */ null, + AuthErrorCode.USER_DISABLED); + } + long issuedAtInSeconds = (long) firebaseToken.getClaims().get("iat"); + if (user.getTokensValidAfterTimestamp() > issuedAtInSeconds * 1000) { + throw new FirebaseAuthException( + ErrorCode.INVALID_ARGUMENT, + "Firebase " + shortName + " is revoked.", + null, + null, + errorCode); + } + } + + static RevocationCheckDecorator decorateIdTokenVerifier( + FirebaseTokenVerifier tokenVerifier, FirebaseUserManager userManager) { + return new RevocationCheckDecorator( + tokenVerifier, userManager, AuthErrorCode.REVOKED_ID_TOKEN, "id token"); + } + + static RevocationCheckDecorator decorateSessionCookieVerifier( + FirebaseTokenVerifier tokenVerifier, FirebaseUserManager userManager) { + return new RevocationCheckDecorator( + tokenVerifier, userManager, AuthErrorCode.REVOKED_SESSION_COOKIE, "session cookie"); + } +} diff --git a/src/main/java/com/google/firebase/auth/SamlProviderConfig.java b/src/main/java/com/google/firebase/auth/SamlProviderConfig.java new file mode 100644 index 000000000..76f8594a8 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/SamlProviderConfig.java @@ -0,0 +1,343 @@ +/* + * Copyright 2020 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.auth; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.util.Key; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Contains metadata associated with a SAML Auth provider. + * + *

Instances of this class are immutable and thread safe. + */ +public final class SamlProviderConfig extends ProviderConfig { + + @Key("idpConfig") + private GenericJson idpConfig; + + @Key("spConfig") + private GenericJson spConfig; + + public String getIdpEntityId() { + return (String) idpConfig.get("idpEntityId"); + } + + public String getSsoUrl() { + return (String) idpConfig.get("ssoUrl"); + } + + public List getX509Certificates() { + List> idpCertificates = + (List>) idpConfig.get("idpCertificates"); + checkNotNull(idpCertificates); + ImmutableList.Builder certificates = ImmutableList.builder(); + for (Map idpCertificate : idpCertificates) { + certificates.add(idpCertificate.get("x509Certificate")); + } + return certificates.build(); + } + + public String getRpEntityId() { + return (String) spConfig.get("spEntityId"); + } + + public String getCallbackUrl() { + return (String) spConfig.get("callbackUri"); + } + + /** + * Returns a new {@link UpdateRequest}, which can be used to update the attributes of this + * provider config. + * + * @return a non-null {@link UpdateRequest} instance. + */ + public UpdateRequest updateRequest() { + return new UpdateRequest(getProviderId()); + } + + static void checkSamlProviderId(String providerId) { + checkArgument(!Strings.isNullOrEmpty(providerId), "Provider ID must not be null or empty."); + checkArgument(providerId.startsWith("saml."), + "Invalid SAML provider ID (must be prefixed with 'saml.'): " + providerId); + } + + private static List ensureNestedList(Map outerMap, String id) { + List list = (List) outerMap.get(id); + if (list == null) { + list = new ArrayList(); + outerMap.put(id, list); + } + return list; + } + + private static Map ensureNestedMap(Map outerMap, String id) { + Map map = (Map) outerMap.get(id); + if (map == null) { + map = new HashMap(); + outerMap.put(id, map); + } + return map; + } + + /** + * A specification class for creating a new SAML Auth provider. + * + *

Set the initial attributes of the new provider by calling various setter methods available + * in this class. + */ + public static final class CreateRequest extends AbstractCreateRequest { + + /** + * Creates a new {@link CreateRequest}, which can be used to create a new SAML Auth provider. + * + *

The returned object should be passed to + * {@link AbstractFirebaseAuth#createSamlProviderConfig(CreateRequest)} to register the provider + * information persistently. + */ + public CreateRequest() { } + + /** + * Sets the ID for the new provider. + * + * @param providerId A non-null, non-empty provider ID string. + * @throws IllegalArgumentException If the provider ID is null or empty, or is not prefixed with + * 'saml.'. + */ + @Override + public CreateRequest setProviderId(String providerId) { + checkSamlProviderId(providerId); + return super.setProviderId(providerId); + } + + /** + * Sets the IDP entity ID for the new provider. + * + * @param idpEntityId A non-null, non-empty IDP entity ID string. + * @throws IllegalArgumentException If the IDP entity ID is null or empty. + */ + public CreateRequest setIdpEntityId(String idpEntityId) { + checkArgument(!Strings.isNullOrEmpty(idpEntityId), + "IDP entity ID must not be null or empty."); + ensureNestedMap(properties, "idpConfig").put("idpEntityId", idpEntityId); + return this; + } + + /** + * Sets the SSO URL for the new provider. + * + * @param ssoUrl A non-null, non-empty SSO URL string. + * @throws IllegalArgumentException If the SSO URL is null or empty, or if the format is + * invalid. + */ + public CreateRequest setSsoUrl(String ssoUrl) { + checkArgument(!Strings.isNullOrEmpty(ssoUrl), "SSO URL must not be null or empty."); + assertValidUrl(ssoUrl); + ensureNestedMap(properties, "idpConfig").put("ssoUrl", ssoUrl); + return this; + } + + /** + * Adds a x509 certificate to the new provider. + * + * @param x509Certificate A non-null, non-empty x509 certificate string. + * @throws IllegalArgumentException If the x509 certificate is null or empty. + */ + public CreateRequest addX509Certificate(String x509Certificate) { + checkArgument(!Strings.isNullOrEmpty(x509Certificate), + "The x509 certificate must not be null or empty."); + Map idpConfigProperties = ensureNestedMap(properties, "idpConfig"); + List x509Certificates = ensureNestedList(idpConfigProperties, "idpCertificates"); + x509Certificates.add(ImmutableMap.of("x509Certificate", x509Certificate)); + return this; + } + + /** + * Adds a collection of x509 certificates to the new provider. + * + * @param x509Certificates A non-null, non-empty collection of x509 certificate strings. + * @throws IllegalArgumentException If the collection is null or empty, or if any x509 + * certificates are null or empty. + */ + public CreateRequest addAllX509Certificates(Collection x509Certificates) { + checkArgument(x509Certificates != null, + "The collection of x509 certificates must not be null."); + checkArgument(!x509Certificates.isEmpty(), + "The collection of x509 certificates must not be empty."); + for (String certificate : x509Certificates) { + addX509Certificate(certificate); + } + return this; + } + + /** + * Sets the RP entity ID for the new provider. + * + * @param rpEntityId A non-null, non-empty RP entity ID string. + * @throws IllegalArgumentException If the RP entity ID is null or empty. + */ + public CreateRequest setRpEntityId(String rpEntityId) { + checkArgument(!Strings.isNullOrEmpty(rpEntityId), "RP entity ID must not be null or empty."); + ensureNestedMap(properties, "spConfig").put("spEntityId", rpEntityId); + return this; + } + + /** + * Sets the callback URL for the new provider. + * + * @param callbackUrl A non-null, non-empty callback URL string. + * @throws IllegalArgumentException If the callback URL is null or empty, or if the format is + * invalid. + */ + public CreateRequest setCallbackUrl(String callbackUrl) { + checkArgument(!Strings.isNullOrEmpty(callbackUrl), "Callback URL must not be null or empty."); + assertValidUrl(callbackUrl); + ensureNestedMap(properties, "spConfig").put("callbackUri", callbackUrl); + return this; + } + + CreateRequest getThis() { + return this; + } + } + + /** + * A specification class for updating an existing SAML Auth provider. + * + *

An instance of this class can be obtained via a {@link SamlProviderConfig} object, or from + * a provider ID string. Specify the changes to be made to the provider config by calling the + * various setter methods available in this class. + */ + public static final class UpdateRequest extends AbstractUpdateRequest { + /** + * Creates a new {@link UpdateRequest}, which can be used to updates an existing SAML Auth + * provider. + * + *

The returned object should be passed to + * {@link AbstractFirebaseAuth#updateSamlProviderConfig(UpdateRequest)} to update the provider + * information persistently. + * + * @param providerId a non-null, non-empty provider ID string. + * @throws IllegalArgumentException If the provider ID is null or empty, or is not prefixed with + * 'saml.'. + */ + public UpdateRequest(String providerId) { + super(providerId); + checkSamlProviderId(providerId); + } + + /** + * Sets the IDP entity ID for the existing provider. + * + * @param idpEntityId A non-null, non-empty IDP entity ID string. + * @throws IllegalArgumentException If the IDP entity ID is null or empty. + */ + public UpdateRequest setIdpEntityId(String idpEntityId) { + checkArgument(!Strings.isNullOrEmpty(idpEntityId), + "IDP entity ID must not be null or empty."); + ensureNestedMap(properties, "idpConfig").put("idpEntityId", idpEntityId); + return this; + } + + /** + * Sets the SSO URL for the existing provider. + * + * @param ssoUrl A non-null, non-empty SSO URL string. + * @throws IllegalArgumentException If the SSO URL is null or empty, or if the format is + * invalid. + */ + public UpdateRequest setSsoUrl(String ssoUrl) { + checkArgument(!Strings.isNullOrEmpty(ssoUrl), "SSO URL must not be null or empty."); + assertValidUrl(ssoUrl); + ensureNestedMap(properties, "idpConfig").put("ssoUrl", ssoUrl); + return this; + } + + /** + * Adds a x509 certificate to the existing provider. + * + * @param x509Certificate A non-null, non-empty x509 certificate string. + * @throws IllegalArgumentException If the x509 certificate is null or empty. + */ + public UpdateRequest addX509Certificate(String x509Certificate) { + checkArgument(!Strings.isNullOrEmpty(x509Certificate), + "The x509 certificate must not be null or empty."); + Map idpConfigProperties = ensureNestedMap(properties, "idpConfig"); + List x509Certificates = ensureNestedList(idpConfigProperties, "idpCertificates"); + x509Certificates.add(ImmutableMap.of("x509Certificate", x509Certificate)); + return this; + } + + /** + * Adds a collection of x509 certificates to the existing provider. + * + * @param x509Certificates A non-null, non-empty collection of x509 certificate strings. + * @throws IllegalArgumentException If the collection is null or empty, or if any x509 + * certificates are null or empty. + */ + public UpdateRequest addAllX509Certificates(Collection x509Certificates) { + checkArgument(x509Certificates != null, + "The collection of x509 certificates must not be null."); + checkArgument(!x509Certificates.isEmpty(), + "The collection of x509 certificates must not be empty."); + for (String certificate : x509Certificates) { + addX509Certificate(certificate); + } + return this; + } + + /** + * Sets the RP entity ID for the existing provider. + * + * @param rpEntityId A non-null, non-empty RP entity ID string. + * @throws IllegalArgumentException If the RP entity ID is null or empty. + */ + public UpdateRequest setRpEntityId(String rpEntityId) { + checkArgument(!Strings.isNullOrEmpty(rpEntityId), "RP entity ID must not be null or empty."); + ensureNestedMap(properties, "spConfig").put("spEntityId", rpEntityId); + return this; + } + + /** + * Sets the callback URL for the exising provider. + * + * @param callbackUrl A non-null, non-empty callback URL string. + * @throws IllegalArgumentException If the callback URL is null or empty, or if the format is + * invalid. + */ + public UpdateRequest setCallbackUrl(String callbackUrl) { + checkArgument(!Strings.isNullOrEmpty(callbackUrl), "Callback URL must not be null or empty."); + assertValidUrl(callbackUrl); + ensureNestedMap(properties, "spConfig").put("callbackUri", callbackUrl); + return this; + } + + UpdateRequest getThis() { + return this; + } + } +} diff --git a/src/main/java/com/google/firebase/auth/SessionCookieOptions.java b/src/main/java/com/google/firebase/auth/SessionCookieOptions.java index d630780e8..6c55d242f 100644 --- a/src/main/java/com/google/firebase/auth/SessionCookieOptions.java +++ b/src/main/java/com/google/firebase/auth/SessionCookieOptions.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/UidIdentifier.java b/src/main/java/com/google/firebase/auth/UidIdentifier.java new file mode 100644 index 000000000..1819c11d3 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/UidIdentifier.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020 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.auth; + +import com.google.firebase.auth.internal.GetAccountInfoRequest; +import com.google.firebase.internal.NonNull; + +/** + * Used for looking up an account by uid. + * + * @see {FirebaseAuth#getUsers} + */ +public final class UidIdentifier extends UserIdentifier { + private final String uid; + + public UidIdentifier(@NonNull String uid) { + UserRecord.checkUid(uid); + this.uid = uid; + } + + @Override + public String toString() { + return "UidIdentifier(" + uid + ")"; + } + + @Override + void populate(@NonNull GetAccountInfoRequest payload) { + payload.addUid(uid); + } + + @Override + boolean matches(@NonNull UserRecord userRecord) { + return uid.equals(userRecord.getUid()); + } +} diff --git a/src/main/java/com/google/firebase/auth/UserIdentifier.java b/src/main/java/com/google/firebase/auth/UserIdentifier.java new file mode 100644 index 000000000..96d925703 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/UserIdentifier.java @@ -0,0 +1,31 @@ +/* + * Copyright 2020 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.auth; + +import com.google.firebase.auth.internal.GetAccountInfoRequest; +import com.google.firebase.internal.NonNull; + +/** + * Identifies a user to be looked up. + */ +public abstract class UserIdentifier { + public abstract String toString(); + + abstract void populate(@NonNull GetAccountInfoRequest payload); + + abstract boolean matches(@NonNull UserRecord userRecord); +} diff --git a/src/main/java/com/google/firebase/auth/UserImportHash.java b/src/main/java/com/google/firebase/auth/UserImportHash.java index 443c6f858..21fea1d5f 100644 --- a/src/main/java/com/google/firebase/auth/UserImportHash.java +++ b/src/main/java/com/google/firebase/auth/UserImportHash.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/UserImportOptions.java b/src/main/java/com/google/firebase/auth/UserImportOptions.java index 86accf91b..06567cfbb 100644 --- a/src/main/java/com/google/firebase/auth/UserImportOptions.java +++ b/src/main/java/com/google/firebase/auth/UserImportOptions.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/UserImportResult.java b/src/main/java/com/google/firebase/auth/UserImportResult.java index 143b0618b..07c1b2ca2 100644 --- a/src/main/java/com/google/firebase/auth/UserImportResult.java +++ b/src/main/java/com/google/firebase/auth/UserImportResult.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/UserInfo.java b/src/main/java/com/google/firebase/auth/UserInfo.java index a95e5ecdc..2eb242caf 100644 --- a/src/main/java/com/google/firebase/auth/UserInfo.java +++ b/src/main/java/com/google/firebase/auth/UserInfo.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/UserMetadata.java b/src/main/java/com/google/firebase/auth/UserMetadata.java index a2872371f..577ce3ba6 100644 --- a/src/main/java/com/google/firebase/auth/UserMetadata.java +++ b/src/main/java/com/google/firebase/auth/UserMetadata.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,14 +23,16 @@ public class UserMetadata { private final long creationTimestamp; private final long lastSignInTimestamp; + private final long lastRefreshTimestamp; public UserMetadata(long creationTimestamp) { - this(creationTimestamp, 0L); + this(creationTimestamp, 0L, 0L); } - public UserMetadata(long creationTimestamp, long lastSignInTimestamp) { + public UserMetadata(long creationTimestamp, long lastSignInTimestamp, long lastRefreshTimestamp) { this.creationTimestamp = creationTimestamp; this.lastSignInTimestamp = lastSignInTimestamp; + this.lastRefreshTimestamp = lastRefreshTimestamp; } /** @@ -50,4 +52,13 @@ public long getCreationTimestamp() { public long getLastSignInTimestamp() { return lastSignInTimestamp; } + + /** + * Returns the time at which the user was last active (ID token refreshed). + *  + * @return Milliseconds since epoch timestamp, or 0 if the user was never active. + */ + public long getLastRefreshTimestamp() { + return lastRefreshTimestamp; + } } diff --git a/src/main/java/com/google/firebase/auth/UserProvider.java b/src/main/java/com/google/firebase/auth/UserProvider.java index a13e0ecc1..278fd341d 100644 --- a/src/main/java/com/google/firebase/auth/UserProvider.java +++ b/src/main/java/com/google/firebase/auth/UserProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/UserRecord.java b/src/main/java/com/google/firebase/auth/UserRecord.java index e00450079..28c901378 100644 --- a/src/main/java/com/google/firebase/auth/UserRecord.java +++ b/src/main/java/com/google/firebase/auth/UserRecord.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.api.client.json.JsonFactory; +import com.google.api.client.util.DateTime; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -49,6 +50,7 @@ public class UserRecord implements UserInfo { private static final int MAX_CLAIMS_PAYLOAD_SIZE = 1000; private final String uid; + private final String tenantId; private final String email; private final String phoneNumber; private final boolean emailVerified; @@ -65,6 +67,7 @@ public class UserRecord implements UserInfo { checkNotNull(jsonFactory, "jsonFactory must not be null"); checkArgument(!Strings.isNullOrEmpty(response.getUid()), "uid must not be null or empty"); this.uid = response.getUid(); + this.tenantId = response.getTenantId(); this.email = response.getEmail(); this.phoneNumber = response.getPhoneNumber(); this.emailVerified = response.isEmailVerified(); @@ -80,7 +83,15 @@ public class UserRecord implements UserInfo { } } this.tokensValidAfterTimestamp = response.getValidSince() * 1000; - this.userMetadata = new UserMetadata(response.getCreatedAt(), response.getLastLoginAt()); + + String lastRefreshAtRfc3339 = response.getLastRefreshAt(); + long lastRefreshAtMillis = 0; + if (!Strings.isNullOrEmpty(lastRefreshAtRfc3339)) { + lastRefreshAtMillis = DateTime.parseRfc3339(lastRefreshAtRfc3339).getValue(); + } + + this.userMetadata = new UserMetadata( + response.getCreatedAt(), response.getLastLoginAt(), lastRefreshAtMillis); this.customClaims = parseCustomClaims(response.getCustomClaims(), jsonFactory); } @@ -107,6 +118,16 @@ public String getUid() { return uid; } + /** + * Returns the tenant ID associated with this user, if one exists. + * + * @return a tenant ID string or null. + */ + @Nullable + public String getTenantId() { + return this.tenantId; + } + /** * Returns the provider ID of this user. * @@ -190,9 +211,9 @@ public UserInfo[] getProviderData() { } /** - * Returns a timestamp in milliseconds since epoch, truncated down to the closest second. + * Returns a timestamp in milliseconds since epoch, truncated down to the closest second. * Tokens minted before this timestamp are considered invalid. - * + * * @return Timestamp in milliseconds since the epoch. Tokens minted before this timestamp are * considered invalid. */ @@ -247,6 +268,11 @@ static void checkPhoneNumber(String phoneNumber) { "phone number must be a valid, E.164 compliant identifier starting with a '+' sign"); } + static void checkProvider(String providerId, String providerUid) { + checkArgument(!Strings.isNullOrEmpty(providerId), "providerId must be a non-empty string"); + checkArgument(!Strings.isNullOrEmpty(providerUid), "providerUid must be a non-empty string"); + } + static void checkUrl(String photoUrl) { checkArgument(!Strings.isNullOrEmpty(photoUrl), "url cannot be null or empty"); try { @@ -357,10 +383,10 @@ public CreateRequest setEmailVerified(boolean emailVerified) { /** * Sets the display name for the new user. * - * @param displayName a non-null, non-empty display name string. + * @param displayName a non-null display name string. */ public CreateRequest setDisplayName(String displayName) { - checkNotNull(displayName, "displayName cannot be null or empty"); + checkNotNull(displayName, "displayName cannot be null"); properties.put("displayName", displayName); return this; } @@ -449,6 +475,29 @@ public UpdateRequest setPhoneNumber(@Nullable String phone) { if (phone != null) { checkPhoneNumber(phone); } + + if (phone == null && properties.containsKey("deleteProvider")) { + Object deleteProvider = properties.get("deleteProvider"); + if (deleteProvider != null) { + // Due to java's type erasure, we can't fully check the type. :( + @SuppressWarnings("unchecked") + Iterable deleteProviderIterable = (Iterable)deleteProvider; + + // If we've been told to unlink the phone provider both via setting phoneNumber to null + // *and* by setting providersToUnlink to include 'phone', then we'll reject that. Though + // it might also be reasonable to relax this restriction and just unlink it. + for (String dp : deleteProviderIterable) { + if ("phone".equals(dp)) { + throw new IllegalArgumentException( + "Both UpdateRequest.setPhoneNumber(null) and " + + "UpdateRequest.setProvidersToUnlink(['phone']) were set. To unlink from a " + + "phone provider, only specify UpdateRequest.setPhoneNumber(null)."); + + } + } + } + } + properties.put("phoneNumber", phone); return this; } @@ -522,6 +571,52 @@ public UpdateRequest setCustomClaims(Map customClaims) { return this; } + /** + * Links this user to the specified provider. + * + *

Linking a provider to an existing user account does not invalidate the + * refresh token of that account. In other words, the existing account + * continues to be able to access resources, despite not having used + * the newly linked provider to sign in. If you wish to force the user to + * authenticate with this new provider, you need to (a) revoke their + * refresh token (see + * https://firebase.google.com/docs/auth/admin/manage-sessions#revoke_refresh_tokens), + * and (b) ensure no other authentication methods are present on this + * account. + * + * @param providerToLink provider info to be linked to this user\'s account. + */ + public UpdateRequest setProviderToLink(@NonNull UserProvider providerToLink) { + properties.put("linkProviderUserInfo", checkNotNull(providerToLink)); + return this; + } + + /** + * Unlinks this user from the specified providers. + * + * @param providerIds list of identifiers for the identity providers. + */ + public UpdateRequest setProvidersToUnlink(Iterable providerIds) { + checkNotNull(providerIds); + for (String id : providerIds) { + checkArgument(!Strings.isNullOrEmpty(id), "providerIds must not be null or empty"); + + if ("phone".equals(id) && properties.containsKey("phoneNumber") + && properties.get("phoneNumber") == null) { + // If we've been told to unlink the phone provider both via setting phoneNumber to null + // *and* by setting providersToUnlink to include 'phone', then we'll reject that. Though + // it might also be reasonable to relax this restriction and just unlink it. + throw new IllegalArgumentException( + "Both UpdateRequest.setPhoneNumber(null) and " + + "UpdateRequest.setProvidersToUnlink(['phone']) were set. To unlink from a phone " + + "provider, only specify UpdateRequest.setPhoneNumber(null)."); + } + } + + properties.put("deleteProvider", providerIds); + return this; + } + UpdateRequest setValidSince(long epochSeconds) { checkValidSince(epochSeconds); properties.put("validSince", epochSeconds); @@ -543,7 +638,20 @@ Map getProperties(JsonFactory jsonFactory) { } if (copy.containsKey("phoneNumber") && copy.get("phoneNumber") == null) { - copy.put("deleteProvider", ImmutableList.of("phone")); + Object deleteProvider = copy.get("deleteProvider"); + if (deleteProvider != null) { + // Due to java's type erasure, we can't fully check the type. :( + @SuppressWarnings("unchecked") + Iterable deleteProviderIterable = (Iterable)deleteProvider; + + copy.put("deleteProvider", new ImmutableList.Builder() + .addAll(deleteProviderIterable) + .add("phone") + .build()); + } else { + copy.put("deleteProvider", ImmutableList.of("phone")); + } + copy.remove("phoneNumber"); } diff --git a/src/main/java/com/google/firebase/auth/hash/Argon2.java b/src/main/java/com/google/firebase/auth/hash/Argon2.java new file mode 100644 index 000000000..22c6a6150 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/Argon2.java @@ -0,0 +1,217 @@ +/* + * Copyright 2022 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.auth.hash; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.collect.ImmutableMap; +import com.google.common.io.BaseEncoding; +import com.google.firebase.auth.UserImportHash; +import java.util.Map; + +/** + * Represents the Argon2 password hashing algorithm. Can be used as an instance of {@link + * com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class Argon2 extends UserImportHash { + + private static final int MIN_HASH_LENGTH_BYTES = 4; + private static final int MAX_HASH_LENGTH_BYTES = 1024; + private static final int MIN_PARALLELISM = 1; + private static final int MAX_PARALLELISM = 16; + private static final int MIN_ITERATIONS = 1; + private static final int MAX_ITERATIONS = 16; + private static final int MIN_MEMORY_COST_KIB = 1; + private static final int MAX_MEMORY_COST_KIB = 32768; + + private final int hashLengthBytes; + private final Argon2HashType hashType; + private final int parallelism; + private final int iterations; + private final int memoryCostKib; + private final Argon2Version version; + private final String associatedData; + + private Argon2(Builder builder) { + super("ARGON2"); + checkArgument(intShouldBeBetweenLimitsInclusive(builder.hashLengthBytes, MIN_HASH_LENGTH_BYTES, + MAX_HASH_LENGTH_BYTES), + "hashLengthBytes is required for Argon2 and must be between %s and %s", + MIN_HASH_LENGTH_BYTES, MAX_HASH_LENGTH_BYTES); + checkArgument(builder.hashType != null, + "A hashType is required for Argon2"); + checkArgument( + intShouldBeBetweenLimitsInclusive(builder.parallelism, MIN_PARALLELISM, MAX_PARALLELISM), + "parallelism is required for Argon2 and must be between %s and %s", MIN_PARALLELISM, + MAX_PARALLELISM); + checkArgument( + intShouldBeBetweenLimitsInclusive(builder.iterations, MIN_ITERATIONS, MAX_ITERATIONS), + "iterations is required for Argon2 and must be between %s and %s", MIN_ITERATIONS, + MAX_ITERATIONS); + checkArgument(intShouldBeBetweenLimitsInclusive(builder.memoryCostKib, MIN_MEMORY_COST_KIB, + MAX_MEMORY_COST_KIB), + "memoryCostKib is required for Argon2 and must be less than or equal to %s", + MAX_MEMORY_COST_KIB); + this.hashLengthBytes = builder.hashLengthBytes; + this.hashType = builder.hashType; + this.parallelism = builder.parallelism; + this.iterations = builder.iterations; + this.memoryCostKib = builder.memoryCostKib; + if (builder.version != null) { + this.version = builder.version; + } else { + this.version = null; + } + if (builder.associatedData != null) { + this.associatedData = BaseEncoding.base64Url().encode(builder.associatedData); + } else { + this.associatedData = null; + } + } + + private static boolean intShouldBeBetweenLimitsInclusive(int property, int fromInclusive, + int toInclusive) { + return property >= fromInclusive && property <= toInclusive; + } + + @Override + protected Map getOptions() { + ImmutableMap.Builder argon2Parameters = ImmutableMap.builder() + .put("hashLengthBytes", hashLengthBytes) + .put("hashType", hashType.toString()) + .put("parallelism", parallelism) + .put("iterations", iterations) + .put("memoryCostKib", memoryCostKib); + if (this.associatedData != null) { + argon2Parameters.put("associatedData", associatedData); + } + if (this.version != null) { + argon2Parameters.put("version", version.toString()); + } + return ImmutableMap.of("argon2Parameters", argon2Parameters.build()); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private int hashLengthBytes; + private Argon2HashType hashType; + private int parallelism; + private int iterations; + private int memoryCostKib; + private Argon2Version version; + private byte[] associatedData; + + private Builder() {} + + /** + * Sets the hash length in bytes. Required field. + * + * @param hashLengthBytes an integer between 4 and 1024 (inclusive). + * @return This builder. + */ + public Builder setHashLengthBytes(int hashLengthBytes) { + this.hashLengthBytes = hashLengthBytes; + return this; + } + + /** + * Sets the Argon2 hash type. Required field. + * + * @param hashType a value from the {@link Argon2HashType} enum. + * @return This builder. + */ + public Builder setHashType(Argon2HashType hashType) { + this.hashType = hashType; + return this; + } + + /** + * Sets the degree of parallelism, also called threads or lanes. Required field. + * + * @param parallelism an integer between 1 and 16 (inclusive). + * @return This builder. + */ + public Builder setParallelism(int parallelism) { + this.parallelism = parallelism; + return this; + } + + /** + * Sets the number of iterations to perform. Required field. + * + * @param iterations an integer between 1 and 16 (inclusive). + * @return This builder. + */ + public Builder setIterations(int iterations) { + this.iterations = iterations; + return this; + } + + /** + * Sets the memory cost in kibibytes. Required field. + * + * @param memoryCostKib an integer between 1 and 32768 (inclusive). + * @return This builder. + */ + public Builder setMemoryCostKib(int memoryCostKib) { + this.memoryCostKib = memoryCostKib; + return this; + } + + /** + * Sets the version of the Argon2 algorithm. + * + * @param version a value from the {@link Argon2Version} enum. + * @return This builder. + */ + public Builder setVersion(Argon2Version version) { + this.version = version; + return this; + } + + /** + * Sets additional associated data, if provided, to append to the hash value for additional + * security. This data is base64 encoded before it is sent to the API. + * + * @param associatedData Associated data as a byte array. + * @return This builder. + */ + public Builder setAssociatedData(byte[] associatedData) { + this.associatedData = associatedData; + return this; + } + + public Argon2 build() { + return new Argon2(this); + } + } + + public enum Argon2HashType { + ARGON2_D, + ARGON2_ID, + ARGON2_I + } + + public enum Argon2Version { + VERSION_10, + VERSION_13 + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/Bcrypt.java b/src/main/java/com/google/firebase/auth/hash/Bcrypt.java index 2b5f89029..994484995 100644 --- a/src/main/java/com/google/firebase/auth/hash/Bcrypt.java +++ b/src/main/java/com/google/firebase/auth/hash/Bcrypt.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,7 +24,7 @@ * Represents the Bcrypt password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class Bcrypt extends UserImportHash { +public final class Bcrypt extends UserImportHash { private Bcrypt() { super("BCRYPT"); diff --git a/src/main/java/com/google/firebase/auth/hash/Hmac.java b/src/main/java/com/google/firebase/auth/hash/Hmac.java index d55879389..0a2b2bd8f 100644 --- a/src/main/java/com/google/firebase/auth/hash/Hmac.java +++ b/src/main/java/com/google/firebase/auth/hash/Hmac.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/hash/HmacMd5.java b/src/main/java/com/google/firebase/auth/hash/HmacMd5.java index b67574358..7b1da7a06 100644 --- a/src/main/java/com/google/firebase/auth/hash/HmacMd5.java +++ b/src/main/java/com/google/firebase/auth/hash/HmacMd5.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ * Represents the HMAC MD5 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class HmacMd5 extends Hmac { +public final class HmacMd5 extends Hmac { private HmacMd5(Builder builder) { super("HMAC_MD5", builder); diff --git a/src/main/java/com/google/firebase/auth/hash/HmacSha1.java b/src/main/java/com/google/firebase/auth/hash/HmacSha1.java index a9ecefd6f..16d6822fb 100644 --- a/src/main/java/com/google/firebase/auth/hash/HmacSha1.java +++ b/src/main/java/com/google/firebase/auth/hash/HmacSha1.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ * Represents the HMAC SHA1 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class HmacSha1 extends Hmac { +public final class HmacSha1 extends Hmac { private HmacSha1(Builder builder) { super("HMAC_SHA1", builder); diff --git a/src/main/java/com/google/firebase/auth/hash/HmacSha256.java b/src/main/java/com/google/firebase/auth/hash/HmacSha256.java index 78f131cff..2d8df3bfa 100644 --- a/src/main/java/com/google/firebase/auth/hash/HmacSha256.java +++ b/src/main/java/com/google/firebase/auth/hash/HmacSha256.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ * Represents the HMAC SHA256 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class HmacSha256 extends Hmac { +public final class HmacSha256 extends Hmac { private HmacSha256(Builder builder) { super("HMAC_SHA256", builder); diff --git a/src/main/java/com/google/firebase/auth/hash/HmacSha512.java b/src/main/java/com/google/firebase/auth/hash/HmacSha512.java index 21e6a2b25..1bed76e1f 100644 --- a/src/main/java/com/google/firebase/auth/hash/HmacSha512.java +++ b/src/main/java/com/google/firebase/auth/hash/HmacSha512.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ * Represents the HMAC SHA512 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class HmacSha512 extends Hmac { +public final class HmacSha512 extends Hmac { private HmacSha512(Builder builder) { super("HMAC_SHA512", builder); diff --git a/src/main/java/com/google/firebase/auth/hash/Md5.java b/src/main/java/com/google/firebase/auth/hash/Md5.java index f45362ae3..3468953db 100644 --- a/src/main/java/com/google/firebase/auth/hash/Md5.java +++ b/src/main/java/com/google/firebase/auth/hash/Md5.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,10 +20,10 @@ * Represents the MD5 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class Md5 extends RepeatableHash { +public final class Md5 extends RepeatableHash { private Md5(Builder builder) { - super("MD5", 0, 120000, builder); + super("MD5", 0, 8192, builder); } public static Builder builder() { diff --git a/src/main/java/com/google/firebase/auth/hash/Pbkdf2Sha256.java b/src/main/java/com/google/firebase/auth/hash/Pbkdf2Sha256.java index 4c5108e35..27db34921 100644 --- a/src/main/java/com/google/firebase/auth/hash/Pbkdf2Sha256.java +++ b/src/main/java/com/google/firebase/auth/hash/Pbkdf2Sha256.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ * Represents the PBKDF2 SHA256 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class Pbkdf2Sha256 extends RepeatableHash { +public final class Pbkdf2Sha256 extends RepeatableHash { private Pbkdf2Sha256(Builder builder) { super("PBKDF2_SHA256", 0, 120000, builder); diff --git a/src/main/java/com/google/firebase/auth/hash/PbkdfSha1.java b/src/main/java/com/google/firebase/auth/hash/PbkdfSha1.java index 8afe3f4ab..fd9e2f68d 100644 --- a/src/main/java/com/google/firebase/auth/hash/PbkdfSha1.java +++ b/src/main/java/com/google/firebase/auth/hash/PbkdfSha1.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ * Represents the PBKDF SHA1 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class PbkdfSha1 extends RepeatableHash { +public final class PbkdfSha1 extends RepeatableHash { private PbkdfSha1(Builder builder) { super("PBKDF_SHA1", 0, 120000, builder); diff --git a/src/main/java/com/google/firebase/auth/hash/RepeatableHash.java b/src/main/java/com/google/firebase/auth/hash/RepeatableHash.java index bcf1a9524..bf90da8fb 100644 --- a/src/main/java/com/google/firebase/auth/hash/RepeatableHash.java +++ b/src/main/java/com/google/firebase/auth/hash/RepeatableHash.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/hash/Scrypt.java b/src/main/java/com/google/firebase/auth/hash/Scrypt.java index 1c9612285..4d2445d8d 100644 --- a/src/main/java/com/google/firebase/auth/hash/Scrypt.java +++ b/src/main/java/com/google/firebase/auth/hash/Scrypt.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/hash/Sha1.java b/src/main/java/com/google/firebase/auth/hash/Sha1.java index d14975fda..e40112b8d 100644 --- a/src/main/java/com/google/firebase/auth/hash/Sha1.java +++ b/src/main/java/com/google/firebase/auth/hash/Sha1.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,10 +20,10 @@ * Represents the SHA1 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class Sha1 extends RepeatableHash { +public final class Sha1 extends RepeatableHash { private Sha1(Builder builder) { - super("SHA1", 0, 120000, builder); + super("SHA1", 1, 8192, builder); } public static Builder builder() { diff --git a/src/main/java/com/google/firebase/auth/hash/Sha256.java b/src/main/java/com/google/firebase/auth/hash/Sha256.java index ecc0e7280..11100e636 100644 --- a/src/main/java/com/google/firebase/auth/hash/Sha256.java +++ b/src/main/java/com/google/firebase/auth/hash/Sha256.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,10 +20,10 @@ * Represents the SHA256 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class Sha256 extends RepeatableHash { +public final class Sha256 extends RepeatableHash { private Sha256(Builder builder) { - super("SHA256", 0, 120000, builder); + super("SHA256", 1, 8192, builder); } public static Builder builder() { diff --git a/src/main/java/com/google/firebase/auth/hash/Sha512.java b/src/main/java/com/google/firebase/auth/hash/Sha512.java index 858d16e05..8c044600c 100644 --- a/src/main/java/com/google/firebase/auth/hash/Sha512.java +++ b/src/main/java/com/google/firebase/auth/hash/Sha512.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,10 +20,10 @@ * Represents the SHA512 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class Sha512 extends RepeatableHash { +public final class Sha512 extends RepeatableHash { private Sha512(Builder builder) { - super("SHA512", 0, 120000, builder); + super("SHA512", 1, 8192, builder); } public static Builder builder() { diff --git a/src/main/java/com/google/firebase/auth/hash/StandardScrypt.java b/src/main/java/com/google/firebase/auth/hash/StandardScrypt.java index 49f7d72f5..d489a9f4e 100644 --- a/src/main/java/com/google/firebase/auth/hash/StandardScrypt.java +++ b/src/main/java/com/google/firebase/auth/hash/StandardScrypt.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,7 +24,7 @@ * Represents the Standard Scrypt password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class StandardScrypt extends UserImportHash { +public final class StandardScrypt extends UserImportHash { private final int derivedKeyLength; private final int blockSize; diff --git a/src/main/java/com/google/firebase/auth/internal/AuthErrorHandler.java b/src/main/java/com/google/firebase/auth/internal/AuthErrorHandler.java new file mode 100644 index 000000000..c298da4dc --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/AuthErrorHandler.java @@ -0,0 +1,235 @@ +/* + * Copyright 2020 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.auth.internal; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.util.Key; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseException; +import com.google.firebase.auth.AuthErrorCode; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.internal.AbstractHttpErrorHandler; +import com.google.firebase.internal.Nullable; +import java.io.IOException; +import java.util.Map; + +final class AuthErrorHandler extends AbstractHttpErrorHandler { + + private static final Map ERROR_CODES = + ImmutableMap.builder() + .put( + "CONFIGURATION_NOT_FOUND", + new AuthError( + ErrorCode.NOT_FOUND, + "No IdP configuration found corresponding to the provided identifier", + AuthErrorCode.CONFIGURATION_NOT_FOUND)) + .put( + "DUPLICATE_EMAIL", + new AuthError( + ErrorCode.ALREADY_EXISTS, + "The user with the provided email already exists", + AuthErrorCode.EMAIL_ALREADY_EXISTS)) + .put( + "DUPLICATE_LOCAL_ID", + new AuthError( + ErrorCode.ALREADY_EXISTS, + "The user with the provided uid already exists", + AuthErrorCode.UID_ALREADY_EXISTS)) + .put( + "EMAIL_EXISTS", + new AuthError( + ErrorCode.ALREADY_EXISTS, + "The user with the provided email already exists", + AuthErrorCode.EMAIL_ALREADY_EXISTS)) + .put( + "EMAIL_NOT_FOUND", + new AuthError( + ErrorCode.NOT_FOUND, + "No user record found for the given email", + AuthErrorCode.EMAIL_NOT_FOUND)) + .put( + "INVALID_DYNAMIC_LINK_DOMAIN", + new AuthError( + ErrorCode.INVALID_ARGUMENT, + "The provided dynamic link domain is not " + + "configured or authorized for the current project", + AuthErrorCode.INVALID_DYNAMIC_LINK_DOMAIN)) + .put( + "INVALID_HOSTING_LINK_DOMAIN", + new AuthError( + ErrorCode.INVALID_ARGUMENT, + "The provided hosting link domain is not configured in Firebase Hosting or is " + + "not owned by the current project", + AuthErrorCode.INVALID_HOSTING_LINK_DOMAIN)) + .put( + "PHONE_NUMBER_EXISTS", + new AuthError( + ErrorCode.ALREADY_EXISTS, + "The user with the provided phone number already exists", + AuthErrorCode.PHONE_NUMBER_ALREADY_EXISTS)) + .put( + "TENANT_NOT_FOUND", + new AuthError( + ErrorCode.NOT_FOUND, + "No tenant found for the given identifier", + AuthErrorCode.TENANT_NOT_FOUND)) + .put( + "UNAUTHORIZED_DOMAIN", + new AuthError( + ErrorCode.INVALID_ARGUMENT, + "The domain of the continue URL is not whitelisted", + AuthErrorCode.UNAUTHORIZED_CONTINUE_URL)) + .put( + "USER_NOT_FOUND", + new AuthError( + ErrorCode.NOT_FOUND, + "No user record found for the given identifier", + AuthErrorCode.USER_NOT_FOUND)) + .build(); + + private final JsonFactory jsonFactory; + + AuthErrorHandler(JsonFactory jsonFactory) { + this.jsonFactory = checkNotNull(jsonFactory); + } + + @Override + protected FirebaseAuthException createException(FirebaseException base) { + String response = getResponse(base); + AuthServiceErrorResponse parsed = safeParse(response); + AuthError errorInfo = ERROR_CODES.get(parsed.getCode()); + if (errorInfo != null) { + return new FirebaseAuthException( + errorInfo.getErrorCode(), + errorInfo.buildMessage(parsed), + base.getCause(), + base.getHttpResponse(), + errorInfo.getAuthErrorCode()); + } + + return new FirebaseAuthException(base); + } + + private String getResponse(FirebaseException base) { + if (base.getHttpResponse() == null) { + return null; + } + + return base.getHttpResponse().getContent(); + } + + private AuthServiceErrorResponse safeParse(String response) { + AuthServiceErrorResponse parsed = new AuthServiceErrorResponse(); + if (!Strings.isNullOrEmpty(response)) { + try { + jsonFactory.createJsonParser(response).parse(parsed); + } catch (IOException ignore) { + // Ignore any error that may occur while parsing the error response. The server + // may have responded with a non-json payload. + } + } + + return parsed; + } + + private static class AuthError { + + private final ErrorCode errorCode; + private final String message; + private final AuthErrorCode authErrorCode; + + AuthError(ErrorCode errorCode, String message, AuthErrorCode authErrorCode) { + this.errorCode = errorCode; + this.message = message; + this.authErrorCode = authErrorCode; + } + + ErrorCode getErrorCode() { + return errorCode; + } + + AuthErrorCode getAuthErrorCode() { + return authErrorCode; + } + + String buildMessage(AuthServiceErrorResponse response) { + StringBuilder builder = new StringBuilder(this.message) + .append(" (").append(response.getCode()).append(")"); + String detail = response.getDetail(); + if (!Strings.isNullOrEmpty(detail)) { + builder.append(": ").append(detail); + } else { + builder.append("."); + } + + return builder.toString(); + } + } + + /** + * JSON data binding for JSON error messages sent by Google identity toolkit service. These + * error messages take the form `{"error": {"message": "CODE : OPTIONAL DETAILS"}}`. + */ + private static class AuthServiceErrorResponse { + + @Key("error") + private GenericJson error; + + @Nullable + public String getCode() { + String message = getMessage(); + if (Strings.isNullOrEmpty(message)) { + return null; + } + + int separator = message.indexOf(':'); + if (separator != -1) { + return message.substring(0, separator).trim(); + } + + return message; + } + + @Nullable + public String getDetail() { + String message = getMessage(); + if (Strings.isNullOrEmpty(message)) { + return null; + } + + int separator = message.indexOf(':'); + if (separator != -1) { + return message.substring(separator + 1).trim(); + } + + return null; + } + + private String getMessage() { + if (error == null) { + return null; + } + + return (String) error.get("message"); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/AuthHttpClient.java b/src/main/java/com/google/firebase/auth/internal/AuthHttpClient.java new file mode 100644 index 000000000..468bd4f4b --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/AuthHttpClient.java @@ -0,0 +1,81 @@ +/* + * Copyright 2020 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.auth.internal; + +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponseInterceptor; +import com.google.api.client.json.JsonFactory; +import com.google.common.collect.ImmutableSortedSet; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.internal.ErrorHandlingHttpClient; +import com.google.firebase.internal.HttpRequestInfo; +import com.google.firebase.internal.SdkUtils; +import java.util.Map; +import java.util.Set; + +/** + * Provides a convenient API for making REST calls to the Firebase Auth backend servers. + */ +public final class AuthHttpClient { + + private static final String CLIENT_VERSION_HEADER = "X-Client-Version"; + + private static final String CLIENT_VERSION = "Java/Admin/" + SdkUtils.getVersion(); + + private final ErrorHandlingHttpClient httpClient; + private final JsonFactory jsonFactory; + + public AuthHttpClient(JsonFactory jsonFactory, HttpRequestFactory requestFactory) { + AuthErrorHandler authErrorHandler = new AuthErrorHandler(jsonFactory); + this.httpClient = new ErrorHandlingHttpClient<>(requestFactory, jsonFactory, authErrorHandler); + this.jsonFactory = jsonFactory; + } + + public static Set generateMask(Map properties) { + ImmutableSortedSet.Builder maskBuilder = ImmutableSortedSet.naturalOrder(); + for (Map.Entry entry : properties.entrySet()) { + if (entry.getValue() instanceof Map) { + Set childMask = generateMask((Map) entry.getValue()); + for (String childProperty : childMask) { + maskBuilder.add(entry.getKey() + "." + childProperty); + } + } else { + maskBuilder.add(entry.getKey()); + } + } + return maskBuilder.build(); + } + + public void setInterceptor(HttpResponseInterceptor interceptor) { + this.httpClient.setInterceptor(interceptor); + } + + public T sendRequest(HttpRequestInfo request, Class clazz) throws FirebaseAuthException { + IncomingHttpResponse response = this.sendRequest(request); + return this.parse(response, clazz); + } + + public IncomingHttpResponse sendRequest(HttpRequestInfo request) throws FirebaseAuthException { + request.addHeader(CLIENT_VERSION_HEADER, CLIENT_VERSION); + return httpClient.send(request); + } + + public T parse(IncomingHttpResponse response, Class clazz) throws FirebaseAuthException { + return httpClient.parse(response, clazz); + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/HttpErrorResponse.java b/src/main/java/com/google/firebase/auth/internal/BatchDeleteResponse.java similarity index 55% rename from src/main/java/com/google/firebase/auth/internal/HttpErrorResponse.java rename to src/main/java/com/google/firebase/auth/internal/BatchDeleteResponse.java index d4be4b4a6..c8b28eec7 100644 --- a/src/main/java/com/google/firebase/auth/internal/HttpErrorResponse.java +++ b/src/main/java/com/google/firebase/auth/internal/BatchDeleteResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,33 +17,35 @@ package com.google.firebase.auth.internal; import com.google.api.client.util.Key; -import com.google.common.base.Strings; +import java.util.List; /** - * JSON data binding for JSON error messages sent by Google identity toolkit service. + * Represents the response from Google identity Toolkit for a batch delete request. */ -public class HttpErrorResponse { +public class BatchDeleteResponse { - @Key("error") - private Error error; + @Key("errors") + private List errors; - public String getErrorCode() { - if (error != null) { - if (!Strings.isNullOrEmpty(error.getCode())) { - return error.getCode(); - } - } - return "unknown"; + public List getErrors() { + return errors; } - public static class Error { + public static class ErrorInfo { + @Key("index") + private int index; @Key("message") - private String code; + private String message; + + // A 'localId' field also exists here, but is not currently exposed in the Admin SDK. - public String getCode() { - return code; + public int getIndex() { + return index; } - } + public String getMessage() { + return message; + } + } } diff --git a/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java b/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java index 3036f9f28..ecf9e354d 100644 --- a/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java +++ b/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ package com.google.firebase.auth.internal; +import com.google.firebase.auth.FirebaseAuthException; import com.google.firebase.internal.NonNull; import java.io.IOException; @@ -32,10 +33,10 @@ interface CryptoSigner { * * @param payload Data to be signed * @return Signature as a byte array - * @throws IOException If an error occurs during signing + * @throws FirebaseAuthException If an error occurs during signing */ @NonNull - byte[] sign(@NonNull byte[] payload) throws IOException; + byte[] sign(@NonNull byte[] payload) throws FirebaseAuthException; /** * Returns the client email of the service account used to sign payloads. diff --git a/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java b/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java index 3447f05d9..543f955c3 100644 --- a/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java +++ b/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java @@ -8,10 +8,8 @@ import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.HttpResponse; import com.google.api.client.http.HttpResponseInterceptor; -import com.google.api.client.http.json.JsonHttpContent; +import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.JsonObjectParser; -import com.google.api.client.util.Key; import com.google.api.client.util.StringUtils; import com.google.auth.ServiceAccountSigner; import com.google.auth.oauth2.GoogleCredentials; @@ -21,9 +19,14 @@ import com.google.common.io.BaseEncoding; import com.google.common.io.ByteStreams; import com.google.firebase.FirebaseApp; -import com.google.firebase.FirebaseOptions; +import com.google.firebase.FirebaseException; import com.google.firebase.ImplFirebaseTrampolines; -import com.google.firebase.internal.FirebaseRequestInitializer; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.internal.Utils; +import com.google.firebase.internal.AbstractPlatformErrorHandler; +import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.ErrorHandlingHttpClient; +import com.google.firebase.internal.HttpRequestInfo; import com.google.firebase.internal.NonNull; import java.io.IOException; import java.util.Map; @@ -31,10 +34,12 @@ /** * A set of {@link CryptoSigner} implementations and utilities for interacting with them. */ -class CryptoSigners { +public class CryptoSigners { private static final String METADATA_SERVICE_URL = - "http://metadata/computeMetadata/v1/instance/service-accounts/default/email"; + "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email"; + + private CryptoSigners() { } /** * A {@link CryptoSigner} implementation that uses service account credentials or equivalent @@ -61,56 +66,41 @@ public String getAccount() { /** * @ {@link CryptoSigner} implementation that uses the - * - * Google IAM service to sign data. + * + * Google IAMCredentials service to sign data. */ static class IAMCryptoSigner implements CryptoSigner { private static final String IAM_SIGN_BLOB_URL = - "https://iam.googleapis.com/v1/projects/-/serviceAccounts/%s:signBlob"; + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:signBlob"; - private final HttpRequestFactory requestFactory; - private final JsonFactory jsonFactory; private final String serviceAccount; - private HttpResponseInterceptor interceptor; + private final ErrorHandlingHttpClient httpClient; IAMCryptoSigner( @NonNull HttpRequestFactory requestFactory, @NonNull JsonFactory jsonFactory, @NonNull String serviceAccount) { - this.requestFactory = checkNotNull(requestFactory); - this.jsonFactory = checkNotNull(jsonFactory); checkArgument(!Strings.isNullOrEmpty(serviceAccount)); this.serviceAccount = serviceAccount; + this.httpClient = new ErrorHandlingHttpClient<>( + requestFactory, + jsonFactory, + new IAMErrorHandler(jsonFactory)); } void setInterceptor(HttpResponseInterceptor interceptor) { - this.interceptor = interceptor; + httpClient.setInterceptor(interceptor); } @Override - public byte[] sign(byte[] payload) throws IOException { - String encodedUrl = String.format(IAM_SIGN_BLOB_URL, serviceAccount); - HttpResponse response = null; + public byte[] sign(byte[] payload) throws FirebaseAuthException { String encodedPayload = BaseEncoding.base64().encode(payload); - Map content = ImmutableMap.of("bytesToSign", encodedPayload); - try { - HttpRequest request = requestFactory.buildPostRequest(new GenericUrl(encodedUrl), - new JsonHttpContent(jsonFactory, content)); - request.setParser(new JsonObjectParser(jsonFactory)); - request.setResponseInterceptor(interceptor); - response = request.execute(); - SignBlobResponse parsed = response.parseAs(SignBlobResponse.class); - return BaseEncoding.base64().decode(parsed.signature); - } finally { - if (response != null) { - try { - response.disconnect(); - } catch (IOException ignored) { - // Ignored - } - } - } + Map content = ImmutableMap.of("payload", encodedPayload); + String encodedUrl = String.format(IAM_SIGN_BLOB_URL, serviceAccount); + HttpRequestInfo requestInfo = HttpRequestInfo.buildJsonPostRequest(encodedUrl, content); + GenericJson parsed = httpClient.sendAndParse(requestInfo, GenericJson.class); + return BaseEncoding.base64().decode((String) parsed.get("signedBlob")); } @Override @@ -119,16 +109,47 @@ public String getAccount() { } } - public static class SignBlobResponse { - @Key("signature") - private String signature; + /** + * A {@link CryptoSigner} implementation that doesn't sign data. For use with the Auth Emulator + * only + */ + public static class EmulatorCryptoSigner implements CryptoSigner { + + private static final String ACCOUNT = "firebase-auth-emulator@example.com"; + + @Override + public byte[] sign(byte[] payload) { + return "".getBytes(); + } + + @Override + public String getAccount() { + return ACCOUNT; + } + } + + private static class IAMErrorHandler + extends AbstractPlatformErrorHandler { + + IAMErrorHandler(JsonFactory jsonFactory) { + super(jsonFactory); + } + + @Override + protected FirebaseAuthException createException(FirebaseException base) { + return new FirebaseAuthException(base); + } } /** * Initializes a {@link CryptoSigner} instance for the given Firebase app. Follows the protocol * documented at go/firebase-admin-sign. */ - static CryptoSigner getCryptoSigner(FirebaseApp firebaseApp) throws IOException { + public static CryptoSigner getCryptoSigner(FirebaseApp firebaseApp) throws IOException { + if (Utils.isEmulatorMode()) { + return new EmulatorCryptoSigner(); + } + GoogleCredentials credentials = ImplFirebaseTrampolines.getCredentials(firebaseApp); // If the SDK was initialized with a service account, use it to sign bytes. @@ -136,14 +157,12 @@ static CryptoSigner getCryptoSigner(FirebaseApp firebaseApp) throws IOException return new ServiceAccountCryptoSigner((ServiceAccountCredentials) credentials); } - FirebaseOptions options = firebaseApp.getOptions(); - HttpRequestFactory requestFactory = options.getHttpTransport().createRequestFactory( - new FirebaseRequestInitializer(firebaseApp)); - JsonFactory jsonFactory = options.getJsonFactory(); + HttpRequestFactory requestFactory = ApiClientUtils.newAuthorizedRequestFactory(firebaseApp); + JsonFactory jsonFactory = firebaseApp.getOptions().getJsonFactory(); // If the SDK was initialized with a service account email, use it with the IAM service // to sign bytes. - String serviceAccountId = options.getServiceAccountId(); + String serviceAccountId = firebaseApp.getOptions().getServiceAccountId(); if (!Strings.isNullOrEmpty(serviceAccountId)) { return new IAMCryptoSigner(requestFactory, jsonFactory, serviceAccountId); } @@ -156,15 +175,22 @@ static CryptoSigner getCryptoSigner(FirebaseApp firebaseApp) throws IOException // Attempt to discover a service account email from the local Metadata service. Use it // with the IAM service to sign bytes. - HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(METADATA_SERVICE_URL)); + serviceAccountId = discoverServiceAccountId(firebaseApp); + return new IAMCryptoSigner(requestFactory, jsonFactory, serviceAccountId); + } + + private static String discoverServiceAccountId(FirebaseApp firebaseApp) throws IOException { + HttpRequestFactory metadataRequestFactory = + ApiClientUtils.newUnauthorizedRequestFactory(firebaseApp); + HttpRequest request = metadataRequestFactory.buildGetRequest( + new GenericUrl(METADATA_SERVICE_URL)); request.getHeaders().set("Metadata-Flavor", "Google"); HttpResponse response = request.execute(); try { byte[] output = ByteStreams.toByteArray(response.getContent()); - serviceAccountId = StringUtils.newStringUtf8(output).trim(); - return new IAMCryptoSigner(requestFactory, jsonFactory, serviceAccountId); + return StringUtils.newStringUtf8(output).trim(); } finally { - response.disconnect(); + ApiClientUtils.disconnectQuietly(response); } } } diff --git a/src/main/java/com/google/firebase/auth/internal/DownloadAccountResponse.java b/src/main/java/com/google/firebase/auth/internal/DownloadAccountResponse.java index 7b56d6d2f..50dab1289 100644 --- a/src/main/java/com/google/firebase/auth/internal/DownloadAccountResponse.java +++ b/src/main/java/com/google/firebase/auth/internal/DownloadAccountResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/internal/FirebaseCustomAuthToken.java b/src/main/java/com/google/firebase/auth/internal/FirebaseCustomAuthToken.java index e67576464..bcffb71ed 100644 --- a/src/main/java/com/google/firebase/auth/internal/FirebaseCustomAuthToken.java +++ b/src/main/java/com/google/firebase/auth/internal/FirebaseCustomAuthToken.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ import com.google.api.client.json.webtoken.JsonWebSignature; import com.google.api.client.util.Key; import com.google.firebase.auth.FirebaseToken; +import com.google.firebase.internal.Nullable; import java.io.IOException; @@ -77,6 +78,9 @@ public static class Payload extends IdToken.Payload { @Key("claims") private GenericJson developerClaims; + @Key("tenant_id") + private String tenantId; + public final String getUid() { return uid; } @@ -95,6 +99,15 @@ public Payload setDeveloperClaims(GenericJson developerClaims) { return this; } + public final String getTenantId() { + return tenantId; + } + + public Payload setTenantId(String tenantId) { + this.tenantId = tenantId; + return this; + } + @Override public Payload setIssuer(String issuer) { return (Payload) super.setIssuer(issuer); diff --git a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java index 72ef91791..bc41c7d90 100644 --- a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java +++ b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,9 +25,11 @@ import com.google.api.client.util.Base64; import com.google.api.client.util.Clock; import com.google.api.client.util.StringUtils; - +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; -import com.google.firebase.FirebaseApp; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.internal.Nullable; + import java.io.IOException; import java.util.Collection; import java.util.Map; @@ -42,19 +44,27 @@ public class FirebaseTokenFactory { private final JsonFactory jsonFactory; private final Clock clock; private final CryptoSigner signer; + private final String tenantId; - FirebaseTokenFactory(JsonFactory jsonFactory, Clock clock, CryptoSigner signer) { + public FirebaseTokenFactory( + JsonFactory jsonFactory, Clock clock, CryptoSigner signer, @Nullable String tenantId) { this.jsonFactory = checkNotNull(jsonFactory); this.clock = checkNotNull(clock); this.signer = checkNotNull(signer); + this.tenantId = tenantId; + } + + @VisibleForTesting + FirebaseTokenFactory(JsonFactory jsonFactory, Clock clock, CryptoSigner signer) { + this(jsonFactory, clock, signer, null); } - String createSignedCustomAuthTokenForUser(String uid) throws IOException { + String createSignedCustomAuthTokenForUser(String uid) throws FirebaseAuthException { return createSignedCustomAuthTokenForUser(uid, null); } public String createSignedCustomAuthTokenForUser( - String uid, Map developerClaims) throws IOException { + String uid, Map developerClaims) throws FirebaseAuthException { checkArgument(!Strings.isNullOrEmpty(uid), "Uid must be provided."); checkArgument(uid.length() <= 128, "Uid must be shorter than 128 characters."); @@ -69,6 +79,9 @@ public String createSignedCustomAuthTokenForUser( .setAudience(FirebaseCustomAuthToken.FIREBASE_AUDIENCE) .setIssuedAtTimeSeconds(issuedAt) .setExpirationTimeSeconds(issuedAt + FirebaseCustomAuthToken.TOKEN_DURATION_SECONDS); + if (!Strings.isNullOrEmpty(tenantId)) { + payload.setTenantId(tenantId); + } if (developerClaims != null) { Collection reservedNames = payload.getClassInfo().getNames(); @@ -78,28 +91,33 @@ public String createSignedCustomAuthTokenForUser( String.format("developerClaims must not contain a reserved key: %s", key)); } } + GenericJson jsonObject = new GenericJson(); jsonObject.putAll(developerClaims); payload.setDeveloperClaims(jsonObject); } + return signPayload(header, payload); } - private String signPayload(JsonWebSignature.Header header, - FirebaseCustomAuthToken.Payload payload) throws IOException { - String headerString = Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(header)); - String payloadString = Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(payload)); - String content = headerString + "." + payloadString; + private String signPayload( + JsonWebSignature.Header header, + FirebaseCustomAuthToken.Payload payload) throws FirebaseAuthException { + String content = encodePayload(header, payload); byte[] contentBytes = StringUtils.getBytesUtf8(content); String signature = Base64.encodeBase64URLSafeString(signer.sign(contentBytes)); return content + "." + signature; } - public static FirebaseTokenFactory fromApp( - FirebaseApp firebaseApp, Clock clock) throws IOException { - return new FirebaseTokenFactory( - firebaseApp.getOptions().getJsonFactory(), - clock, - CryptoSigners.getCryptoSigner(firebaseApp)); + private String encodePayload( + JsonWebSignature.Header header, FirebaseCustomAuthToken.Payload payload) { + try { + String headerString = Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(header)); + String payloadString = Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(payload)); + return headerString + "." + payloadString; + } catch (IOException e) { + throw new IllegalArgumentException( + "Failed to encode JWT with the given claims: " + e.getMessage(), e); + } } } diff --git a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenVerifier.java b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenVerifier.java deleted file mode 100644 index 98b269f0a..000000000 --- a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenVerifier.java +++ /dev/null @@ -1,335 +0,0 @@ -/* - * Copyright 2017 Google Inc. - * - * 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.auth.internal; - -import static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Preconditions.checkNotNull; - -import com.google.api.client.auth.openidconnect.IdToken; -import com.google.api.client.auth.openidconnect.IdToken.Payload; -import com.google.api.client.auth.openidconnect.IdTokenVerifier; -import com.google.api.client.googleapis.auth.oauth2.GooglePublicKeysManager; -import com.google.api.client.json.webtoken.JsonWebSignature.Header; -import com.google.api.client.util.ArrayMap; -import com.google.api.client.util.Clock; -import com.google.common.base.Strings; -import com.google.firebase.auth.FirebaseAuthException; - -import com.google.firebase.internal.NonNull; -import java.io.IOException; -import java.math.BigDecimal; -import java.security.GeneralSecurityException; -import java.security.PublicKey; -import java.util.Collection; -import java.util.Collections; - -/** - * Verifies that a JWT returned by Firebase is valid for use in the this project. - * - *

This class should be kept as a Singleton within the server in order to maximize caching of the - * public signing keys. - */ -public final class FirebaseTokenVerifier extends IdTokenVerifier { - - static final String ID_TOKEN_CERT_URL = - "https://www.googleapis.com/robot/v1/metadata/x509/" - + "securetoken@system.gserviceaccount.com"; - static final String ID_TOKEN_ISSUER_PREFIX = "https://securetoken.google.com/"; - - static final String SESSION_COOKIE_CERT_URL = - "https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys"; - static final String SESSION_COOKIE_ISSUER_PREFIX = "https://session.firebase.google.com/"; - - private static final String FIREBASE_AUDIENCE = - "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit"; - - private static final String ERROR_INVALID_CREDENTIAL = "ERROR_INVALID_CREDENTIAL"; - private static final String ERROR_RUNTIME_EXCEPTION = "ERROR_RUNTIME_EXCEPTION"; - private static final String PROJECT_ID_MATCH_MESSAGE = - "Make sure the %s comes from the same Firebase project as the service account used to " - + "authenticate this SDK."; - private static final String VERIFY_TOKEN_DOCS_MESSAGE = - "See %s for details on how to retrieve %s."; - private static final String ALGORITHM = "RS256"; - - private final String projectId; - private final GooglePublicKeysManager publicKeysManager; - private final String method; - private final String shortName; - private final String articledShortName; - - private final String projectIdMatchMessage; - private final String verifyTokenMessage; - - private FirebaseTokenVerifier(Builder builder) { - super(builder); - checkArgument(!Strings.isNullOrEmpty(builder.projectId), "projectId must be set"); - checkArgument(!Strings.isNullOrEmpty(builder.shortName), "shortName must be set"); - checkArgument(!Strings.isNullOrEmpty(builder.method), "method must be set"); - this.projectId = builder.projectId; - this.shortName = builder.shortName; - this.articledShortName = prefixWithIndefiniteArticle(shortName); - this.method = builder.method; - this.publicKeysManager = checkNotNull(builder.publicKeysManager, - "publicKeysManager must be set"); - this.projectIdMatchMessage = String.format(PROJECT_ID_MATCH_MESSAGE, shortName); - this.verifyTokenMessage = String.format(VERIFY_TOKEN_DOCS_MESSAGE, builder.docUrl, - articledShortName); - } - - /** - * We are changing the semantics of the super-class method in order to provide more details on why - * this is failing to the developer. - */ - public void verifyTokenAndSignature(IdToken token) throws FirebaseAuthException { - Payload payload = token.getPayload(); - Header header = token.getHeader(); - String errorMessage = null; - - boolean isCustomToken = - payload.getAudience() != null && payload.getAudience().equals(FIREBASE_AUDIENCE); - boolean isLegacyCustomToken = - header.getAlgorithm() != null - && header.getAlgorithm().equals("HS256") - && payload.get("v") != null - && payload.get("v").equals(new BigDecimal(0)) - && payload.get("d") != null - && payload.get("d") instanceof ArrayMap - && ((ArrayMap) payload.get("d")).get("uid") != null; - - if (header.getKeyId() == null) { - if (isCustomToken) { - errorMessage = String.format("%s expects %s, but was given a custom token.", - method, articledShortName); - } else if (isLegacyCustomToken) { - errorMessage = String.format("%s expects %s, but was given a legacy custom token.", - method, articledShortName); - } else { - errorMessage = String.format("Firebase %s has no \"kid\" claim.", shortName); - } - } else if (header.getAlgorithm() == null || !header.getAlgorithm().equals(ALGORITHM)) { - errorMessage = - String.format( - "Firebase %s has incorrect algorithm. Expected \"%s\" but got \"%s\".", - shortName, ALGORITHM, header.getAlgorithm()); - } else if (!token.verifyAudience(getAudience())) { - errorMessage = - String.format( - "Firebase %s has incorrect \"aud\" (audience) claim. Expected \"%s\" but got " - + "\"%s\". %s", shortName, concat(getAudience()), - concat(token.getPayload().getAudienceAsList()), projectIdMatchMessage); - } else if (!token.verifyIssuer(getIssuers())) { - errorMessage = - String.format( - "Firebase %s has incorrect \"iss\" (issuer) claim. " - + "Expected \"%s\" but got \"%s\". %s", shortName, concat(getIssuers()), - token.getPayload().getIssuer(), projectIdMatchMessage); - } else if (payload.getSubject() == null) { - errorMessage = String.format("Firebase %s has no \"sub\" (subject) claim.", shortName); - } else if (payload.getSubject().isEmpty()) { - errorMessage = String.format("Firebase %s has an empty string \"sub\" (subject) claim.", - shortName); - } else if (payload.getSubject().length() > 128) { - errorMessage = String.format("Firebase %s has \"sub\" (subject) claim longer than " - + "128 characters.", shortName); - } else if (!token.verifyTime(getClock().currentTimeMillis(), getAcceptableTimeSkewSeconds())) { - errorMessage = - String.format("Firebase %s has expired or is not yet valid. Get a fresh %s and " - + "try again.", shortName, shortName); - } - - if (errorMessage != null) { - throw new FirebaseAuthException(ERROR_INVALID_CREDENTIAL, - String.format("%s %s", errorMessage, verifyTokenMessage)); - } - - try { - if (!verifySignature(token)) { - throw new FirebaseAuthException(ERROR_INVALID_CREDENTIAL, - String.format("Firebase %s isn't signed by a valid public key. %s", - shortName, verifyTokenMessage)); - } - } catch (IOException | GeneralSecurityException e) { - throw new FirebaseAuthException( - ERROR_RUNTIME_EXCEPTION, "Error while verifying signature.", e); - } - } - - private String prefixWithIndefiniteArticle(String word) { - if ("aeiouAEIOU".indexOf(word.charAt(0)) < 0) { - return "a " + word; - } else { - return "an " + word; - } - } - - private String concat(Collection collection) { - StringBuilder stringBuilder = new StringBuilder(); - for (String inputLine : collection) { - stringBuilder.append(inputLine.trim()).append(", "); - } - return stringBuilder.substring(0, stringBuilder.length() - 2); - } - - /** - * Verifies the cryptographic signature on the FirebaseToken. Can block on a web request to fetch - * the keys if they have expired. - */ - private boolean verifySignature(IdToken token) throws GeneralSecurityException, IOException { - for (PublicKey key : publicKeysManager.getPublicKeys()) { - if (token.verifySignature(key)) { - return true; - } - } - return false; - } - - public String getProjectId() { - return projectId; - } - - /** - * Builder for {@link FirebaseTokenVerifier}. - */ - public static class Builder extends IdTokenVerifier.Builder { - - private String projectId; - private String shortName; - private String method; - private String docUrl; - private GooglePublicKeysManager publicKeysManager; - - public String getProjectId() { - return projectId; - } - - /** - * Sets the project ID and a URL prefix for the issuer (iss) claim. The full issuer claim - * is the concatenation of the prefix and the project ID. - * - * @param issuerPrefix A URL prefix. - * @param projectId A Firebase project ID. - * @return This builder. - */ - public Builder setProjectId(String issuerPrefix, String projectId) { - this.projectId = projectId; - this.setAudience(Collections.singleton(projectId)); - this.setIssuer(issuerPrefix + projectId); - return this; - } - - /** - * Sets the short name of the type of tokens being validated (e.g. ID token, session cookie). - * - * @param shortName A short string identifier. - * @return This builder. - */ - public Builder setShortName(String shortName) { - this.shortName = shortName; - return this; - } - - /** - * Sets the name of the operation that triggers token verification (e.g. verifyIdToken()) - * - * @param method A method name. - * @return This builder. - */ - public Builder setMethod(String method) { - this.method = method; - return this; - } - - /** - * A URL to public documentation where more information about token verification can be found. - * - * @param docUrl A documentation URL. - * @return This builder. - */ - public Builder setDocUrl(String docUrl) { - this.docUrl = docUrl; - return this; - } - - /** - * Sets the {@code Clock} instance to be used to compare token issue and expiry times. - * - * @param clock A {@code Clock} instance. - * @return This builder. - */ - @Override - public Builder setClock(Clock clock) { - return (Builder) super.setClock(clock); - } - - /** - * Overrides the GooglePublicKeysManager from the default. - * - * @param publicKeysManager A public keys manager. - * @return This builder. - */ - public Builder setPublicKeysManager(GooglePublicKeysManager publicKeysManager) { - this.publicKeysManager = publicKeysManager; - return this; - } - - @Override - public FirebaseTokenVerifier build() { - return new FirebaseTokenVerifier(this); - } - } - - /** - * Creates a new {@link FirebaseTokenVerifier} for verifying Firebase ID tokens. - * - * @param projectId Project ID string - * @param keyManagers {@link KeyManagers} instance with public key managers to use - * @param clock {@code Clock} instance for Google API client - * @return A new {@link FirebaseTokenVerifier} instance - */ - @NonNull public static FirebaseTokenVerifier createIdTokenVerifier( - @NonNull String projectId, @NonNull KeyManagers keyManagers, @NonNull Clock clock) { - return new FirebaseTokenVerifier.Builder() - .setProjectId(ID_TOKEN_ISSUER_PREFIX, projectId) - .setPublicKeysManager(keyManagers.getIdTokenKeysManager()) - .setShortName("ID token") - .setMethod("verifyIdToken()") - .setDocUrl("https://firebase.google.com/docs/auth/admin/verify-id-tokens") - .setClock(clock) - .build(); - } - - /** - * Creates a new {@link FirebaseTokenVerifier} for verifying Firebase ID tokens. - * - * @param projectId Project ID string - * @param keyManagers {@link KeyManagers} instance with public key managers to use - * @param clock {@code Clock} instance for Google API client - * @return A new {@link FirebaseTokenVerifier} instance - */ - @NonNull public static FirebaseTokenVerifier createSessionCookieVerifier( - @NonNull String projectId, @NonNull KeyManagers keyManagers, @NonNull Clock clock) { - return new FirebaseTokenVerifier.Builder() - .setProjectId(SESSION_COOKIE_ISSUER_PREFIX, projectId) - .setPublicKeysManager(keyManagers.getSessionCookieKeysManager()) - .setShortName("session cookie") - .setMethod("verifySessionCookie()") - .setDocUrl("https://firebase.google.com/docs/auth/admin/manage-cookies") - .setClock(clock) - .build(); - } -} diff --git a/src/main/java/com/google/firebase/auth/internal/GetAccountInfoRequest.java b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoRequest.java new file mode 100644 index 000000000..4f2230046 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoRequest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2020 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.auth.internal; + +import com.google.api.client.util.Key; +import java.util.ArrayList; +import java.util.List; + +/** + * Represents the request to look up account information. + */ +public final class GetAccountInfoRequest { + + @Key("localId") + private List uids = null; + + @Key("email") + private List emails = null; + + @Key("phoneNumber") + private List phoneNumbers = null; + + @Key("federatedUserId") + private List federatedUserIds = null; + + private static final class FederatedUserId { + @Key("providerId") + private String providerId = null; + + @Key("rawId") + private String rawId = null; + + FederatedUserId(String providerId, String rawId) { + this.providerId = providerId; + this.rawId = rawId; + } + } + + public void addUid(String uid) { + if (uids == null) { + uids = new ArrayList<>(); + } + uids.add(uid); + } + + public void addEmail(String email) { + if (emails == null) { + emails = new ArrayList<>(); + } + emails.add(email); + } + + public void addPhoneNumber(String phoneNumber) { + if (phoneNumbers == null) { + phoneNumbers = new ArrayList<>(); + } + phoneNumbers.add(phoneNumber); + } + + public void addFederatedUserId(String providerId, String providerUid) { + if (federatedUserIds == null) { + federatedUserIds = new ArrayList<>(); + } + federatedUserIds.add(new FederatedUserId(providerId, providerUid)); + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java index 3d17c50f6..e794c233a 100644 --- a/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java +++ b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -46,6 +46,9 @@ public static class User { @Key("localId") private String uid; + @Key("tenantId") + private String tenantId; + @Key("email") private String email; @@ -73,6 +76,9 @@ public static class User { @Key("lastLoginAt") private long lastLoginAt; + @Key("lastRefreshAt") + private String lastRefreshAt; + @Key("validSince") private long validSince; @@ -83,6 +89,10 @@ public String getUid() { return uid; } + public String getTenantId() { + return tenantId; + } + public String getEmail() { return email; } @@ -119,10 +129,14 @@ public long getLastLoginAt() { return lastLoginAt; } + public String getLastRefreshAt() { + return lastRefreshAt; + } + public long getValidSince() { return validSince; } - + public String getCustomClaims() { return customClaims; } diff --git a/src/main/java/com/google/firebase/auth/internal/KeyManagers.java b/src/main/java/com/google/firebase/auth/internal/KeyManagers.java deleted file mode 100644 index b22837a1a..000000000 --- a/src/main/java/com/google/firebase/auth/internal/KeyManagers.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2017 Google Inc. - * - * 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.auth.internal; - -import static com.google.common.base.Preconditions.checkNotNull; - -import com.google.api.client.googleapis.auth.oauth2.GooglePublicKeysManager; -import com.google.api.client.http.HttpTransport; -import com.google.api.client.json.gson.GsonFactory; -import com.google.api.client.util.Clock; -import com.google.common.annotations.VisibleForTesting; -import com.google.firebase.FirebaseApp; -import com.google.firebase.internal.NonNull; - -/** - * A utility for initializing and keeping tack of the various public key manager instances - * used by {@link com.google.firebase.auth.FirebaseAuth}. - */ -public class KeyManagers { - - private final GooglePublicKeysManager idTokenKeysManager; - private final GooglePublicKeysManager sessionCookieKeysManager; - - private KeyManagers( - GooglePublicKeysManager idTokenKeysManager, - GooglePublicKeysManager sessionCookieKeysManager) { - this.idTokenKeysManager = checkNotNull(idTokenKeysManager); - this.sessionCookieKeysManager = checkNotNull(sessionCookieKeysManager); - } - - /** - * Returns the key manager that should be used for ID token verification. - */ - GooglePublicKeysManager getIdTokenKeysManager() { - return idTokenKeysManager; - } - - /** - * Returns the key manager that should be used for session cookie verification. - */ - GooglePublicKeysManager getSessionCookieKeysManager() { - return sessionCookieKeysManager; - } - - /** - * Initialize a new set of key managers for the specified app using the given clock. - * - * @param app A {@link FirebaseApp} instance. - * @param clock A {@code Clock} to be used with Google API client. - * @return A new {@link KeyManagers} instance. - */ - public static KeyManagers getDefault(@NonNull FirebaseApp app, @NonNull Clock clock) { - HttpTransport transport = app.getOptions().getHttpTransport(); - return getDefault(transport, clock); - } - - @VisibleForTesting - static KeyManagers getDefault(HttpTransport transport, Clock clock) { - return new KeyManagers( - createPublicKeysManager(transport, clock, FirebaseTokenVerifier.ID_TOKEN_CERT_URL), - createPublicKeysManager(transport, clock, FirebaseTokenVerifier.SESSION_COOKIE_CERT_URL)); - } - - private static GooglePublicKeysManager createPublicKeysManager( - HttpTransport transport, Clock clock, String certUrl) { - return new GooglePublicKeysManager.Builder(transport, new GsonFactory()) - .setClock(clock) - .setPublicCertsEncodedUrl(certUrl) - .build(); - } -} diff --git a/src/main/java/com/google/firebase/auth/internal/ListOidcProviderConfigsResponse.java b/src/main/java/com/google/firebase/auth/internal/ListOidcProviderConfigsResponse.java new file mode 100644 index 000000000..187f98cf5 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/ListOidcProviderConfigsResponse.java @@ -0,0 +1,62 @@ +/* + * Copyright 2020 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.auth.internal; + +import com.google.api.client.util.Key; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.firebase.auth.OidcProviderConfig; +import java.util.List; + +/** + * JSON data binding for ListOAuthIdpConfigsResponse messages sent by Google identity toolkit + * service. + */ +public final class ListOidcProviderConfigsResponse + implements ListProviderConfigsResponse { + + @Key("oauthIdpConfigs") + private List providerConfigs; + + @Key("nextPageToken") + private String pageToken; + + @VisibleForTesting + public ListOidcProviderConfigsResponse( + List providerConfigs, String pageToken) { + this.providerConfigs = providerConfigs; + this.pageToken = pageToken; + } + + public ListOidcProviderConfigsResponse() { } + + @Override + public List getProviderConfigs() { + return providerConfigs == null ? ImmutableList.of() : providerConfigs; + } + + @Override + public boolean hasProviderConfigs() { + return providerConfigs != null && !providerConfigs.isEmpty(); + } + + @Override + public String getPageToken() { + return Strings.nullToEmpty(pageToken); + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/ListProviderConfigsResponse.java b/src/main/java/com/google/firebase/auth/internal/ListProviderConfigsResponse.java new file mode 100644 index 000000000..2f25ae623 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/ListProviderConfigsResponse.java @@ -0,0 +1,32 @@ +/* + * Copyright 2020 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.auth.internal; + +import com.google.firebase.auth.ProviderConfig; +import java.util.List; + +/** + * Interface for config list response messages sent by Google identity toolkit service. + */ +public interface ListProviderConfigsResponse { + + public List getProviderConfigs(); + + public boolean hasProviderConfigs(); + + public String getPageToken(); +} diff --git a/src/main/java/com/google/firebase/auth/internal/ListSamlProviderConfigsResponse.java b/src/main/java/com/google/firebase/auth/internal/ListSamlProviderConfigsResponse.java new file mode 100644 index 000000000..55b944d53 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/ListSamlProviderConfigsResponse.java @@ -0,0 +1,62 @@ +/* + * Copyright 2020 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.auth.internal; + +import com.google.api.client.util.Key; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.firebase.auth.SamlProviderConfig; +import java.util.List; + +/** + * JSON data binding for ListInboundSamlConfigsResponse messages sent by Google identity toolkit + * service. + */ +public final class ListSamlProviderConfigsResponse + implements ListProviderConfigsResponse { + + @Key("inboundSamlConfigs") + private List providerConfigs; + + @Key("nextPageToken") + private String pageToken; + + @VisibleForTesting + public ListSamlProviderConfigsResponse( + List providerConfigs, String pageToken) { + this.providerConfigs = providerConfigs; + this.pageToken = pageToken; + } + + public ListSamlProviderConfigsResponse() { } + + @Override + public List getProviderConfigs() { + return providerConfigs == null ? ImmutableList.of() : providerConfigs; + } + + @Override + public boolean hasProviderConfigs() { + return providerConfigs != null && !providerConfigs.isEmpty(); + } + + @Override + public String getPageToken() { + return Strings.nullToEmpty(pageToken); + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/ListTenantsResponse.java b/src/main/java/com/google/firebase/auth/internal/ListTenantsResponse.java new file mode 100644 index 000000000..f1086921f --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/ListTenantsResponse.java @@ -0,0 +1,55 @@ +/* + * Copyright 2020 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.auth.internal; + +import com.google.api.client.util.Key; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.firebase.auth.multitenancy.Tenant; +import java.util.List; + +/** + * JSON data binding for ListTenantsResponse messages sent by Google identity toolkit service. + */ +public final class ListTenantsResponse { + + @Key("tenants") + private List tenants; + + @Key("pageToken") + private String pageToken; + + @VisibleForTesting + public ListTenantsResponse(List tenants, String pageToken) { + this.tenants = tenants; + this.pageToken = pageToken; + } + + public ListTenantsResponse() { } + + public List getTenants() { + return tenants == null ? ImmutableList.of() : tenants; + } + + public boolean hasTenants() { + return tenants != null && !tenants.isEmpty(); + } + + public String getPageToken() { + return pageToken == null ? "" : pageToken; + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/UploadAccountResponse.java b/src/main/java/com/google/firebase/auth/internal/UploadAccountResponse.java index abf28539d..df7ee4711 100644 --- a/src/main/java/com/google/firebase/auth/internal/UploadAccountResponse.java +++ b/src/main/java/com/google/firebase/auth/internal/UploadAccountResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/internal/Utils.java b/src/main/java/com/google/firebase/auth/internal/Utils.java new file mode 100644 index 000000000..244a844f9 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/Utils.java @@ -0,0 +1,35 @@ +/* + * Copyright 2021 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.auth.internal; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.firebase.internal.FirebaseProcessEnvironment; + +public class Utils { + @VisibleForTesting + public static final String AUTH_EMULATOR_HOST = "FIREBASE_AUTH_EMULATOR_HOST"; + + public static boolean isEmulatorMode() { + return !Strings.isNullOrEmpty(getEmulatorHost()); + } + + public static String getEmulatorHost() { + return FirebaseProcessEnvironment.getenv(AUTH_EMULATOR_HOST); + } + +} diff --git a/src/main/java/com/google/firebase/auth/internal/package-info.java b/src/main/java/com/google/firebase/auth/internal/package-info.java index bba0b9910..4526835de 100644 --- a/src/main/java/com/google/firebase/auth/internal/package-info.java +++ b/src/main/java/com/google/firebase/auth/internal/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/auth/multitenancy/FirebaseTenantClient.java b/src/main/java/com/google/firebase/auth/multitenancy/FirebaseTenantClient.java new file mode 100644 index 000000000..15fb6d618 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/multitenancy/FirebaseTenantClient.java @@ -0,0 +1,124 @@ +/* + * Copyright 2020 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.auth.multitenancy; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponseInterceptor; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.common.base.Joiner; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.internal.AuthHttpClient; +import com.google.firebase.auth.internal.ListTenantsResponse; +import com.google.firebase.auth.internal.Utils; +import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.HttpRequestInfo; +import java.util.Map; + +final class FirebaseTenantClient { + + static final int MAX_LIST_TENANTS_RESULTS = 100; + + private static final String ID_TOOLKIT_URL = + "https://identitytoolkit.googleapis.com/%s/projects/%s"; + + private static final String ID_TOOLKIT_URL_EMULATOR = + "http://%s/identitytoolkit.googleapis.com/%s/projects/%s"; + + private final String tenantMgtBaseUrl; + private final AuthHttpClient httpClient; + + FirebaseTenantClient(FirebaseApp app) { + this( + ImplFirebaseTrampolines.getProjectId(checkNotNull(app)), + app.getOptions().getJsonFactory(), + ApiClientUtils.newAuthorizedRequestFactory(app)); + } + + FirebaseTenantClient( + String projectId, JsonFactory jsonFactory, HttpRequestFactory requestFactory) { + checkArgument(!Strings.isNullOrEmpty(projectId), + "Project ID is required to access the auth service. Use a service account credential or " + + "set the project ID explicitly via FirebaseOptions. Alternatively you can also " + + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable."); + this.tenantMgtBaseUrl = getTenantMgtBaseUrl(projectId); + this.httpClient = new AuthHttpClient(jsonFactory, requestFactory); + } + + private String getTenantMgtBaseUrl(String projectId) { + if (Utils.isEmulatorMode()) { + return String.format(ID_TOOLKIT_URL_EMULATOR, Utils.getEmulatorHost(), "v2", projectId); + } + return String.format(ID_TOOLKIT_URL, "v2", projectId); + } + + void setInterceptor(HttpResponseInterceptor interceptor) { + httpClient.setInterceptor(interceptor); + } + + Tenant getTenant(String tenantId) throws FirebaseAuthException { + String url = tenantMgtBaseUrl + getTenantUrlSuffix(tenantId); + return httpClient.sendRequest(HttpRequestInfo.buildGetRequest(url), Tenant.class); + } + + Tenant createTenant(Tenant.CreateRequest request) throws FirebaseAuthException { + String url = tenantMgtBaseUrl + "/tenants"; + return httpClient.sendRequest( + HttpRequestInfo.buildJsonPostRequest(url, request.getProperties()), + Tenant.class); + } + + Tenant updateTenant(Tenant.UpdateRequest request) throws FirebaseAuthException { + Map properties = request.getProperties(); + String url = tenantMgtBaseUrl + getTenantUrlSuffix(request.getTenantId()); + HttpRequestInfo requestInfo = HttpRequestInfo.buildJsonPatchRequest(url, properties) + .addParameter("updateMask", Joiner.on(",").join(AuthHttpClient.generateMask(properties))); + return httpClient.sendRequest(requestInfo, Tenant.class); + } + + void deleteTenant(String tenantId) throws FirebaseAuthException { + String url = tenantMgtBaseUrl + getTenantUrlSuffix(tenantId); + httpClient.sendRequest(HttpRequestInfo.buildDeleteRequest(url), GenericJson.class); + } + + ListTenantsResponse listTenants(int maxResults, String pageToken) + throws FirebaseAuthException { + ImmutableMap.Builder builder = + ImmutableMap.builder().put("pageSize", maxResults); + if (pageToken != null) { + checkArgument(!pageToken.equals( + ListTenantsPage.END_OF_LIST), "Invalid end of list page token."); + builder.put("pageToken", pageToken); + } + + HttpRequestInfo requestInfo = HttpRequestInfo.buildGetRequest(tenantMgtBaseUrl + "/tenants") + .addAllParameters(builder.build()); + return httpClient.sendRequest(requestInfo, ListTenantsResponse.class); + } + + private static String getTenantUrlSuffix(String tenantId) { + checkArgument(!Strings.isNullOrEmpty(tenantId), "Tenant ID must not be null or empty."); + return "/tenants/" + tenantId; + } +} diff --git a/src/main/java/com/google/firebase/auth/multitenancy/ListTenantsPage.java b/src/main/java/com/google/firebase/auth/multitenancy/ListTenantsPage.java new file mode 100644 index 000000000..5f9917bce --- /dev/null +++ b/src/main/java/com/google/firebase/auth/multitenancy/ListTenantsPage.java @@ -0,0 +1,245 @@ +/* + * Copyright 2020 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.auth.multitenancy; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.gax.paging.Page; +import com.google.common.collect.ImmutableList; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.internal.ListTenantsResponse; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; + +/** + * Represents a page of {@link Tenant} instances. + * + *

Provides methods for iterating over the tenants in the current page, and calling up + * subsequent pages of tenants. + * + *

Instances of this class are thread-safe and immutable. + */ +public class ListTenantsPage implements Page { + + static final String END_OF_LIST = ""; + + private final ListTenantsResponse currentBatch; + private final TenantSource source; + private final int maxResults; + + private ListTenantsPage( + @NonNull ListTenantsResponse currentBatch, @NonNull TenantSource source, int maxResults) { + this.currentBatch = checkNotNull(currentBatch); + this.source = checkNotNull(source); + this.maxResults = maxResults; + } + + /** + * Checks if there is another page of tenants available to retrieve. + * + * @return true if another page is available, or false otherwise. + */ + @Override + public boolean hasNextPage() { + return !END_OF_LIST.equals(currentBatch.getPageToken()); + } + + /** + * Returns the string token that identifies the next page. + * + *

Never returns null. Returns empty string if there are no more pages available to be + * retrieved. + * + * @return A non-null string token (possibly empty, representing no more pages) + */ + @NonNull + @Override + public String getNextPageToken() { + return currentBatch.getPageToken(); + } + + /** + * Returns the next page of tenants. + * + * @return A new {@link ListTenantsPage} instance, or null if there are no more pages. + */ + @Nullable + @Override + public ListTenantsPage getNextPage() { + if (hasNextPage()) { + PageFactory factory = new PageFactory(source, maxResults, currentBatch.getPageToken()); + try { + return factory.create(); + } catch (FirebaseAuthException e) { + throw new RuntimeException(e); + } + } + return null; + } + + /** + * Returns an {@code Iterable} that facilitates transparently iterating over all the tenants in + * the current Firebase project, starting from this page. + * + *

The {@code Iterator} instances produced by the returned {@code Iterable} never buffers more + * than one page of tenants at a time. It is safe to abandon the iterators (i.e. break the loops) + * at any time. + * + * @return a new {@code Iterable} instance. + */ + @NonNull + @Override + public Iterable iterateAll() { + return new TenantIterable(this); + } + + /** + * Returns an {@code Iterable} over the tenants in this page. + * + * @return a {@code Iterable} instance. + */ + @NonNull + @Override + public Iterable getValues() { + return currentBatch.getTenants(); + } + + private static class TenantIterable implements Iterable { + + private final ListTenantsPage startingPage; + + TenantIterable(@NonNull ListTenantsPage startingPage) { + this.startingPage = checkNotNull(startingPage, "starting page must not be null"); + } + + @Override + @NonNull + public Iterator iterator() { + return new TenantIterator(startingPage); + } + + /** + * An {@code Iterator} that cycles through tenants, one at a time. + * + *

It buffers the last retrieved batch of tenants in memory. The {@code maxResults} parameter + * is an upper bound on the batch size. + */ + private static class TenantIterator implements Iterator { + + private ListTenantsPage currentPage; + private List batch; + private int index = 0; + + private TenantIterator(ListTenantsPage startingPage) { + setCurrentPage(startingPage); + } + + @Override + public boolean hasNext() { + if (index == batch.size()) { + if (currentPage.hasNextPage()) { + setCurrentPage(currentPage.getNextPage()); + } else { + return false; + } + } + + return index < batch.size(); + } + + @Override + public Tenant next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return batch.get(index++); + } + + @Override + public void remove() { + throw new UnsupportedOperationException("remove operation not supported"); + } + + private void setCurrentPage(ListTenantsPage page) { + this.currentPage = checkNotNull(page); + this.batch = ImmutableList.copyOf(page.getValues()); + this.index = 0; + } + } + } + + /** + * Represents a source of tenant data that can be queried to load a batch of tenants. + */ + interface TenantSource { + @NonNull + ListTenantsResponse fetch(int maxResults, String pageToken) + throws FirebaseAuthException; + } + + static class DefaultTenantSource implements TenantSource { + + private final FirebaseTenantClient tenantClient; + + DefaultTenantSource(FirebaseTenantClient tenantClient) { + this.tenantClient = checkNotNull(tenantClient, "Tenant client must not be null."); + } + + @Override + public ListTenantsResponse fetch(int maxResults, String pageToken) + throws FirebaseAuthException { + return tenantClient.listTenants(maxResults, pageToken); + } + } + + /** + * A simple factory class for {@link ListTenantsPage} instances. + * + *

Performs argument validation before attempting to load any tenant data (which is expensive, + * and hence may be performed asynchronously on a separate thread). + */ + static class PageFactory { + + private final TenantSource source; + private final int maxResults; + private final String pageToken; + + PageFactory(@NonNull TenantSource source) { + this(source, FirebaseTenantClient.MAX_LIST_TENANTS_RESULTS, null); + } + + PageFactory(@NonNull TenantSource source, int maxResults, @Nullable String pageToken) { + checkArgument(maxResults > 0 && maxResults <= FirebaseTenantClient.MAX_LIST_TENANTS_RESULTS, + "maxResults must be a positive integer that does not exceed %s", + FirebaseTenantClient.MAX_LIST_TENANTS_RESULTS); + checkArgument(!END_OF_LIST.equals(pageToken), "Invalid end of list page token."); + this.source = checkNotNull(source, "Source must not be null."); + this.maxResults = maxResults; + this.pageToken = pageToken; + } + + ListTenantsPage create() throws FirebaseAuthException { + ListTenantsResponse batch = source.fetch(maxResults, pageToken); + return new ListTenantsPage(batch, source, maxResults); + } + } +} + diff --git a/src/main/java/com/google/firebase/auth/multitenancy/Tenant.java b/src/main/java/com/google/firebase/auth/multitenancy/Tenant.java new file mode 100644 index 000000000..57d215e96 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/multitenancy/Tenant.java @@ -0,0 +1,195 @@ +/* + * Copyright 2020 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.auth.multitenancy; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.client.util.Key; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import java.util.HashMap; +import java.util.Map; + +/** + * Contains metadata associated with a Firebase tenant. + * + *

Instances of this class are immutable and thread safe. + */ +public final class Tenant { + + @Key("name") + private String resourceName; + + @Key("displayName") + private String displayName; + + @Key("allowPasswordSignup") + private boolean passwordSignInAllowed; + + @Key("enableEmailLinkSignin") + private boolean emailLinkSignInEnabled; + + public String getTenantId() { + return resourceName.substring(resourceName.lastIndexOf("/") + 1); + } + + public String getDisplayName() { + return displayName; + } + + public boolean isPasswordSignInAllowed() { + return passwordSignInAllowed; + } + + public boolean isEmailLinkSignInEnabled() { + return emailLinkSignInEnabled; + } + + /** + * Returns a new {@link UpdateRequest}, which can be used to update the attributes of this tenant. + * + * @return a non-null {@link UpdateRequest} instance. + */ + public UpdateRequest updateRequest() { + return new UpdateRequest(getTenantId()); + } + + /** + * A specification class for creating a new tenant. + * + *

Set the initial attributes of the new tenant by calling various setter methods available in + * this class. None of the attributes are required. + */ + public static final class CreateRequest { + + private final Map properties = new HashMap<>(); + + /** + * Creates a new {@link CreateRequest}, which can be used to create a new tenant. + * + *

The returned object should be passed to {@link TenantManager#createTenant(CreateRequest)} + * to register the tenant information persistently. + */ + public CreateRequest() { } + + /** + * Sets the display name for the new tenant. + * + * @param displayName a non-null, non-empty display name string. + */ + public CreateRequest setDisplayName(String displayName) { + checkArgument(!Strings.isNullOrEmpty(displayName), "display name must not be null or empty"); + properties.put("displayName", displayName); + return this; + } + + /** + * Sets whether to allow email/password user authentication. + * + * @param passwordSignInAllowed a boolean indicating whether users can be authenticated using + * an email and password. + */ + public CreateRequest setPasswordSignInAllowed(boolean passwordSignInAllowed) { + properties.put("allowPasswordSignup", passwordSignInAllowed); + return this; + } + + /** + * Sets whether to enable email link user authentication. + * + * @param emailLinkSignInEnabled a boolean indicating whether users can be authenticated using + * an email link. + */ + public CreateRequest setEmailLinkSignInEnabled(boolean emailLinkSignInEnabled) { + properties.put("enableEmailLinkSignin", emailLinkSignInEnabled); + return this; + } + + Map getProperties() { + return ImmutableMap.copyOf(properties); + } + } + + /** + * A class for updating the attributes of an existing tenant. + * + *

An instance of this class can be obtained via a {@link Tenant} object, or from a tenant ID + * string. Specify the changes to be made to the tenant by calling the various setter methods + * available in this class. + */ + public static final class UpdateRequest { + + private final String tenantId; + private final Map properties = new HashMap<>(); + + /** + * Creates a new {@link UpdateRequest}, which can be used to update the attributes of the + * of the tenant identified by the specified tenant ID. + * + *

This method allows updating attributes of a tenant account, without first having to call + * {@link TenantManager#getTenant(String)}. + * + * @param tenantId a non-null, non-empty tenant ID string. + * @throws IllegalArgumentException If the tenant ID is null or empty. + */ + public UpdateRequest(String tenantId) { + checkArgument(!Strings.isNullOrEmpty(tenantId), "tenant ID must not be null or empty"); + this.tenantId = tenantId; + } + + String getTenantId() { + return tenantId; + } + + /** + * Sets the display name of the existing tenant. + * + * @param displayName a non-null, non-empty display name string. + */ + public UpdateRequest setDisplayName(String displayName) { + checkArgument(!Strings.isNullOrEmpty(displayName), "display name must not be null or empty"); + properties.put("displayName", displayName); + return this; + } + + /** + * Sets whether to allow email/password user authentication. + * + * @param passwordSignInAllowed a boolean indicating whether users can be authenticated using + * an email and password. + */ + public UpdateRequest setPasswordSignInAllowed(boolean passwordSignInAllowed) { + properties.put("allowPasswordSignup", passwordSignInAllowed); + return this; + } + + /** + * Sets whether to enable email link user authentication. + * + * @param emailLinkSignInEnabled a boolean indicating whether users can be authenticated using + * an email link. + */ + public UpdateRequest setEmailLinkSignInEnabled(boolean emailLinkSignInEnabled) { + properties.put("enableEmailLinkSignin", emailLinkSignInEnabled); + return this; + } + + Map getProperties() { + return ImmutableMap.copyOf(properties); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/multitenancy/TenantAwareFirebaseAuth.java b/src/main/java/com/google/firebase/auth/multitenancy/TenantAwareFirebaseAuth.java new file mode 100644 index 000000000..bc374c036 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/multitenancy/TenantAwareFirebaseAuth.java @@ -0,0 +1,102 @@ +/* + * Copyright 2020 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.auth.multitenancy; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.core.ApiAsyncFunction; +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; +import com.google.common.base.Strings; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.firebase.FirebaseApp; +import com.google.firebase.auth.AbstractFirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.FirebaseToken; +import com.google.firebase.auth.SessionCookieOptions; + +/** + * The tenant-aware Firebase client. + * + *

This can be used to perform a variety of authentication-related operations, scoped to a + * particular tenant. + */ +public final class TenantAwareFirebaseAuth extends AbstractFirebaseAuth { + + private final String tenantId; + + private TenantAwareFirebaseAuth(Builder builder) { + super(builder); + checkArgument(!Strings.isNullOrEmpty(builder.tenantId)); + this.tenantId = builder.tenantId; + } + + /** Returns the client's tenant ID. */ + public String getTenantId() { + return tenantId; + } + + @Override + public String createSessionCookie( + String idToken, SessionCookieOptions options) throws FirebaseAuthException { + verifyIdToken(idToken); + return super.createSessionCookie(idToken, options); + } + + @Override + public ApiFuture createSessionCookieAsync( + final String idToken, final SessionCookieOptions options) { + ApiFuture future = verifyIdTokenAsync(idToken); + return ApiFutures.transformAsync(future, new ApiAsyncFunction() { + @Override + public ApiFuture apply(FirebaseToken input) { + return TenantAwareFirebaseAuth.super.createSessionCookieAsync(idToken, options); + } + }, MoreExecutors.directExecutor()); + } + + static TenantAwareFirebaseAuth fromApp(FirebaseApp app, String tenantId) { + return populateBuilderFromApp(builder(), app, tenantId) + .setTenantId(tenantId) + .build(); + } + + static Builder builder() { + return new Builder(); + } + + static class Builder extends AbstractFirebaseAuth.Builder { + + private String tenantId; + + private Builder() { } + + @Override + protected Builder getThis() { + return this; + } + + public Builder setTenantId(String tenantId) { + this.tenantId = tenantId; + return this; + } + + TenantAwareFirebaseAuth build() { + return new TenantAwareFirebaseAuth(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/multitenancy/TenantManager.java b/src/main/java/com/google/firebase/auth/multitenancy/TenantManager.java new file mode 100644 index 000000000..a30c0b884 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/multitenancy/TenantManager.java @@ -0,0 +1,292 @@ +/* + * Copyright 2020 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.auth.multitenancy; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpResponseInterceptor; +import com.google.api.core.ApiFuture; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.firebase.FirebaseApp; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.multitenancy.ListTenantsPage.DefaultTenantSource; +import com.google.firebase.auth.multitenancy.ListTenantsPage.PageFactory; +import com.google.firebase.auth.multitenancy.ListTenantsPage.TenantSource; +import com.google.firebase.auth.multitenancy.Tenant.CreateRequest; +import com.google.firebase.auth.multitenancy.Tenant.UpdateRequest; +import com.google.firebase.internal.CallableOperation; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import java.util.HashMap; +import java.util.Map; + +/** + * This class can be used to perform a variety of tenant-related operations, including creating, + * updating, and listing tenants. + */ +public final class TenantManager { + + private final FirebaseApp firebaseApp; + private final FirebaseTenantClient tenantClient; + private final Map tenantAwareAuths; + + /** + * Creates a new {@link TenantManager} instance. For internal use only. Use + * {@link FirebaseAuth#getTenantManager()} to obtain an instance for regular use. + * + * @hide + */ + public TenantManager(FirebaseApp firebaseApp) { + this(firebaseApp, new FirebaseTenantClient(firebaseApp)); + } + + @VisibleForTesting + TenantManager(FirebaseApp firebaseApp, FirebaseTenantClient tenantClient) { + this.firebaseApp = checkNotNull(firebaseApp); + this.tenantClient = checkNotNull(tenantClient); + this.tenantAwareAuths = new HashMap<>(); + } + + @VisibleForTesting + void setInterceptor(HttpResponseInterceptor interceptor) { + this.tenantClient.setInterceptor(interceptor); + } + + /** + * Gets the tenant corresponding to the specified tenant ID. + * + * @param tenantId A tenant ID string. + * @return A {@link Tenant} instance. + * @throws IllegalArgumentException If the tenant ID string is null or empty. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public Tenant getTenant(@NonNull String tenantId) throws FirebaseAuthException { + return getTenantOp(tenantId).call(); + } + + public synchronized TenantAwareFirebaseAuth getAuthForTenant(@NonNull String tenantId) { + checkArgument(!Strings.isNullOrEmpty(tenantId), "Tenant ID must not be null or empty."); + if (!tenantAwareAuths.containsKey(tenantId)) { + tenantAwareAuths.put(tenantId, TenantAwareFirebaseAuth.fromApp(firebaseApp, tenantId)); + } + return tenantAwareAuths.get(tenantId); + } + + /** + * Similar to {@link #getTenant(String)} but performs the operation asynchronously. + * + * @param tenantId A tenantId string. + * @return An {@code ApiFuture} which will complete successfully with a {@link Tenant} instance + * If an error occurs while retrieving tenant data or if the specified tenant ID does not + * exist, the future throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the tenant ID string is null or empty. + */ + public ApiFuture getTenantAsync(@NonNull String tenantId) { + return getTenantOp(tenantId).callAsync(firebaseApp); + } + + private CallableOperation getTenantOp(final String tenantId) { + checkArgument(!Strings.isNullOrEmpty(tenantId), "Tenant ID must not be null or empty."); + return new CallableOperation() { + @Override + protected Tenant execute() throws FirebaseAuthException { + return tenantClient.getTenant(tenantId); + } + }; + } + + /** + * Gets a page of tenants starting from the specified {@code pageToken}. Page size will be limited + * to 1000 tenants. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of tenants. + * @return A {@link ListTenantsPage} instance. + * @throws IllegalArgumentException If the specified page token is empty. + * @throws FirebaseAuthException If an error occurs while retrieving tenant data. + */ + public ListTenantsPage listTenants(@Nullable String pageToken) throws FirebaseAuthException { + return listTenants(pageToken, FirebaseTenantClient.MAX_LIST_TENANTS_RESULTS); + } + + /** + * Gets a page of tenants starting from the specified {@code pageToken}. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of tenants. + * @param maxResults Maximum number of tenants to include in the returned page. This may not + * exceed 1000. + * @return A {@link ListTenantsPage} instance. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + * @throws FirebaseAuthException If an error occurs while retrieving tenant data. + */ + public ListTenantsPage listTenants(@Nullable String pageToken, int maxResults) + throws FirebaseAuthException { + return listTenantsOp(pageToken, maxResults).call(); + } + + /** + * Similar to {@link #listTenants(String)} but performs the operation asynchronously. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of tenants. + * @return An {@code ApiFuture} which will complete successfully with a {@link ListTenantsPage} + * instance. If an error occurs while retrieving tenant data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty. + */ + public ApiFuture listTenantsAsync(@Nullable String pageToken) { + return listTenantsAsync(pageToken, FirebaseTenantClient.MAX_LIST_TENANTS_RESULTS); + } + + /** + * Similar to {@link #listTenants(String, int)} but performs the operation asynchronously. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of tenants. + * @param maxResults Maximum number of tenants to include in the returned page. This may not + * exceed 1000. + * @return An {@code ApiFuture} which will complete successfully with a {@link ListTenantsPage} + * instance. If an error occurs while retrieving tenant data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + */ + public ApiFuture listTenantsAsync(@Nullable String pageToken, int maxResults) { + return listTenantsOp(pageToken, maxResults).callAsync(firebaseApp); + } + + private CallableOperation listTenantsOp( + @Nullable final String pageToken, final int maxResults) { + final TenantSource tenantSource = new DefaultTenantSource(tenantClient); + final PageFactory factory = new PageFactory(tenantSource, maxResults, pageToken); + return new CallableOperation() { + @Override + protected ListTenantsPage execute() throws FirebaseAuthException { + return factory.create(); + } + }; + } + + /** + * Creates a new tenant with the attributes contained in the specified {@link CreateRequest}. + * + * @param request A non-null {@link CreateRequest} instance. + * @return A {@link Tenant} instance corresponding to the newly created tenant. + * @throws NullPointerException if the provided request is null. + * @throws FirebaseAuthException if an error occurs while creating the tenant. + */ + public Tenant createTenant(@NonNull CreateRequest request) throws FirebaseAuthException { + return createTenantOp(request).call(); + } + + /** + * Similar to {@link #createTenant(CreateRequest)} but performs the operation asynchronously. + * + * @param request A non-null {@link CreateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link Tenant} + * instance corresponding to the newly created tenant. If an error occurs while creating the + * tenant, the future throws a {@link FirebaseAuthException}. + * @throws NullPointerException if the provided request is null. + */ + public ApiFuture createTenantAsync(@NonNull CreateRequest request) { + return createTenantOp(request).callAsync(firebaseApp); + } + + private CallableOperation createTenantOp( + final CreateRequest request) { + checkNotNull(request, "Create request must not be null."); + return new CallableOperation() { + @Override + protected Tenant execute() throws FirebaseAuthException { + return tenantClient.createTenant(request); + } + }; + } + + + /** + * Updates an existing user account with the attributes contained in the specified {@link + * UpdateRequest}. + * + * @param request A non-null {@link UpdateRequest} instance. + * @return A {@link Tenant} instance corresponding to the updated user account. + * @throws NullPointerException if the provided update request is null. + * @throws FirebaseAuthException if an error occurs while updating the user account. + */ + public Tenant updateTenant(@NonNull UpdateRequest request) throws FirebaseAuthException { + return updateTenantOp(request).call(); + } + + /** + * Similar to {@link #updateTenant(UpdateRequest)} but performs the operation asynchronously. + * + * @param request A non-null {@link UpdateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link Tenant} + * instance corresponding to the updated user account. If an error occurs while updating the + * user account, the future throws a {@link FirebaseAuthException}. + */ + public ApiFuture updateTenantAsync(@NonNull UpdateRequest request) { + return updateTenantOp(request).callAsync(firebaseApp); + } + + private CallableOperation updateTenantOp( + final UpdateRequest request) { + checkNotNull(request, "Update request must not be null."); + checkArgument(!request.getProperties().isEmpty(), + "Tenant update must have at least one property set."); + return new CallableOperation() { + @Override + protected Tenant execute() throws FirebaseAuthException { + return tenantClient.updateTenant(request); + } + }; + } + + /** + * Deletes the tenant identified by the specified tenant ID. + * + * @param tenantId A tenant ID string. + * @throws IllegalArgumentException If the tenant ID string is null or empty. + * @throws FirebaseAuthException If an error occurs while deleting the tenant. + */ + public void deleteTenant(@NonNull String tenantId) throws FirebaseAuthException { + deleteTenantOp(tenantId).call(); + } + + /** + * Similar to {@link #deleteTenant(String)} but performs the operation asynchronously. + * + * @param tenantId A tenant ID string. + * @return An {@code ApiFuture} which will complete successfully when the specified tenant account + * has been deleted. If an error occurs while deleting the tenant account, the future throws a + * {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the tenant ID string is null or empty. + */ + public ApiFuture deleteTenantAsync(String tenantId) { + return deleteTenantOp(tenantId).callAsync(firebaseApp); + } + + private CallableOperation deleteTenantOp(final String tenantId) { + checkArgument(!Strings.isNullOrEmpty(tenantId), "Tenant ID must not be null or empty."); + return new CallableOperation() { + @Override + protected Void execute() throws FirebaseAuthException { + tenantClient.deleteTenant(tenantId); + return null; + } + }; + } +} diff --git a/src/main/java/com/google/firebase/cloud/FirestoreClient.java b/src/main/java/com/google/firebase/cloud/FirestoreClient.java index 15171d14b..682c39f07 100644 --- a/src/main/java/com/google/firebase/cloud/FirestoreClient.java +++ b/src/main/java/com/google/firebase/cloud/FirestoreClient.java @@ -11,10 +11,15 @@ import com.google.firebase.ImplFirebaseTrampolines; import com.google.firebase.internal.FirebaseService; import com.google.firebase.internal.NonNull; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * {@code FirestoreClient} provides access to Google Cloud Firestore. Use this API to obtain a - * {@code Firestore} + * {@code Firestore} * instance, which provides methods for updating and querying data in Firestore. * *

A Google Cloud project ID is required to access Firestore. FirestoreClient determines the @@ -26,9 +31,11 @@ */ public class FirestoreClient { + private static final Logger logger = LoggerFactory.getLogger(FirestoreClient.class); + private final Firestore firestore; - private FirestoreClient(FirebaseApp app) { + private FirestoreClient(FirebaseApp app, String databaseId) { checkNotNull(app, "FirebaseApp must not be null"); String projectId = ImplFirebaseTrampolines.getProjectId(app); checkArgument(!Strings.isNullOrEmpty(projectId), @@ -43,14 +50,17 @@ private FirestoreClient(FirebaseApp app) { .setCredentialsProvider( FixedCredentialsProvider.create(ImplFirebaseTrampolines.getCredentials(app))) .setProjectId(projectId) + .setDatabaseId(databaseId) .build() .getService(); } /** - * Returns the Firestore instance associated with the default Firebase app. + * Returns the default Firestore instance associated with the default Firebase app. Returns the + * same instance for all invocations. The Firestore instance and all references obtained from it + * becomes unusable, once the default app is deleted. * - * @return A non-null {@code Firestore} + * @return A non-null {@code Firestore} * instance. */ @NonNull @@ -59,40 +69,98 @@ public static Firestore getFirestore() { } /** - * Returns the Firestore instance associated with the specified Firebase app. + * Returns the default Firestore instance associated with the specified Firebase app. For a given + * app, invocation always returns the same instance. The Firestore instance and all references + * obtained from it becomes unusable, once the specified app is deleted. * * @param app A non-null {@link FirebaseApp}. - * @return A non-null {@code Firestore} + * @return A non-null {@code Firestore} * instance. */ @NonNull public static Firestore getFirestore(FirebaseApp app) { - return getInstance(app).firestore; + final FirestoreOptions firestoreOptions = ImplFirebaseTrampolines.getFirestoreOptions(app); + return getFirestore(app, firestoreOptions == null ? null : firestoreOptions.getDatabaseId()); } - private static synchronized FirestoreClient getInstance(FirebaseApp app) { + /** + * Returns the Firestore instance associated with the specified Firebase app. Returns the same + * instance for all invocations given the same app and database parameter. The Firestore instance + * and all references obtained from it becomes unusable, once the specified app is deleted. + * + * @param app A non-null {@link FirebaseApp}. + * @param database - The name of database. + * @return A non-null {@code Firestore} + * instance. + */ + @NonNull + public static Firestore getFirestore(FirebaseApp app, String database) { + return getInstance(app, database).firestore; + } + + /** + * Returns the Firestore instance associated with the default Firebase app. Returns the same + * instance for all invocations given the same database parameter. The Firestore instance and all + * references obtained from it becomes unusable, once the default app is deleted. + * + * @param database - The name of database. + * @return A non-null {@code Firestore} + * instance. + */ + @NonNull + public static Firestore getFirestore(String database) { + return getFirestore(FirebaseApp.getInstance(), database); + } + + private static synchronized FirestoreClient getInstance(FirebaseApp app, String database) { FirestoreClientService service = ImplFirebaseTrampolines.getService(app, SERVICE_ID, FirestoreClientService.class); if (service == null) { service = ImplFirebaseTrampolines.addService(app, new FirestoreClientService(app)); } - return service.getInstance(); + return service.getInstance().get(database); } private static final String SERVICE_ID = FirestoreClient.class.getName(); - private static class FirestoreClientService extends FirebaseService { + private static class FirestoreClientService extends FirebaseService { FirestoreClientService(FirebaseApp app) { - super(SERVICE_ID, new FirestoreClient(app)); + super(SERVICE_ID, new FirestoreInstances(app)); } @Override public void destroy() { - // NOTE: We don't explicitly tear down anything here (for now). User won't be able to call - // FirestoreClient.getFirestore() any more, but already created Firestore instances will - // continue to work. Request Firestore team to provide a cleanup/teardown method on the - // Firestore object. + instance.destroy(); + } + } + + private static class FirestoreInstances { + + private final FirebaseApp app; + + private final Map clients = + Collections.synchronizedMap(new HashMap<>()); + + private FirestoreInstances(FirebaseApp app) { + this.app = app; + } + + FirestoreClient get(String databaseId) { + return clients.computeIfAbsent(databaseId, id -> new FirestoreClient(app, id)); + } + + void destroy() { + synchronized (clients) { + for (FirestoreClient client : clients.values()) { + try { + client.firestore.close(); + } catch (Exception e) { + logger.warn("Error while closing the Firestore instance", e); + } + } + clients.clear(); + } } } diff --git a/src/main/java/com/google/firebase/cloud/StorageClient.java b/src/main/java/com/google/firebase/cloud/StorageClient.java index d6a1567f4..4176b5329 100644 --- a/src/main/java/com/google/firebase/cloud/StorageClient.java +++ b/src/main/java/com/google/firebase/cloud/StorageClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -71,7 +71,7 @@ public static synchronized StorageClient getInstance(FirebaseApp app) { * configured via {@link com.google.firebase.FirebaseOptions} when initializing the app. If * no bucket was configured via options, this method throws an exception. * - * @return a cloud storage {@code Bucket} + * @return a cloud storage {@code Bucket} * instance. * @throws IllegalArgumentException If no bucket is configured via FirebaseOptions, * or if the bucket does not exist. @@ -84,7 +84,7 @@ public Bucket bucket() { * Returns a cloud storage Bucket instance for the specified bucket name. * * @param name a non-null, non-empty bucket name. - * @return a cloud storage {@code Bucket} + * @return a cloud storage {@code Bucket} * instance. * @throws IllegalArgumentException If the bucket name is null, empty, or if the specified * bucket does not exist. @@ -106,13 +106,5 @@ private static class StorageClientService extends FirebaseService StorageClientService(StorageClient client) { super(SERVICE_ID, client); } - - @Override - public void destroy() { - // NOTE: We don't explicitly tear down anything here, but public methods of StorageClient - // will now fail because calls to getOptions() and getToken() will hit FirebaseApp, - // which will throw once the app is deleted. - } } - } diff --git a/src/main/java/com/google/firebase/database/ChildEventListener.java b/src/main/java/com/google/firebase/database/ChildEventListener.java index 65bcdfdce..21f74db54 100644 --- a/src/main/java/com/google/firebase/database/ChildEventListener.java +++ b/src/main/java/com/google/firebase/database/ChildEventListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/DataSnapshot.java b/src/main/java/com/google/firebase/database/DataSnapshot.java index f1f470505..64fa7f7b7 100644 --- a/src/main/java/com/google/firebase/database/DataSnapshot.java +++ b/src/main/java/com/google/firebase/database/DataSnapshot.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/DatabaseError.java b/src/main/java/com/google/firebase/database/DatabaseError.java index 456eb99e6..1b0deb059 100644 --- a/src/main/java/com/google/firebase/database/DatabaseError.java +++ b/src/main/java/com/google/firebase/database/DatabaseError.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/DatabaseException.java b/src/main/java/com/google/firebase/database/DatabaseException.java index c85e93878..a5aff67b8 100644 --- a/src/main/java/com/google/firebase/database/DatabaseException.java +++ b/src/main/java/com/google/firebase/database/DatabaseException.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/DatabaseReference.java b/src/main/java/com/google/firebase/database/DatabaseReference.java index 10dfe40ba..7a1bebac1 100644 --- a/src/main/java/com/google/firebase/database/DatabaseReference.java +++ b/src/main/java/com/google/firebase/database/DatabaseReference.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/Exclude.java b/src/main/java/com/google/firebase/database/Exclude.java index e4d90c7da..4eb65dc46 100644 --- a/src/main/java/com/google/firebase/database/Exclude.java +++ b/src/main/java/com/google/firebase/database/Exclude.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/FirebaseDatabase.java b/src/main/java/com/google/firebase/database/FirebaseDatabase.java index 2ddb55d58..9dea16566 100644 --- a/src/main/java/com/google/firebase/database/FirebaseDatabase.java +++ b/src/main/java/com/google/firebase/database/FirebaseDatabase.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,10 @@ import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; import com.google.firebase.ImplFirebaseTrampolines; @@ -27,15 +31,21 @@ import com.google.firebase.database.core.Repo; import com.google.firebase.database.core.RepoInfo; import com.google.firebase.database.core.RepoManager; +import com.google.firebase.database.util.EmulatorHelper; import com.google.firebase.database.utilities.ParsedUrl; import com.google.firebase.database.utilities.Utilities; import com.google.firebase.database.utilities.Validation; +import com.google.firebase.internal.EmulatorCredentials; import com.google.firebase.internal.FirebaseService; import com.google.firebase.internal.SdkUtils; +import java.io.IOException; import java.util.Collections; +import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -112,15 +122,21 @@ public static synchronized FirebaseDatabase getInstance(FirebaseApp app, String if (service == null) { service = ImplFirebaseTrampolines.addService(app, new FirebaseDatabaseService()); } - - DatabaseInstances dbInstances = service.getInstance(); if (url == null || url.isEmpty()) { throw new DatabaseException( "Failed to get FirebaseDatabase instance: Specify DatabaseURL within " + "FirebaseApp or from your getInstance() call."); } - - ParsedUrl parsedUrl = Utilities.parseUrl(url); + ParsedUrl parsedUrl; + boolean connectingToEmulator = false; + String possibleEmulatorUrl = EmulatorHelper + .getEmulatorUrl(url, EmulatorHelper.getEmulatorHostFromEnv()); + if (!Strings.isNullOrEmpty(possibleEmulatorUrl)) { + parsedUrl = Utilities.parseUrl(possibleEmulatorUrl); + connectingToEmulator = true; + } else { + parsedUrl = Utilities.parseUrl(url); + } if (!parsedUrl.path.isEmpty()) { throw new DatabaseException( "Specified Database URL '" @@ -130,6 +146,7 @@ public static synchronized FirebaseDatabase getInstance(FirebaseApp app, String + parsedUrl.path.toString()); } + DatabaseInstances dbInstances = service.getInstance(); FirebaseDatabase database = dbInstances.get(parsedUrl.repoInfo); if (database == null) { DatabaseConfig config = new DatabaseConfig(); @@ -140,11 +157,12 @@ public static synchronized FirebaseDatabase getInstance(FirebaseApp app, String config.setSessionPersistenceKey(app.getName()); } config.setFirebaseApp(app); - + if (connectingToEmulator) { + config.setCustomCredentials(new EmulatorCredentials(), true); + } database = new FirebaseDatabase(app, parsedUrl.repoInfo, config); dbInstances.put(parsedUrl.repoInfo, database); } - return database; } @@ -156,7 +174,7 @@ static FirebaseDatabase createForTests( return db; } - /** + /** * @return The version for this build of the Firebase Database client */ public static String getSdkVersion() { @@ -207,6 +225,11 @@ public DatabaseReference getReference(String path) { public DatabaseReference getReferenceFromUrl(String url) { checkNotNull(url, "Can't pass null for argument 'url' in FirebaseDatabase.getReferenceFromUrl()"); + String possibleEmulatorUrl = EmulatorHelper + .getEmulatorUrl(url, EmulatorHelper.getEmulatorHostFromEnv()); + if (!Strings.isNullOrEmpty(possibleEmulatorUrl)) { + url = possibleEmulatorUrl; + } ParsedUrl parsedUrl = Utilities.parseUrl(url); Repo repo = ensureRepo(); if (!parsedUrl.repoInfo.host.equals(repo.getRepoInfo().host)) { @@ -336,6 +359,10 @@ DatabaseConfig getConfig() { return this.config; } + /** + * Tears down the WebSocket connections and background threads started by this {@code + * FirebaseDatabase} instance thus disconnecting from the remote database. + */ void destroy() { synchronized (lock) { if (destroyed.get()) { diff --git a/src/main/java/com/google/firebase/database/GenericTypeIndicator.java b/src/main/java/com/google/firebase/database/GenericTypeIndicator.java index 3d9bb94d9..5a519d4d7 100644 --- a/src/main/java/com/google/firebase/database/GenericTypeIndicator.java +++ b/src/main/java/com/google/firebase/database/GenericTypeIndicator.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/IgnoreExtraProperties.java b/src/main/java/com/google/firebase/database/IgnoreExtraProperties.java index ff953fd88..ce5d92a66 100644 --- a/src/main/java/com/google/firebase/database/IgnoreExtraProperties.java +++ b/src/main/java/com/google/firebase/database/IgnoreExtraProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/InternalHelpers.java b/src/main/java/com/google/firebase/database/InternalHelpers.java index f5ef4e249..73a92f972 100644 --- a/src/main/java/com/google/firebase/database/InternalHelpers.java +++ b/src/main/java/com/google/firebase/database/InternalHelpers.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/MutableData.java b/src/main/java/com/google/firebase/database/MutableData.java index 7a0e7983b..862330357 100644 --- a/src/main/java/com/google/firebase/database/MutableData.java +++ b/src/main/java/com/google/firebase/database/MutableData.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/OnDisconnect.java b/src/main/java/com/google/firebase/database/OnDisconnect.java index 7b4cc0859..fc81e2d42 100644 --- a/src/main/java/com/google/firebase/database/OnDisconnect.java +++ b/src/main/java/com/google/firebase/database/OnDisconnect.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/PropertyName.java b/src/main/java/com/google/firebase/database/PropertyName.java index 70a8f1345..0b9a58cec 100644 --- a/src/main/java/com/google/firebase/database/PropertyName.java +++ b/src/main/java/com/google/firebase/database/PropertyName.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/Query.java b/src/main/java/com/google/firebase/database/Query.java index e22a4931a..9903cf525 100644 --- a/src/main/java/com/google/firebase/database/Query.java +++ b/src/main/java/com/google/firebase/database/Query.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/ServerValue.java b/src/main/java/com/google/firebase/database/ServerValue.java index de0bb6a0f..410860bc5 100644 --- a/src/main/java/com/google/firebase/database/ServerValue.java +++ b/src/main/java/com/google/firebase/database/ServerValue.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/ThrowOnExtraProperties.java b/src/main/java/com/google/firebase/database/ThrowOnExtraProperties.java index d0a354c4d..49e91ba78 100644 --- a/src/main/java/com/google/firebase/database/ThrowOnExtraProperties.java +++ b/src/main/java/com/google/firebase/database/ThrowOnExtraProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/Transaction.java b/src/main/java/com/google/firebase/database/Transaction.java index 525005b5c..e2733f26e 100644 --- a/src/main/java/com/google/firebase/database/Transaction.java +++ b/src/main/java/com/google/firebase/database/Transaction.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/ValueEventListener.java b/src/main/java/com/google/firebase/database/ValueEventListener.java index bb50e0e26..4b89b59a2 100644 --- a/src/main/java/com/google/firebase/database/ValueEventListener.java +++ b/src/main/java/com/google/firebase/database/ValueEventListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/annotations/NotNull.java b/src/main/java/com/google/firebase/database/annotations/NotNull.java index fe60cf688..3d79b48e3 100644 --- a/src/main/java/com/google/firebase/database/annotations/NotNull.java +++ b/src/main/java/com/google/firebase/database/annotations/NotNull.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/annotations/Nullable.java b/src/main/java/com/google/firebase/database/annotations/Nullable.java index 7c58d121c..da4e931d0 100644 --- a/src/main/java/com/google/firebase/database/annotations/Nullable.java +++ b/src/main/java/com/google/firebase/database/annotations/Nullable.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/annotations/package-info.java b/src/main/java/com/google/firebase/database/annotations/package-info.java index 23e1605a2..e23aff54e 100644 --- a/src/main/java/com/google/firebase/database/annotations/package-info.java +++ b/src/main/java/com/google/firebase/database/annotations/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/collection/ArraySortedMap.java b/src/main/java/com/google/firebase/database/collection/ArraySortedMap.java index edc7afabe..46043e30e 100644 --- a/src/main/java/com/google/firebase/database/collection/ArraySortedMap.java +++ b/src/main/java/com/google/firebase/database/collection/ArraySortedMap.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/collection/ImmutableSortedMap.java b/src/main/java/com/google/firebase/database/collection/ImmutableSortedMap.java index 525f9edc5..8beb8bb91 100644 --- a/src/main/java/com/google/firebase/database/collection/ImmutableSortedMap.java +++ b/src/main/java/com/google/firebase/database/collection/ImmutableSortedMap.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/collection/ImmutableSortedMapIterator.java b/src/main/java/com/google/firebase/database/collection/ImmutableSortedMapIterator.java index 5229579d7..d901ef71f 100644 --- a/src/main/java/com/google/firebase/database/collection/ImmutableSortedMapIterator.java +++ b/src/main/java/com/google/firebase/database/collection/ImmutableSortedMapIterator.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/collection/ImmutableSortedSet.java b/src/main/java/com/google/firebase/database/collection/ImmutableSortedSet.java index f5ab4f7fb..ba6079602 100644 --- a/src/main/java/com/google/firebase/database/collection/ImmutableSortedSet.java +++ b/src/main/java/com/google/firebase/database/collection/ImmutableSortedSet.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/collection/LLRBBlackValueNode.java b/src/main/java/com/google/firebase/database/collection/LLRBBlackValueNode.java index 99a59f980..a0e471637 100644 --- a/src/main/java/com/google/firebase/database/collection/LLRBBlackValueNode.java +++ b/src/main/java/com/google/firebase/database/collection/LLRBBlackValueNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/collection/LLRBEmptyNode.java b/src/main/java/com/google/firebase/database/collection/LLRBEmptyNode.java index e1e72ff91..c2e04d033 100644 --- a/src/main/java/com/google/firebase/database/collection/LLRBEmptyNode.java +++ b/src/main/java/com/google/firebase/database/collection/LLRBEmptyNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/collection/LLRBNode.java b/src/main/java/com/google/firebase/database/collection/LLRBNode.java index bb3214c7f..c8fe496ba 100644 --- a/src/main/java/com/google/firebase/database/collection/LLRBNode.java +++ b/src/main/java/com/google/firebase/database/collection/LLRBNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/collection/LLRBRedValueNode.java b/src/main/java/com/google/firebase/database/collection/LLRBRedValueNode.java index 642d3cbf3..488fdb22b 100644 --- a/src/main/java/com/google/firebase/database/collection/LLRBRedValueNode.java +++ b/src/main/java/com/google/firebase/database/collection/LLRBRedValueNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/collection/LLRBValueNode.java b/src/main/java/com/google/firebase/database/collection/LLRBValueNode.java index 091af493e..b8d29d16a 100644 --- a/src/main/java/com/google/firebase/database/collection/LLRBValueNode.java +++ b/src/main/java/com/google/firebase/database/collection/LLRBValueNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/collection/RBTreeSortedMap.java b/src/main/java/com/google/firebase/database/collection/RBTreeSortedMap.java index 177eb2dcd..283bd2d8b 100644 --- a/src/main/java/com/google/firebase/database/collection/RBTreeSortedMap.java +++ b/src/main/java/com/google/firebase/database/collection/RBTreeSortedMap.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/collection/StandardComparator.java b/src/main/java/com/google/firebase/database/collection/StandardComparator.java index 9f2cb985a..a4de5bd8d 100644 --- a/src/main/java/com/google/firebase/database/collection/StandardComparator.java +++ b/src/main/java/com/google/firebase/database/collection/StandardComparator.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/collection/package-info.java b/src/main/java/com/google/firebase/database/collection/package-info.java index ab03ce9d9..cd9ec39a9 100644 --- a/src/main/java/com/google/firebase/database/collection/package-info.java +++ b/src/main/java/com/google/firebase/database/collection/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/CompoundHash.java b/src/main/java/com/google/firebase/database/connection/CompoundHash.java index 443a899fb..a3456dbd4 100644 --- a/src/main/java/com/google/firebase/database/connection/CompoundHash.java +++ b/src/main/java/com/google/firebase/database/connection/CompoundHash.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/Connection.java b/src/main/java/com/google/firebase/database/connection/Connection.java index 83e377304..111d3a897 100644 --- a/src/main/java/com/google/firebase/database/connection/Connection.java +++ b/src/main/java/com/google/firebase/database/connection/Connection.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/ConnectionAuthTokenProvider.java b/src/main/java/com/google/firebase/database/connection/ConnectionAuthTokenProvider.java index 8874238ed..21e0a3076 100644 --- a/src/main/java/com/google/firebase/database/connection/ConnectionAuthTokenProvider.java +++ b/src/main/java/com/google/firebase/database/connection/ConnectionAuthTokenProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/ConnectionContext.java b/src/main/java/com/google/firebase/database/connection/ConnectionContext.java index 574bc1df9..110935605 100644 --- a/src/main/java/com/google/firebase/database/connection/ConnectionContext.java +++ b/src/main/java/com/google/firebase/database/connection/ConnectionContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/ConnectionUtils.java b/src/main/java/com/google/firebase/database/connection/ConnectionUtils.java index de04b58e6..6821bc5ef 100644 --- a/src/main/java/com/google/firebase/database/connection/ConnectionUtils.java +++ b/src/main/java/com/google/firebase/database/connection/ConnectionUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/Constants.java b/src/main/java/com/google/firebase/database/connection/Constants.java index 6ec9a3da0..6a42939cf 100644 --- a/src/main/java/com/google/firebase/database/connection/Constants.java +++ b/src/main/java/com/google/firebase/database/connection/Constants.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/HostInfo.java b/src/main/java/com/google/firebase/database/connection/HostInfo.java index 1bfa7758d..23be57cbe 100644 --- a/src/main/java/com/google/firebase/database/connection/HostInfo.java +++ b/src/main/java/com/google/firebase/database/connection/HostInfo.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/ListenHashProvider.java b/src/main/java/com/google/firebase/database/connection/ListenHashProvider.java index 9ab58bb23..9be415379 100644 --- a/src/main/java/com/google/firebase/database/connection/ListenHashProvider.java +++ b/src/main/java/com/google/firebase/database/connection/ListenHashProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/NettyWebSocketClient.java b/src/main/java/com/google/firebase/database/connection/NettyWebSocketClient.java index 9769b65a8..403a1319f 100644 --- a/src/main/java/com/google/firebase/database/connection/NettyWebSocketClient.java +++ b/src/main/java/com/google/firebase/database/connection/NettyWebSocketClient.java @@ -35,8 +35,11 @@ import java.io.EOFException; import java.net.URI; import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; import java.util.concurrent.ExecutorService; import java.util.concurrent.ThreadFactory; +import javax.net.ssl.SSLException; import javax.net.ssl.TrustManagerFactory; /** @@ -57,13 +60,15 @@ class NettyWebSocketClient implements WebsocketConnection.WSClient { private final ChannelHandler channelHandler; private final ExecutorService executorService; private final EventLoopGroup group; + private final boolean isSecure; private Channel channel; NettyWebSocketClient( - URI uri, String userAgent, ThreadFactory threadFactory, + URI uri, boolean isSecure, String userAgent, ThreadFactory threadFactory, WebsocketConnection.WSClientEventHandler eventHandler) { this.uri = checkNotNull(uri, "uri must not be null"); + this.isSecure = isSecure; this.eventHandler = checkNotNull(eventHandler, "event handler must not be null"); this.channelHandler = new WebSocketClientHandler(uri, userAgent, eventHandler); this.executorService = new FirebaseScheduledExecutor(threadFactory, @@ -75,20 +80,26 @@ class NettyWebSocketClient implements WebsocketConnection.WSClient { public void connect() { checkState(channel == null, "channel already initialized"); try { - TrustManagerFactory trustFactory = TrustManagerFactory.getInstance( - TrustManagerFactory.getDefaultAlgorithm()); - trustFactory.init((KeyStore) null); - final SslContext sslContext = SslContextBuilder.forClient() - .trustManager(trustFactory).build(); Bootstrap bootstrap = new Bootstrap(); + SslContext sslContext = null; + if (this.isSecure) { + TrustManagerFactory trustFactory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + trustFactory.init((KeyStore) null); + sslContext = SslContextBuilder.forClient() + .trustManager(trustFactory).build(); + } final int port = uri.getPort() != -1 ? uri.getPort() : DEFAULT_WSS_PORT; + final SslContext finalSslContext = sslContext; bootstrap.group(group) .channel(NioSocketChannel.class) .handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) { ChannelPipeline p = ch.pipeline(); - p.addLast(sslContext.newHandler(ch.alloc(), uri.getHost(), port)); + if (finalSslContext != null) { + p.addLast(finalSslContext.newHandler(ch.alloc(), uri.getHost(), port)); + } p.addLast( new HttpClientCodec(), // Set the max size for the HTTP responses. This only applies to the WebSocket diff --git a/src/main/java/com/google/firebase/database/connection/PersistentConnection.java b/src/main/java/com/google/firebase/database/connection/PersistentConnection.java index eca676121..4a53a2261 100644 --- a/src/main/java/com/google/firebase/database/connection/PersistentConnection.java +++ b/src/main/java/com/google/firebase/database/connection/PersistentConnection.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/PersistentConnectionImpl.java b/src/main/java/com/google/firebase/database/connection/PersistentConnectionImpl.java index fc79b4cfe..2ec6af5e8 100644 --- a/src/main/java/com/google/firebase/database/connection/PersistentConnectionImpl.java +++ b/src/main/java/com/google/firebase/database/connection/PersistentConnectionImpl.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/RangeMerge.java b/src/main/java/com/google/firebase/database/connection/RangeMerge.java index eb74cf2a9..5a2508efe 100644 --- a/src/main/java/com/google/firebase/database/connection/RangeMerge.java +++ b/src/main/java/com/google/firebase/database/connection/RangeMerge.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/RequestResultCallback.java b/src/main/java/com/google/firebase/database/connection/RequestResultCallback.java index 53c77f87e..8051c23c6 100644 --- a/src/main/java/com/google/firebase/database/connection/RequestResultCallback.java +++ b/src/main/java/com/google/firebase/database/connection/RequestResultCallback.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java b/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java index 3e29db07a..88a2be670 100644 --- a/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java +++ b/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -413,8 +413,8 @@ public WSClient newClient(WSClientEventHandler delegate) { String host = (optCachedHost != null) ? optCachedHost : hostInfo.getHost(); URI uri = HostInfo.getConnectionUrl( host, hostInfo.isSecure(), hostInfo.getNamespace(), optLastSessionId); - return new NettyWebSocketClient( - uri, context.getUserAgent(), context.getThreadFactory(), delegate); + return new NettyWebSocketClient(uri, hostInfo.isSecure(), context.getUserAgent(), + context.getThreadFactory(), delegate); } } diff --git a/src/main/java/com/google/firebase/database/connection/package-info.java b/src/main/java/com/google/firebase/database/connection/package-info.java index e83511690..9bce1cc1d 100644 --- a/src/main/java/com/google/firebase/database/connection/package-info.java +++ b/src/main/java/com/google/firebase/database/connection/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/util/RetryHelper.java b/src/main/java/com/google/firebase/database/connection/util/RetryHelper.java index f8f6bb77a..c92c8e763 100644 --- a/src/main/java/com/google/firebase/database/connection/util/RetryHelper.java +++ b/src/main/java/com/google/firebase/database/connection/util/RetryHelper.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/connection/util/package-info.java b/src/main/java/com/google/firebase/database/connection/util/package-info.java index d1b554d62..f31b84af5 100644 --- a/src/main/java/com/google/firebase/database/connection/util/package-info.java +++ b/src/main/java/com/google/firebase/database/connection/util/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/AuthTokenProvider.java b/src/main/java/com/google/firebase/database/core/AuthTokenProvider.java index 15da9e7c0..b93e8154c 100644 --- a/src/main/java/com/google/firebase/database/core/AuthTokenProvider.java +++ b/src/main/java/com/google/firebase/database/core/AuthTokenProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/ChildEventRegistration.java b/src/main/java/com/google/firebase/database/core/ChildEventRegistration.java index 8b0460294..592aa1749 100644 --- a/src/main/java/com/google/firebase/database/core/ChildEventRegistration.java +++ b/src/main/java/com/google/firebase/database/core/ChildEventRegistration.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/CompoundWrite.java b/src/main/java/com/google/firebase/database/core/CompoundWrite.java index 22c049645..f8f135319 100644 --- a/src/main/java/com/google/firebase/database/core/CompoundWrite.java +++ b/src/main/java/com/google/firebase/database/core/CompoundWrite.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/Constants.java b/src/main/java/com/google/firebase/database/core/Constants.java index b30b45f3d..c9706e3aa 100644 --- a/src/main/java/com/google/firebase/database/core/Constants.java +++ b/src/main/java/com/google/firebase/database/core/Constants.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/Context.java b/src/main/java/com/google/firebase/database/core/Context.java index da68861cc..02e7fe7a8 100644 --- a/src/main/java/com/google/firebase/database/core/Context.java +++ b/src/main/java/com/google/firebase/database/core/Context.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ package com.google.firebase.database.core; +import com.google.auth.oauth2.GoogleCredentials; import com.google.firebase.FirebaseApp; import com.google.firebase.ImplFirebaseTrampolines; import com.google.firebase.database.DatabaseException; @@ -233,4 +234,13 @@ private String buildUserAgent(String platformAgent) { .append(platformAgent); return sb.toString(); } + + public void setCustomCredentials(GoogleCredentials customCredentials, boolean autoRefresh) { + // ensure that platform exists + getPlatform(); + // ensure that runloop exists else we might get a NPE + this.ensureRunLoop(); + this.authTokenProvider = new JvmAuthTokenProvider(firebaseApp, this.getExecutorService(), + autoRefresh, customCredentials); + } } diff --git a/src/main/java/com/google/firebase/database/core/DatabaseConfig.java b/src/main/java/com/google/firebase/database/core/DatabaseConfig.java index 844f59266..4e4cb7cdf 100644 --- a/src/main/java/com/google/firebase/database/core/DatabaseConfig.java +++ b/src/main/java/com/google/firebase/database/core/DatabaseConfig.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/EventRegistration.java b/src/main/java/com/google/firebase/database/core/EventRegistration.java index 815884303..5517f2ffb 100644 --- a/src/main/java/com/google/firebase/database/core/EventRegistration.java +++ b/src/main/java/com/google/firebase/database/core/EventRegistration.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/EventRegistrationZombieListener.java b/src/main/java/com/google/firebase/database/core/EventRegistrationZombieListener.java index bce5fecde..cebcddd6e 100644 --- a/src/main/java/com/google/firebase/database/core/EventRegistrationZombieListener.java +++ b/src/main/java/com/google/firebase/database/core/EventRegistrationZombieListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/EventTarget.java b/src/main/java/com/google/firebase/database/core/EventTarget.java index 2f0b91ad5..f87c6645d 100644 --- a/src/main/java/com/google/firebase/database/core/EventTarget.java +++ b/src/main/java/com/google/firebase/database/core/EventTarget.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/JvmAuthTokenProvider.java b/src/main/java/com/google/firebase/database/core/JvmAuthTokenProvider.java index 541a4e4ee..e4c03cb24 100644 --- a/src/main/java/com/google/firebase/database/core/JvmAuthTokenProvider.java +++ b/src/main/java/com/google/firebase/database/core/JvmAuthTokenProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,7 +41,12 @@ public class JvmAuthTokenProvider implements AuthTokenProvider { } JvmAuthTokenProvider(FirebaseApp firebaseApp, Executor executor, boolean autoRefresh) { - this.credentials = ImplFirebaseTrampolines.getCredentials(firebaseApp); + this(firebaseApp, executor, autoRefresh, ImplFirebaseTrampolines.getCredentials(firebaseApp)); + } + + JvmAuthTokenProvider(FirebaseApp firebaseApp, Executor executor, boolean autoRefresh, + GoogleCredentials customCredentials) { + this.credentials = customCredentials; this.authVariable = firebaseApp.getOptions().getDatabaseAuthVariableOverride(); this.executor = executor; if (autoRefresh) { @@ -107,7 +112,7 @@ private static class TokenChangeListenerWrapper implements CredentialsChangedLis } @Override - public void onChanged(OAuth2Credentials credentials) throws IOException { + public void onChanged(OAuth2Credentials credentials) { // When this event fires, it is guaranteed that credentials.getAccessToken() will return a // valid OAuth2 token. final AccessToken accessToken = credentials.getAccessToken(); diff --git a/src/main/java/com/google/firebase/database/core/JvmPlatform.java b/src/main/java/com/google/firebase/database/core/JvmPlatform.java index 2a226c078..fac2f6b93 100644 --- a/src/main/java/com/google/firebase/database/core/JvmPlatform.java +++ b/src/main/java/com/google/firebase/database/core/JvmPlatform.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/Path.java b/src/main/java/com/google/firebase/database/core/Path.java index b6fd451b9..22c2f0dde 100644 --- a/src/main/java/com/google/firebase/database/core/Path.java +++ b/src/main/java/com/google/firebase/database/core/Path.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/Platform.java b/src/main/java/com/google/firebase/database/core/Platform.java index 2d2742a62..e3a2353ed 100644 --- a/src/main/java/com/google/firebase/database/core/Platform.java +++ b/src/main/java/com/google/firebase/database/core/Platform.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/Repo.java b/src/main/java/com/google/firebase/database/core/Repo.java index 98777d04d..0850cca5a 100644 --- a/src/main/java/com/google/firebase/database/core/Repo.java +++ b/src/main/java/com/google/firebase/database/core/Repo.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/RepoInfo.java b/src/main/java/com/google/firebase/database/core/RepoInfo.java index 79f83346a..1d7e31d34 100644 --- a/src/main/java/com/google/firebase/database/core/RepoInfo.java +++ b/src/main/java/com/google/firebase/database/core/RepoInfo.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/RepoManager.java b/src/main/java/com/google/firebase/database/core/RepoManager.java index a1d0eafce..e694fd943 100644 --- a/src/main/java/com/google/firebase/database/core/RepoManager.java +++ b/src/main/java/com/google/firebase/database/core/RepoManager.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/RunLoop.java b/src/main/java/com/google/firebase/database/core/RunLoop.java index 723f9430d..16b341da5 100644 --- a/src/main/java/com/google/firebase/database/core/RunLoop.java +++ b/src/main/java/com/google/firebase/database/core/RunLoop.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/ServerValues.java b/src/main/java/com/google/firebase/database/core/ServerValues.java index e484976f9..d4af3637b 100644 --- a/src/main/java/com/google/firebase/database/core/ServerValues.java +++ b/src/main/java/com/google/firebase/database/core/ServerValues.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/SnapshotHolder.java b/src/main/java/com/google/firebase/database/core/SnapshotHolder.java index 60d51bd84..55d497739 100644 --- a/src/main/java/com/google/firebase/database/core/SnapshotHolder.java +++ b/src/main/java/com/google/firebase/database/core/SnapshotHolder.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/SparseSnapshotTree.java b/src/main/java/com/google/firebase/database/core/SparseSnapshotTree.java index e73c7587f..c47e31195 100644 --- a/src/main/java/com/google/firebase/database/core/SparseSnapshotTree.java +++ b/src/main/java/com/google/firebase/database/core/SparseSnapshotTree.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/SyncPoint.java b/src/main/java/com/google/firebase/database/core/SyncPoint.java index 8a3c91fa2..cd8bdbe9c 100644 --- a/src/main/java/com/google/firebase/database/core/SyncPoint.java +++ b/src/main/java/com/google/firebase/database/core/SyncPoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/SyncTree.java b/src/main/java/com/google/firebase/database/core/SyncTree.java index f7da11750..01562caa2 100644 --- a/src/main/java/com/google/firebase/database/core/SyncTree.java +++ b/src/main/java/com/google/firebase/database/core/SyncTree.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/Tag.java b/src/main/java/com/google/firebase/database/core/Tag.java index e25fcb359..d25adb357 100644 --- a/src/main/java/com/google/firebase/database/core/Tag.java +++ b/src/main/java/com/google/firebase/database/core/Tag.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/ThreadPoolEventTarget.java b/src/main/java/com/google/firebase/database/core/ThreadPoolEventTarget.java index 34162fbe2..3a32d9167 100644 --- a/src/main/java/com/google/firebase/database/core/ThreadPoolEventTarget.java +++ b/src/main/java/com/google/firebase/database/core/ThreadPoolEventTarget.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/UserWriteRecord.java b/src/main/java/com/google/firebase/database/core/UserWriteRecord.java index 02c46a2d9..21fa08a79 100644 --- a/src/main/java/com/google/firebase/database/core/UserWriteRecord.java +++ b/src/main/java/com/google/firebase/database/core/UserWriteRecord.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/ValidationPath.java b/src/main/java/com/google/firebase/database/core/ValidationPath.java index 3ea3d03c6..bc3620ec3 100644 --- a/src/main/java/com/google/firebase/database/core/ValidationPath.java +++ b/src/main/java/com/google/firebase/database/core/ValidationPath.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/ValueEventRegistration.java b/src/main/java/com/google/firebase/database/core/ValueEventRegistration.java index 839ce5344..d46580daa 100644 --- a/src/main/java/com/google/firebase/database/core/ValueEventRegistration.java +++ b/src/main/java/com/google/firebase/database/core/ValueEventRegistration.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/WriteTree.java b/src/main/java/com/google/firebase/database/core/WriteTree.java index 86ba8536f..95a57d4d8 100644 --- a/src/main/java/com/google/firebase/database/core/WriteTree.java +++ b/src/main/java/com/google/firebase/database/core/WriteTree.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/WriteTreeRef.java b/src/main/java/com/google/firebase/database/core/WriteTreeRef.java index d66f041b4..3123987ea 100644 --- a/src/main/java/com/google/firebase/database/core/WriteTreeRef.java +++ b/src/main/java/com/google/firebase/database/core/WriteTreeRef.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/ZombieEventManager.java b/src/main/java/com/google/firebase/database/core/ZombieEventManager.java index 2b23b89fa..c488c4d0b 100644 --- a/src/main/java/com/google/firebase/database/core/ZombieEventManager.java +++ b/src/main/java/com/google/firebase/database/core/ZombieEventManager.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/operation/AckUserWrite.java b/src/main/java/com/google/firebase/database/core/operation/AckUserWrite.java index 3016e3b2f..0ffeea3e6 100644 --- a/src/main/java/com/google/firebase/database/core/operation/AckUserWrite.java +++ b/src/main/java/com/google/firebase/database/core/operation/AckUserWrite.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/operation/ListenComplete.java b/src/main/java/com/google/firebase/database/core/operation/ListenComplete.java index 816881bb4..fac4dbdbf 100644 --- a/src/main/java/com/google/firebase/database/core/operation/ListenComplete.java +++ b/src/main/java/com/google/firebase/database/core/operation/ListenComplete.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/operation/Merge.java b/src/main/java/com/google/firebase/database/core/operation/Merge.java index 1f859dfa2..520433af5 100644 --- a/src/main/java/com/google/firebase/database/core/operation/Merge.java +++ b/src/main/java/com/google/firebase/database/core/operation/Merge.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/operation/Operation.java b/src/main/java/com/google/firebase/database/core/operation/Operation.java index 951dce939..8a42cd89c 100644 --- a/src/main/java/com/google/firebase/database/core/operation/Operation.java +++ b/src/main/java/com/google/firebase/database/core/operation/Operation.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/operation/OperationSource.java b/src/main/java/com/google/firebase/database/core/operation/OperationSource.java index e73fca9a5..fa9aadd82 100644 --- a/src/main/java/com/google/firebase/database/core/operation/OperationSource.java +++ b/src/main/java/com/google/firebase/database/core/operation/OperationSource.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/operation/Overwrite.java b/src/main/java/com/google/firebase/database/core/operation/Overwrite.java index 6177e576a..75cc15e69 100644 --- a/src/main/java/com/google/firebase/database/core/operation/Overwrite.java +++ b/src/main/java/com/google/firebase/database/core/operation/Overwrite.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/operation/package-info.java b/src/main/java/com/google/firebase/database/core/operation/package-info.java index d92e9cf5e..91d53d0b7 100644 --- a/src/main/java/com/google/firebase/database/core/operation/package-info.java +++ b/src/main/java/com/google/firebase/database/core/operation/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/package-info.java b/src/main/java/com/google/firebase/database/core/package-info.java index 62dcee2ef..4598d4bfc 100644 --- a/src/main/java/com/google/firebase/database/core/package-info.java +++ b/src/main/java/com/google/firebase/database/core/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/persistence/CachePolicy.java b/src/main/java/com/google/firebase/database/core/persistence/CachePolicy.java index c82b5fdcb..52997369f 100644 --- a/src/main/java/com/google/firebase/database/core/persistence/CachePolicy.java +++ b/src/main/java/com/google/firebase/database/core/persistence/CachePolicy.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/persistence/DefaultPersistenceManager.java b/src/main/java/com/google/firebase/database/core/persistence/DefaultPersistenceManager.java index 40e1b99dc..a528c2767 100644 --- a/src/main/java/com/google/firebase/database/core/persistence/DefaultPersistenceManager.java +++ b/src/main/java/com/google/firebase/database/core/persistence/DefaultPersistenceManager.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/persistence/LRUCachePolicy.java b/src/main/java/com/google/firebase/database/core/persistence/LRUCachePolicy.java index 744c34c48..2f62d38b3 100644 --- a/src/main/java/com/google/firebase/database/core/persistence/LRUCachePolicy.java +++ b/src/main/java/com/google/firebase/database/core/persistence/LRUCachePolicy.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/persistence/NoopPersistenceManager.java b/src/main/java/com/google/firebase/database/core/persistence/NoopPersistenceManager.java index 15bb3a970..da44d7ec8 100644 --- a/src/main/java/com/google/firebase/database/core/persistence/NoopPersistenceManager.java +++ b/src/main/java/com/google/firebase/database/core/persistence/NoopPersistenceManager.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/persistence/PersistenceManager.java b/src/main/java/com/google/firebase/database/core/persistence/PersistenceManager.java index 61d11bfa0..68e7f0469 100644 --- a/src/main/java/com/google/firebase/database/core/persistence/PersistenceManager.java +++ b/src/main/java/com/google/firebase/database/core/persistence/PersistenceManager.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/persistence/PersistenceStorageEngine.java b/src/main/java/com/google/firebase/database/core/persistence/PersistenceStorageEngine.java index 31ef88761..edc9abfd0 100644 --- a/src/main/java/com/google/firebase/database/core/persistence/PersistenceStorageEngine.java +++ b/src/main/java/com/google/firebase/database/core/persistence/PersistenceStorageEngine.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/persistence/PruneForest.java b/src/main/java/com/google/firebase/database/core/persistence/PruneForest.java index 64872d4f8..7d8499a92 100644 --- a/src/main/java/com/google/firebase/database/core/persistence/PruneForest.java +++ b/src/main/java/com/google/firebase/database/core/persistence/PruneForest.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/persistence/TrackedQuery.java b/src/main/java/com/google/firebase/database/core/persistence/TrackedQuery.java index 61169b4eb..7efeba5a8 100644 --- a/src/main/java/com/google/firebase/database/core/persistence/TrackedQuery.java +++ b/src/main/java/com/google/firebase/database/core/persistence/TrackedQuery.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/persistence/TrackedQueryManager.java b/src/main/java/com/google/firebase/database/core/persistence/TrackedQueryManager.java index 20278b947..d8b072dd1 100644 --- a/src/main/java/com/google/firebase/database/core/persistence/TrackedQueryManager.java +++ b/src/main/java/com/google/firebase/database/core/persistence/TrackedQueryManager.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/persistence/package-info.java b/src/main/java/com/google/firebase/database/core/persistence/package-info.java index 710cf90e9..5d7217861 100644 --- a/src/main/java/com/google/firebase/database/core/persistence/package-info.java +++ b/src/main/java/com/google/firebase/database/core/persistence/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/utilities/ImmutableTree.java b/src/main/java/com/google/firebase/database/core/utilities/ImmutableTree.java index 617cbd0e4..e47d2f072 100644 --- a/src/main/java/com/google/firebase/database/core/utilities/ImmutableTree.java +++ b/src/main/java/com/google/firebase/database/core/utilities/ImmutableTree.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/utilities/Predicate.java b/src/main/java/com/google/firebase/database/core/utilities/Predicate.java index 667ac34c0..45478ace1 100644 --- a/src/main/java/com/google/firebase/database/core/utilities/Predicate.java +++ b/src/main/java/com/google/firebase/database/core/utilities/Predicate.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/utilities/Tree.java b/src/main/java/com/google/firebase/database/core/utilities/Tree.java index 54923ae09..598984c8d 100644 --- a/src/main/java/com/google/firebase/database/core/utilities/Tree.java +++ b/src/main/java/com/google/firebase/database/core/utilities/Tree.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/utilities/TreeNode.java b/src/main/java/com/google/firebase/database/core/utilities/TreeNode.java index 84df758b2..909c0d648 100644 --- a/src/main/java/com/google/firebase/database/core/utilities/TreeNode.java +++ b/src/main/java/com/google/firebase/database/core/utilities/TreeNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/utilities/package-info.java b/src/main/java/com/google/firebase/database/core/utilities/package-info.java index e17c4c192..57a9ee9ae 100644 --- a/src/main/java/com/google/firebase/database/core/utilities/package-info.java +++ b/src/main/java/com/google/firebase/database/core/utilities/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/CacheNode.java b/src/main/java/com/google/firebase/database/core/view/CacheNode.java index f51de2db4..abebe54a0 100644 --- a/src/main/java/com/google/firebase/database/core/view/CacheNode.java +++ b/src/main/java/com/google/firebase/database/core/view/CacheNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/CancelEvent.java b/src/main/java/com/google/firebase/database/core/view/CancelEvent.java index 660111c73..3625d18b2 100644 --- a/src/main/java/com/google/firebase/database/core/view/CancelEvent.java +++ b/src/main/java/com/google/firebase/database/core/view/CancelEvent.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/Change.java b/src/main/java/com/google/firebase/database/core/view/Change.java index c69ca81a7..3c3a7e065 100644 --- a/src/main/java/com/google/firebase/database/core/view/Change.java +++ b/src/main/java/com/google/firebase/database/core/view/Change.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/DataEvent.java b/src/main/java/com/google/firebase/database/core/view/DataEvent.java index ae5f7fa22..cc1fd648c 100644 --- a/src/main/java/com/google/firebase/database/core/view/DataEvent.java +++ b/src/main/java/com/google/firebase/database/core/view/DataEvent.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/Event.java b/src/main/java/com/google/firebase/database/core/view/Event.java index 2c2ea2522..46b6100e1 100644 --- a/src/main/java/com/google/firebase/database/core/view/Event.java +++ b/src/main/java/com/google/firebase/database/core/view/Event.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/EventGenerator.java b/src/main/java/com/google/firebase/database/core/view/EventGenerator.java index 0b28c3bac..3e9e54445 100644 --- a/src/main/java/com/google/firebase/database/core/view/EventGenerator.java +++ b/src/main/java/com/google/firebase/database/core/view/EventGenerator.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/EventRaiser.java b/src/main/java/com/google/firebase/database/core/view/EventRaiser.java index d2bbacee6..6cb0f1e3f 100644 --- a/src/main/java/com/google/firebase/database/core/view/EventRaiser.java +++ b/src/main/java/com/google/firebase/database/core/view/EventRaiser.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/QueryParams.java b/src/main/java/com/google/firebase/database/core/view/QueryParams.java index d361f8727..afa7f0a1a 100644 --- a/src/main/java/com/google/firebase/database/core/view/QueryParams.java +++ b/src/main/java/com/google/firebase/database/core/view/QueryParams.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/QuerySpec.java b/src/main/java/com/google/firebase/database/core/view/QuerySpec.java index 923a77d7d..3174ec087 100644 --- a/src/main/java/com/google/firebase/database/core/view/QuerySpec.java +++ b/src/main/java/com/google/firebase/database/core/view/QuerySpec.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/View.java b/src/main/java/com/google/firebase/database/core/view/View.java index 9aa05976f..2aa4fa1cd 100644 --- a/src/main/java/com/google/firebase/database/core/view/View.java +++ b/src/main/java/com/google/firebase/database/core/view/View.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/ViewCache.java b/src/main/java/com/google/firebase/database/core/view/ViewCache.java index 417fcbb41..f008348b1 100644 --- a/src/main/java/com/google/firebase/database/core/view/ViewCache.java +++ b/src/main/java/com/google/firebase/database/core/view/ViewCache.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/ViewProcessor.java b/src/main/java/com/google/firebase/database/core/view/ViewProcessor.java index 57fcf07c1..3b3aedaff 100644 --- a/src/main/java/com/google/firebase/database/core/view/ViewProcessor.java +++ b/src/main/java/com/google/firebase/database/core/view/ViewProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/filter/ChildChangeAccumulator.java b/src/main/java/com/google/firebase/database/core/view/filter/ChildChangeAccumulator.java index 9f74fe172..f78f7bf51 100644 --- a/src/main/java/com/google/firebase/database/core/view/filter/ChildChangeAccumulator.java +++ b/src/main/java/com/google/firebase/database/core/view/filter/ChildChangeAccumulator.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/filter/IndexedFilter.java b/src/main/java/com/google/firebase/database/core/view/filter/IndexedFilter.java index bd2c4b717..ff0da3b1e 100644 --- a/src/main/java/com/google/firebase/database/core/view/filter/IndexedFilter.java +++ b/src/main/java/com/google/firebase/database/core/view/filter/IndexedFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/filter/LimitedFilter.java b/src/main/java/com/google/firebase/database/core/view/filter/LimitedFilter.java index eb6ca2eec..1f71b1758 100644 --- a/src/main/java/com/google/firebase/database/core/view/filter/LimitedFilter.java +++ b/src/main/java/com/google/firebase/database/core/view/filter/LimitedFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/filter/NodeFilter.java b/src/main/java/com/google/firebase/database/core/view/filter/NodeFilter.java index 6a81b9bde..8df4e4c16 100644 --- a/src/main/java/com/google/firebase/database/core/view/filter/NodeFilter.java +++ b/src/main/java/com/google/firebase/database/core/view/filter/NodeFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/filter/RangedFilter.java b/src/main/java/com/google/firebase/database/core/view/filter/RangedFilter.java index 003754745..380309300 100644 --- a/src/main/java/com/google/firebase/database/core/view/filter/RangedFilter.java +++ b/src/main/java/com/google/firebase/database/core/view/filter/RangedFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/filter/package-info.java b/src/main/java/com/google/firebase/database/core/view/filter/package-info.java index 405959f3e..44d2f0f3a 100644 --- a/src/main/java/com/google/firebase/database/core/view/filter/package-info.java +++ b/src/main/java/com/google/firebase/database/core/view/filter/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/core/view/package-info.java b/src/main/java/com/google/firebase/database/core/view/package-info.java index 455b97e2c..fcc5f5e30 100644 --- a/src/main/java/com/google/firebase/database/core/view/package-info.java +++ b/src/main/java/com/google/firebase/database/core/view/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/BooleanNode.java b/src/main/java/com/google/firebase/database/snapshot/BooleanNode.java index 1f6505642..e824964e3 100644 --- a/src/main/java/com/google/firebase/database/snapshot/BooleanNode.java +++ b/src/main/java/com/google/firebase/database/snapshot/BooleanNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/ChildKey.java b/src/main/java/com/google/firebase/database/snapshot/ChildKey.java index 9ac8e25c5..6f7a4670f 100644 --- a/src/main/java/com/google/firebase/database/snapshot/ChildKey.java +++ b/src/main/java/com/google/firebase/database/snapshot/ChildKey.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/ChildrenNode.java b/src/main/java/com/google/firebase/database/snapshot/ChildrenNode.java index 1b5c8d694..163a31234 100644 --- a/src/main/java/com/google/firebase/database/snapshot/ChildrenNode.java +++ b/src/main/java/com/google/firebase/database/snapshot/ChildrenNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/CompoundHash.java b/src/main/java/com/google/firebase/database/snapshot/CompoundHash.java index ff786a855..d1525e4ee 100644 --- a/src/main/java/com/google/firebase/database/snapshot/CompoundHash.java +++ b/src/main/java/com/google/firebase/database/snapshot/CompoundHash.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/DeferredValueNode.java b/src/main/java/com/google/firebase/database/snapshot/DeferredValueNode.java index 4290eee00..7a8df1c16 100644 --- a/src/main/java/com/google/firebase/database/snapshot/DeferredValueNode.java +++ b/src/main/java/com/google/firebase/database/snapshot/DeferredValueNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/DoubleNode.java b/src/main/java/com/google/firebase/database/snapshot/DoubleNode.java index c4c4df50d..36f5cf9c1 100644 --- a/src/main/java/com/google/firebase/database/snapshot/DoubleNode.java +++ b/src/main/java/com/google/firebase/database/snapshot/DoubleNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/EmptyNode.java b/src/main/java/com/google/firebase/database/snapshot/EmptyNode.java index 5b76855fb..de755d7b3 100644 --- a/src/main/java/com/google/firebase/database/snapshot/EmptyNode.java +++ b/src/main/java/com/google/firebase/database/snapshot/EmptyNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/Index.java b/src/main/java/com/google/firebase/database/snapshot/Index.java index ac8876ab5..e4bf10083 100644 --- a/src/main/java/com/google/firebase/database/snapshot/Index.java +++ b/src/main/java/com/google/firebase/database/snapshot/Index.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/IndexedNode.java b/src/main/java/com/google/firebase/database/snapshot/IndexedNode.java index 5809bbaf3..db8f88077 100644 --- a/src/main/java/com/google/firebase/database/snapshot/IndexedNode.java +++ b/src/main/java/com/google/firebase/database/snapshot/IndexedNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/KeyIndex.java b/src/main/java/com/google/firebase/database/snapshot/KeyIndex.java index 5c40a3c1d..0ee62f639 100644 --- a/src/main/java/com/google/firebase/database/snapshot/KeyIndex.java +++ b/src/main/java/com/google/firebase/database/snapshot/KeyIndex.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/LeafNode.java b/src/main/java/com/google/firebase/database/snapshot/LeafNode.java index 27a101ff2..9f6e72a0e 100644 --- a/src/main/java/com/google/firebase/database/snapshot/LeafNode.java +++ b/src/main/java/com/google/firebase/database/snapshot/LeafNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/LongNode.java b/src/main/java/com/google/firebase/database/snapshot/LongNode.java index 493be9e0b..43e1b31e8 100644 --- a/src/main/java/com/google/firebase/database/snapshot/LongNode.java +++ b/src/main/java/com/google/firebase/database/snapshot/LongNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/NamedNode.java b/src/main/java/com/google/firebase/database/snapshot/NamedNode.java index f47884a43..202c108e0 100644 --- a/src/main/java/com/google/firebase/database/snapshot/NamedNode.java +++ b/src/main/java/com/google/firebase/database/snapshot/NamedNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/Node.java b/src/main/java/com/google/firebase/database/snapshot/Node.java index 7108633eb..d1535c77a 100644 --- a/src/main/java/com/google/firebase/database/snapshot/Node.java +++ b/src/main/java/com/google/firebase/database/snapshot/Node.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/NodeUtilities.java b/src/main/java/com/google/firebase/database/snapshot/NodeUtilities.java index 36e1e603c..b5c200191 100644 --- a/src/main/java/com/google/firebase/database/snapshot/NodeUtilities.java +++ b/src/main/java/com/google/firebase/database/snapshot/NodeUtilities.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/PathIndex.java b/src/main/java/com/google/firebase/database/snapshot/PathIndex.java index 451f3e6b0..c6007fc6b 100644 --- a/src/main/java/com/google/firebase/database/snapshot/PathIndex.java +++ b/src/main/java/com/google/firebase/database/snapshot/PathIndex.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/PriorityIndex.java b/src/main/java/com/google/firebase/database/snapshot/PriorityIndex.java index fa1836be0..9a014bcc8 100644 --- a/src/main/java/com/google/firebase/database/snapshot/PriorityIndex.java +++ b/src/main/java/com/google/firebase/database/snapshot/PriorityIndex.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/PriorityUtilities.java b/src/main/java/com/google/firebase/database/snapshot/PriorityUtilities.java index 14c758f67..8f2e4a71f 100644 --- a/src/main/java/com/google/firebase/database/snapshot/PriorityUtilities.java +++ b/src/main/java/com/google/firebase/database/snapshot/PriorityUtilities.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/RangeMerge.java b/src/main/java/com/google/firebase/database/snapshot/RangeMerge.java index ce846ea34..e6aa48e6a 100644 --- a/src/main/java/com/google/firebase/database/snapshot/RangeMerge.java +++ b/src/main/java/com/google/firebase/database/snapshot/RangeMerge.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/StringNode.java b/src/main/java/com/google/firebase/database/snapshot/StringNode.java index e1af493b0..52f140c05 100644 --- a/src/main/java/com/google/firebase/database/snapshot/StringNode.java +++ b/src/main/java/com/google/firebase/database/snapshot/StringNode.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/ValueIndex.java b/src/main/java/com/google/firebase/database/snapshot/ValueIndex.java index e915fe29e..26b6cf0a1 100644 --- a/src/main/java/com/google/firebase/database/snapshot/ValueIndex.java +++ b/src/main/java/com/google/firebase/database/snapshot/ValueIndex.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/snapshot/package-info.java b/src/main/java/com/google/firebase/database/snapshot/package-info.java index 722941ccd..ba871dbf5 100644 --- a/src/main/java/com/google/firebase/database/snapshot/package-info.java +++ b/src/main/java/com/google/firebase/database/snapshot/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/util/EmulatorHelper.java b/src/main/java/com/google/firebase/database/util/EmulatorHelper.java new file mode 100644 index 000000000..c474261aa --- /dev/null +++ b/src/main/java/com/google/firebase/database/util/EmulatorHelper.java @@ -0,0 +1,72 @@ +/* + * Copyright 2019 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.database.util; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.firebase.database.annotations.Nullable; +import com.google.firebase.database.core.RepoInfo; +import com.google.firebase.database.utilities.ParsedUrl; +import com.google.firebase.database.utilities.Utilities; +import com.google.firebase.internal.FirebaseProcessEnvironment; + +public final class EmulatorHelper { + + private EmulatorHelper() { + } + + @VisibleForTesting + public static final String FIREBASE_RTDB_EMULATOR_HOST_ENV_VAR = + "FIREBASE_DATABASE_EMULATOR_HOST"; + + public static String getEmulatorHostFromEnv() { + return FirebaseProcessEnvironment.getenv(FIREBASE_RTDB_EMULATOR_HOST_ENV_VAR); + } + + @VisibleForTesting + @Nullable + static boolean isEmulatorUrl(String databaseUrl) { + if (Strings.isNullOrEmpty(databaseUrl)) { + return false; + } + RepoInfo repoInfo = Utilities.parseUrl(databaseUrl).repoInfo; + return !repoInfo.host.endsWith(".firebaseio.com") && databaseUrl.contains("ns="); + } + + @Nullable + public static String getEmulatorUrl(String suppliedDatabaseUrl, String emulatorHost) { + if (isEmulatorUrl(suppliedDatabaseUrl)) { + return suppliedDatabaseUrl; + } + if (Strings.isNullOrEmpty(emulatorHost)) { + return null; + } + if (emulatorHost.contains("http:") || emulatorHost.contains("?ns=")) { + throw new IllegalArgumentException( + "emulator host declared in environment variable must be of the format \"host:port\""); + } + String namespaceName = "default"; + String path = "/"; + if (!Strings.isNullOrEmpty(suppliedDatabaseUrl)) { + ParsedUrl parsedDbUrl = Utilities.parseUrl(suppliedDatabaseUrl); + namespaceName = parsedDbUrl.repoInfo.namespace; + path = parsedDbUrl.path.isEmpty() ? "/" : parsedDbUrl.path.toString() + "/"; + } + // Must format correctly + return String.format("http://%s%s?ns=%s", emulatorHost, path, namespaceName); + } +} diff --git a/src/main/java/com/google/firebase/database/util/GAuthToken.java b/src/main/java/com/google/firebase/database/util/GAuthToken.java index 976724081..dfb4c2d6d 100644 --- a/src/main/java/com/google/firebase/database/util/GAuthToken.java +++ b/src/main/java/com/google/firebase/database/util/GAuthToken.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/util/JsonMapper.java b/src/main/java/com/google/firebase/database/util/JsonMapper.java index 0c178f7e1..b71b2bdc2 100644 --- a/src/main/java/com/google/firebase/database/util/JsonMapper.java +++ b/src/main/java/com/google/firebase/database/util/JsonMapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,20 +16,18 @@ package com.google.firebase.database.util; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; import java.io.IOException; +import java.io.StringReader; import java.util.ArrayList; -import java.util.Collection; import java.util.HashMap; -import java.util.Iterator; import java.util.List; import java.util.Map; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.json.JSONStringer; -import org.json.JSONTokener; - /** * Helper class to convert from/to JSON strings. TODO: This class should ideally not live in * firebase-database-connection, but it's required by both firebase-database and @@ -37,102 +35,82 @@ */ public class JsonMapper { - public static String serializeJson(Map object) throws IOException { - return serializeJsonValue(object); - } - - @SuppressWarnings("unchecked") - public static String serializeJsonValue(Object object) throws IOException { - if (object == null) { - return "null"; - } else if (object instanceof String) { - return JSONObject.quote((String) object); - } else if (object instanceof Number) { - try { - return JSONObject.numberToString((Number) object); - } catch (JSONException e) { - throw new IOException("Could not serialize number", e); - } - } else if (object instanceof Boolean) { - return ((Boolean) object) ? "true" : "false"; - } else { - try { - JSONStringer stringer = new JSONStringer(); - serializeJsonValue(object, stringer); - return stringer.toString(); - } catch (JSONException e) { - throw new IOException("Failed to serialize JSON", e); - } - } - } + private static final Gson GSON = new GsonBuilder().serializeNulls().create(); - private static void serializeJsonValue(Object object, JSONStringer stringer) - throws IOException, JSONException { - if (object instanceof Map) { - stringer.object(); - @SuppressWarnings("unchecked") - Map map = (Map) object; - for (Map.Entry entry : map.entrySet()) { - stringer.key(entry.getKey()); - serializeJsonValue(entry.getValue(), stringer); - } - stringer.endObject(); - } else if (object instanceof Collection) { - Collection collection = (Collection) object; - stringer.array(); - for (Object entry : collection) { - serializeJsonValue(entry, stringer); - } - stringer.endArray(); - } else { - stringer.value(object); + public static String serializeJson(Object object) throws IOException { + try { + return GSON.toJson(object); + } catch (JsonSyntaxException e) { + throw new IOException(e); } } public static Map parseJson(String json) throws IOException { try { - return unwrapJsonObject(new JSONObject(json)); - } catch (JSONException e) { + JsonReader jsonReader = new JsonReader(new StringReader(json)); + return unwrapJsonObject(jsonReader); + } catch (IllegalStateException | JsonSyntaxException e) { throw new IOException(e); } } public static Object parseJsonValue(String json) throws IOException { try { - return unwrapJson(new JSONTokener(json).nextValue()); - } catch (JSONException e) { + JsonReader jsonReader = new JsonReader(new StringReader(json)); + jsonReader.setLenient(true); + return unwrapJson(jsonReader); + } catch (IllegalStateException | JsonSyntaxException e) { throw new IOException(e); } } - @SuppressWarnings("unchecked") - private static Map unwrapJsonObject(JSONObject jsonObject) throws JSONException { - Map map = new HashMap<>(jsonObject.length()); - Iterator keys = jsonObject.keys(); - while (keys.hasNext()) { - String key = keys.next(); - map.put(key, unwrapJson(jsonObject.get(key))); + private static Map unwrapJsonObject(JsonReader jsonReader) throws IOException { + Map map = new HashMap<>(); + jsonReader.beginObject(); + while (jsonReader.peek() != JsonToken.END_OBJECT) { + String key = jsonReader.nextName(); + map.put(key, unwrapJson(jsonReader)); } + jsonReader.endObject(); return map; } - private static List unwrapJsonArray(JSONArray jsonArray) throws JSONException { - List list = new ArrayList<>(jsonArray.length()); - for (int i = 0; i < jsonArray.length(); i++) { - list.add(unwrapJson(jsonArray.get(i))); + private static List unwrapJsonArray(JsonReader jsonReader) throws IOException { + List list = new ArrayList<>(); + jsonReader.beginArray(); + while (jsonReader.peek() != JsonToken.END_ARRAY) { + list.add(unwrapJson(jsonReader)); } + jsonReader.endArray(); return list; } - private static Object unwrapJson(Object o) throws JSONException { - if (o instanceof JSONObject) { - return unwrapJsonObject((JSONObject) o); - } else if (o instanceof JSONArray) { - return unwrapJsonArray((JSONArray) o); - } else if (o.equals(JSONObject.NULL)) { - return null; - } else { - return o; + private static Object unwrapJson(JsonReader jsonReader) throws IOException { + switch (jsonReader.peek()) { + case BEGIN_ARRAY: + return unwrapJsonArray(jsonReader); + case BEGIN_OBJECT: + return unwrapJsonObject(jsonReader); + case STRING: + return jsonReader.nextString(); + case NUMBER: + String value = jsonReader.nextString(); + if (value.matches("-?\\d+")) { + long longValue = Long.parseLong(value); + if (longValue <= Integer.MAX_VALUE && longValue >= Integer.MIN_VALUE) { + return (int) longValue; + } + return Long.valueOf(value); + } + return Double.parseDouble(value); + case BOOLEAN: + return jsonReader.nextBoolean(); + case NULL: + jsonReader.nextNull(); + return null; + default: + throw new IllegalStateException("unknown type " + jsonReader.peek()); } } + } diff --git a/src/main/java/com/google/firebase/database/util/package-info.java b/src/main/java/com/google/firebase/database/util/package-info.java index 321042633..1db9cbbb4 100644 --- a/src/main/java/com/google/firebase/database/util/package-info.java +++ b/src/main/java/com/google/firebase/database/util/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/utilities/Clock.java b/src/main/java/com/google/firebase/database/utilities/Clock.java index f25fa6db7..5cd64c9f6 100644 --- a/src/main/java/com/google/firebase/database/utilities/Clock.java +++ b/src/main/java/com/google/firebase/database/utilities/Clock.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/utilities/DefaultClock.java b/src/main/java/com/google/firebase/database/utilities/DefaultClock.java index 731096be8..b38d71374 100644 --- a/src/main/java/com/google/firebase/database/utilities/DefaultClock.java +++ b/src/main/java/com/google/firebase/database/utilities/DefaultClock.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/utilities/DefaultRunLoop.java b/src/main/java/com/google/firebase/database/utilities/DefaultRunLoop.java index e84b7b561..a06c6f2fa 100644 --- a/src/main/java/com/google/firebase/database/utilities/DefaultRunLoop.java +++ b/src/main/java/com/google/firebase/database/utilities/DefaultRunLoop.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/utilities/NodeSizeEstimator.java b/src/main/java/com/google/firebase/database/utilities/NodeSizeEstimator.java index 1c91fc5d4..cb88a0901 100644 --- a/src/main/java/com/google/firebase/database/utilities/NodeSizeEstimator.java +++ b/src/main/java/com/google/firebase/database/utilities/NodeSizeEstimator.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/utilities/OffsetClock.java b/src/main/java/com/google/firebase/database/utilities/OffsetClock.java index 8fb8ce4a1..0f5f5ce7b 100644 --- a/src/main/java/com/google/firebase/database/utilities/OffsetClock.java +++ b/src/main/java/com/google/firebase/database/utilities/OffsetClock.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/utilities/Pair.java b/src/main/java/com/google/firebase/database/utilities/Pair.java index 6372a5e29..0cf41af36 100644 --- a/src/main/java/com/google/firebase/database/utilities/Pair.java +++ b/src/main/java/com/google/firebase/database/utilities/Pair.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/utilities/ParsedUrl.java b/src/main/java/com/google/firebase/database/utilities/ParsedUrl.java index e43ee9514..b7a0e1b98 100644 --- a/src/main/java/com/google/firebase/database/utilities/ParsedUrl.java +++ b/src/main/java/com/google/firebase/database/utilities/ParsedUrl.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/utilities/PushIdGenerator.java b/src/main/java/com/google/firebase/database/utilities/PushIdGenerator.java index 53d8a25ce..2b2b44534 100644 --- a/src/main/java/com/google/firebase/database/utilities/PushIdGenerator.java +++ b/src/main/java/com/google/firebase/database/utilities/PushIdGenerator.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/utilities/Utilities.java b/src/main/java/com/google/firebase/database/utilities/Utilities.java index 88c932e49..54ba9e50f 100644 --- a/src/main/java/com/google/firebase/database/utilities/Utilities.java +++ b/src/main/java/com/google/firebase/database/utilities/Utilities.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,78 +18,107 @@ import com.google.api.core.ApiFuture; import com.google.api.core.SettableApiFuture; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; import com.google.common.io.BaseEncoding; +import com.google.common.net.UrlEscapers; import com.google.firebase.database.DatabaseError; import com.google.firebase.database.DatabaseException; import com.google.firebase.database.DatabaseReference; import com.google.firebase.database.core.Path; import com.google.firebase.database.core.RepoInfo; - import java.io.UnsupportedEncodingException; import java.net.URI; -import java.net.URISyntaxException; +import java.net.URLDecoder; import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.HashMap; import java.util.Map; +import org.apache.http.client.utils.URLEncodedUtils; public class Utilities { - private static final char[] HEX_CHARACTERS = "0123456789abcdef".toCharArray(); public static ParsedUrl parseUrl(String url) throws DatabaseException { - String original = url; try { - int schemeOffset = original.indexOf("//"); - if (schemeOffset == -1) { - throw new URISyntaxException(original, "Invalid scheme specified"); - } - int pathOffset = original.substring(schemeOffset + 2).indexOf("/"); - if (pathOffset != -1) { - pathOffset += schemeOffset + 2; - String[] pathSegments = original.substring(pathOffset).split("/"); - StringBuilder builder = new StringBuilder(); - for (int i = 0; i < pathSegments.length; ++i) { - if (!pathSegments[i].equals("")) { - builder.append("/"); - builder.append(URLEncoder.encode(pathSegments[i], "UTF-8")); - } - } - original = original.substring(0, pathOffset) + builder.toString(); - } + URI uri = URI.create(url); - URI uri = new URI(original); - // URLEncoding a space turns it into a '+', which is different - // from our expected behavior. Do a manual replace to fix it. - final String pathString = uri.getPath().replace("+", " "); - Validation.validateRootPathString(pathString); String scheme = uri.getScheme(); + if (scheme == null) { + throw new IllegalArgumentException("Database URL does not specify a URL scheme"); + } + + String host = uri.getHost(); + if (host == null) { + throw new IllegalArgumentException("Database URL does not specify a valid host"); + } RepoInfo repoInfo = new RepoInfo(); - repoInfo.host = uri.getHost().toLowerCase(); + repoInfo.host = host.toLowerCase(); + repoInfo.secure = scheme.equals("https") || scheme.equals("wss"); int port = uri.getPort(); if (port != -1) { - repoInfo.secure = scheme.equals("https"); repoInfo.host += ":" + port; + } + + Map params = getQueryParamsMap(uri.getRawQuery()); + String namespaceParam = params.get("ns"); + if (!Strings.isNullOrEmpty(namespaceParam)) { + repoInfo.namespace = namespaceParam; } else { - repoInfo.secure = true; + String[] parts = host.split("\\.", -1); + repoInfo.namespace = parts[0].toLowerCase(); } - String[] parts = repoInfo.host.split("\\."); - repoInfo.namespace = parts[0].toLowerCase(); repoInfo.internalHost = repoInfo.host; + // use raw (encoded) path for backwards compatibility. + String pathString = uri.getRawPath(); + pathString = pathString.replace("+", " "); + Validation.validateRootPathString(pathString); + ParsedUrl parsedUrl = new ParsedUrl(); parsedUrl.path = new Path(pathString); parsedUrl.repoInfo = repoInfo; + return parsedUrl; + } catch (Exception e) { + throw new DatabaseException("Invalid Firebase Database url specified: " + url, e); + } + } - } catch (URISyntaxException e) { - throw new DatabaseException("Invalid Firebase Database url specified", e); - } catch (UnsupportedEncodingException e) { - throw new DatabaseException("Failed to URLEncode the path", e); + /** + * Extracts a map of query parameters from an encoded query string. Repeated parameters have + * values concatenated with commas. + * + * @param queryString to parse params from. Must be encoded. + * @return map of query parameters and their values. + */ + @VisibleForTesting + static Map getQueryParamsMap(String queryString) + throws UnsupportedEncodingException { + Map paramsMap = new HashMap<>(); + if (Strings.isNullOrEmpty(queryString)) { + return paramsMap; + } + String[] paramPairs = queryString.split("&"); + for (String paramPair : paramPairs) { + String[] pairParts = paramPair.split("="); + // both the first and second part will be encoded now, we must decode them + String decodedKey = URLDecoder.decode(pairParts[0], StandardCharsets.UTF_8.name()); + String decodedValue = URLDecoder.decode(pairParts[1], StandardCharsets.UTF_8.name()); + String runningValue = paramsMap.get(decodedKey); + if (Strings.isNullOrEmpty(runningValue)) { + runningValue = decodedValue; + } else { + runningValue += "," + decodedValue; + } + paramsMap.put(pairParts[0], runningValue); } + return paramsMap; } public static String[] splitIntoFrames(String src, int maxFrameSize) { diff --git a/src/main/java/com/google/firebase/database/utilities/Validation.java b/src/main/java/com/google/firebase/database/utilities/Validation.java index 8a0bfcf12..835626866 100644 --- a/src/main/java/com/google/firebase/database/utilities/Validation.java +++ b/src/main/java/com/google/firebase/database/utilities/Validation.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/utilities/encoding/CustomClassMapper.java b/src/main/java/com/google/firebase/database/utilities/encoding/CustomClassMapper.java index 5b14fc295..d4ba951e7 100644 --- a/src/main/java/com/google/firebase/database/utilities/encoding/CustomClassMapper.java +++ b/src/main/java/com/google/firebase/database/utilities/encoding/CustomClassMapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -123,20 +123,20 @@ private static Object serialize(T obj) { return ((Number) obj).longValue(); } return doubleValue; - } else if (obj instanceof Short) { - throw new DatabaseException("Shorts are not supported, please use int or long"); - } else if (obj instanceof Byte) { - throw new DatabaseException("Bytes are not supported, please use int or long"); - } else { - // Long, Integer + } else if (obj instanceof Long || obj instanceof Integer) { return obj; + } else { + throw new DatabaseException( + String.format( + "Numbers of type %s are not supported, please use an int, long, float or double", + obj.getClass().getSimpleName())); } } else if (obj instanceof String) { return obj; } else if (obj instanceof Boolean) { return obj; } else if (obj instanceof Character) { - throw new DatabaseException("Characters are not supported, please strings"); + throw new DatabaseException("Characters are not supported, please use Strings"); } else if (obj instanceof Map) { Map result = new HashMap<>(); for (Map.Entry entry : ((Map) obj).entrySet()) { @@ -159,11 +159,11 @@ private static Object serialize(T obj) { return result; } else { throw new DatabaseException( - "Serializing Collections is not supported, " + "please use Lists instead"); + "Serializing Collections is not supported, please use Lists instead"); } } else if (obj.getClass().isArray()) { throw new DatabaseException( - "Serializing Arrays is not supported, please use Lists " + "instead"); + "Serializing Arrays is not supported, please use Lists instead"); } else if (obj instanceof Enum) { return ((Enum) obj).name(); } else { @@ -185,7 +185,7 @@ private static T deserializeToType(Object obj, Type type) { throw new DatabaseException("Generic wildcard types are not supported"); } else if (type instanceof GenericArrayType) { throw new DatabaseException( - "Generic Arrays are not supported, please use Lists " + "instead"); + "Generic Arrays are not supported, please use Lists instead"); } else { throw new IllegalStateException("Unknown type encountered: " + type); } @@ -204,7 +204,7 @@ private static T deserializeToClass(Object obj, Class clazz) { return (T) convertString(obj); } else if (clazz.isArray()) { throw new DatabaseException( - "Converting to Arrays is not supported, please use Lists" + "instead"); + "Converting to Arrays is not supported, please use Lists instead"); } else if (clazz.getTypeParameters().length > 0) { throw new DatabaseException( "Class " @@ -282,14 +282,9 @@ private static T deserializeToPrimitive(Object obj, Class clazz) { return (T) convertLong(obj); } else if (Float.class.isAssignableFrom(clazz) || float.class.isAssignableFrom(clazz)) { return (T) (Float) convertDouble(obj).floatValue(); - } else if (Short.class.isAssignableFrom(clazz) || short.class.isAssignableFrom(clazz)) { - throw new DatabaseException("Deserializing to shorts is not supported"); - } else if (Byte.class.isAssignableFrom(clazz) || byte.class.isAssignableFrom(clazz)) { - throw new DatabaseException("Deserializing to bytes is not supported"); - } else if (Character.class.isAssignableFrom(clazz) || char.class.isAssignableFrom(clazz)) { - throw new DatabaseException("Deserializing to char is not supported"); } else { - throw new IllegalArgumentException("Unknown primitive type: " + clazz); + throw new DatabaseException( + String.format("Deserializing values to %s is not supported", clazz.getSimpleName())); } } @@ -716,7 +711,7 @@ public T deserialize(Map values, Map>, Typ Method setter = this.setters.get(propertyName); Type[] params = setter.getGenericParameterTypes(); if (params.length != 1) { - throw new IllegalStateException("Setter does not have exactly one " + "parameter"); + throw new IllegalStateException("Setter does not have exactly one parameter"); } Type resolvedType = resolveType(params[0], types); Object value = CustomClassMapper.deserializeToType(entry.getValue(), resolvedType); diff --git a/src/main/java/com/google/firebase/database/utilities/encoding/package-info.java b/src/main/java/com/google/firebase/database/utilities/encoding/package-info.java index 87a4778e2..817f3573c 100644 --- a/src/main/java/com/google/firebase/database/utilities/encoding/package-info.java +++ b/src/main/java/com/google/firebase/database/utilities/encoding/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/database/utilities/package-info.java b/src/main/java/com/google/firebase/database/utilities/package-info.java index 3b9bf4945..fd5df4ef4 100644 --- a/src/main/java/com/google/firebase/database/utilities/package-info.java +++ b/src/main/java/com/google/firebase/database/utilities/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/iid/FirebaseInstanceId.java b/src/main/java/com/google/firebase/iid/FirebaseInstanceId.java index 7ac527778..9c0d0970d 100644 --- a/src/main/java/com/google/firebase/iid/FirebaseInstanceId.java +++ b/src/main/java/com/google/firebase/iid/FirebaseInstanceId.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,29 +17,27 @@ package com.google.firebase.iid; import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; -import com.google.api.client.http.GenericUrl; -import com.google.api.client.http.HttpRequest; import com.google.api.client.http.HttpRequestFactory; -import com.google.api.client.http.HttpResponse; -import com.google.api.client.http.HttpResponseException; import com.google.api.client.http.HttpResponseInterceptor; -import com.google.api.client.http.HttpTransport; -import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.JsonObjectParser; import com.google.api.core.ApiFuture; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.collect.ImmutableMap; -import com.google.common.io.ByteStreams; import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseException; import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.database.annotations.Nullable; +import com.google.firebase.internal.AbstractHttpErrorHandler; +import com.google.firebase.internal.ApiClientUtils; import com.google.firebase.internal.CallableOperation; -import com.google.firebase.internal.FirebaseRequestInitializer; +import com.google.firebase.internal.ErrorHandlingHttpClient; import com.google.firebase.internal.FirebaseService; +import com.google.firebase.internal.HttpRequestInfo; import com.google.firebase.internal.NonNull; -import java.io.IOException; import java.util.Map; /** @@ -64,22 +62,30 @@ public class FirebaseInstanceId { .build(); private final FirebaseApp app; - private final HttpRequestFactory requestFactory; - private final JsonFactory jsonFactory; private final String projectId; - - private HttpResponseInterceptor interceptor; + private final ErrorHandlingHttpClient httpClient; private FirebaseInstanceId(FirebaseApp app) { - HttpTransport httpTransport = app.getOptions().getHttpTransport(); - this.app = app; - this.requestFactory = httpTransport.createRequestFactory(new FirebaseRequestInitializer(app)); - this.jsonFactory = app.getOptions().getJsonFactory(); - this.projectId = ImplFirebaseTrampolines.getProjectId(app); + this(app, null); + } + + @VisibleForTesting + FirebaseInstanceId(FirebaseApp app, @Nullable HttpRequestFactory requestFactory) { + this.app = checkNotNull(app, "app must not be null"); + String projectId = ImplFirebaseTrampolines.getProjectId(app); checkArgument(!Strings.isNullOrEmpty(projectId), "Project ID is required to access instance ID service. Use a service account credential or " + "set the project ID explicitly via FirebaseOptions. Alternatively you can also " + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable."); + this.projectId = projectId; + if (requestFactory == null) { + requestFactory = ApiClientUtils.newAuthorizedRequestFactory(app); + } + + this.httpClient = new ErrorHandlingHttpClient<>( + requestFactory, + app.getOptions().getJsonFactory(), + new InstanceIdErrorHandler()); } /** @@ -107,7 +113,7 @@ public static synchronized FirebaseInstanceId getInstance(FirebaseApp app) { @VisibleForTesting void setInterceptor(HttpResponseInterceptor interceptor) { - this.interceptor = interceptor; + httpClient.setInterceptor(interceptor); } /** @@ -146,42 +152,45 @@ private CallableOperation deleteInstanceIdOp( protected Void execute() throws FirebaseInstanceIdException { String url = String.format( "%s/project/%s/instanceId/%s", IID_SERVICE_URL, projectId, instanceId); - HttpResponse response = null; - try { - HttpRequest request = requestFactory.buildDeleteRequest(new GenericUrl(url)); - request.setParser(new JsonObjectParser(jsonFactory)); - request.setResponseInterceptor(interceptor); - response = request.execute(); - ByteStreams.exhaust(response.getContent()); - } catch (Exception e) { - handleError(instanceId, e); - } finally { - disconnectQuietly(response); - } + HttpRequestInfo request = HttpRequestInfo.buildDeleteRequest(url); + httpClient.send(request); return null; } }; } - private static void disconnectQuietly(HttpResponse response) { - if (response != null) { - try { - response.disconnect(); - } catch (IOException ignored) { - // ignored + private static class InstanceIdErrorHandler + extends AbstractHttpErrorHandler { + + @Override + protected FirebaseInstanceIdException createException(FirebaseException base) { + String message = base.getMessage(); + String customMessage = getCustomMessage(base); + if (!Strings.isNullOrEmpty(customMessage)) { + message = customMessage; } + + return new FirebaseInstanceIdException(base, message); } - } - private void handleError(String instanceId, Exception e) throws FirebaseInstanceIdException { - String msg = "Error while invoking instance ID service."; - if (e instanceof HttpResponseException) { - int statusCode = ((HttpResponseException) e).getStatusCode(); - if (ERROR_CODES.containsKey(statusCode)) { - msg = String.format("Instance ID \"%s\": %s", instanceId, ERROR_CODES.get(statusCode)); + private String getCustomMessage(FirebaseException base) { + IncomingHttpResponse response = base.getHttpResponse(); + if (response != null) { + String instanceId = extractInstanceId(response); + String description = ERROR_CODES.get(response.getStatusCode()); + if (description != null) { + return String.format("Instance ID \"%s\": %s", instanceId, description); + } } + + return null; + } + + private String extractInstanceId(IncomingHttpResponse response) { + String url = response.getRequest().getUrl(); + int index = url.lastIndexOf('/'); + return url.substring(index + 1); } - throw new FirebaseInstanceIdException(msg, e); } private static final String SERVICE_ID = FirebaseInstanceId.class.getName(); @@ -191,12 +200,5 @@ private static class FirebaseInstanceIdService extends FirebaseService + implements HttpErrorHandler { + + private static final Map HTTP_ERROR_CODES = + ImmutableMap.builder() + .put(HttpStatusCodes.STATUS_CODE_BAD_REQUEST, ErrorCode.INVALID_ARGUMENT) + .put(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED, ErrorCode.UNAUTHENTICATED) + .put(HttpStatusCodes.STATUS_CODE_FORBIDDEN, ErrorCode.PERMISSION_DENIED) + .put(HttpStatusCodes.STATUS_CODE_NOT_FOUND, ErrorCode.NOT_FOUND) + .put(HttpStatusCodes.STATUS_CODE_CONFLICT, ErrorCode.CONFLICT) + .put(429, ErrorCode.RESOURCE_EXHAUSTED) + .put(HttpStatusCodes.STATUS_CODE_SERVER_ERROR, ErrorCode.INTERNAL) + .put(HttpStatusCodes.STATUS_CODE_SERVICE_UNAVAILABLE, ErrorCode.UNAVAILABLE) + .build(); + + @Override + public final T handleHttpResponseException( + HttpResponseException e, IncomingHttpResponse response) { + FirebaseException base = this.httpResponseErrorToBaseException(e, response); + return this.createException(base); + } + + @Override + public final T handleIOException(IOException e) { + FirebaseException base = this.ioErrorToBaseException(e); + return this.createException(base); + } + + @Override + public final T handleParseException(IOException e, IncomingHttpResponse response) { + FirebaseException base = this.parseErrorToBaseException(e, response); + return this.createException(base); + } + + /** + * Creates a FirebaseException from the given HTTP response error. Error code is determined from + * the HTTP status code of the response. Error message includes both the status code and full + * response payload to aid in debugging. + * + * @param e HTTP response exception. + * @param response Incoming HTTP response. + * @return A FirebaseException instance. + */ + protected FirebaseException httpResponseErrorToBaseException( + HttpResponseException e, IncomingHttpResponse response) { + ErrorCode code = HTTP_ERROR_CODES.get(e.getStatusCode()); + if (code == null) { + code = ErrorCode.UNKNOWN; + } + + String message = String.format("Unexpected HTTP response with status: %d\n%s", + e.getStatusCode(), e.getContent()); + return new FirebaseException(code, message, e, response); + } + + /** + * Creates a FirebaseException from the given IOException. If IOException resulted from a socket + * timeout, sets the error code DEADLINE_EXCEEDED. If the IOException resulted from a network + * outage or other connectivity issue, sets the error code to UNAVAILABLE. In all other cases sets + * the error code to UNKNOWN. + * + * @param e IOException to create the new exception from. + * @return A FirebaseException instance. + */ + protected FirebaseException ioErrorToBaseException(IOException e) { + ErrorCode code = ErrorCode.UNKNOWN; + String message = "Unknown error while making a remote service call" ; + if (isInstance(e, SocketTimeoutException.class)) { + code = ErrorCode.DEADLINE_EXCEEDED; + message = "Timed out while making an API call"; + } + + if (isInstance(e, UnknownHostException.class) || isInstance(e, NoRouteToHostException.class)) { + code = ErrorCode.UNAVAILABLE; + message = "Failed to establish a connection"; + } + + return new FirebaseException(code, message + ": " + e.getMessage(), e); + } + + protected FirebaseException parseErrorToBaseException( + IOException e, IncomingHttpResponse response) { + return new FirebaseException( + ErrorCode.UNKNOWN, "Error while parsing HTTP response: " + e.getMessage(), e, response); + } + + /** + * Converts the given base FirebaseException to a more specific exception type. The base exception + * is guaranteed to have an error code, a message and a cause. But the HTTP response is only set + * if the exception occurred after receiving a response from a remote server. + * + * @param base A FirebaseException. + * @return A more specific exception created from the base. + */ + protected abstract T createException(FirebaseException base); + + /** + * Checks if the given exception stack t contains an instance of type. + */ + private boolean isInstance(IOException t, Class type) { + Throwable current = t; + Set chain = new HashSet<>(); + while (current != null) { + if (!chain.add(current)) { + break; + } + + if (type.isInstance(current)) { + return true; + } + + current = current.getCause(); + } + + return false; + } +} diff --git a/src/main/java/com/google/firebase/internal/AbstractPlatformErrorHandler.java b/src/main/java/com/google/firebase/internal/AbstractPlatformErrorHandler.java new file mode 100644 index 000000000..f92ce3171 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/AbstractPlatformErrorHandler.java @@ -0,0 +1,98 @@ +/* + * Copyright 2020 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.internal; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpResponseException; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.util.Key; +import com.google.common.base.Strings; +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; +import java.io.IOException; + +/** + * An abstract HttpErrorHandler that handles Google Cloud error responses. Format of these + * error responses are defined at https://cloud.google.com/apis/design/errors. + */ +public abstract class AbstractPlatformErrorHandler + extends AbstractHttpErrorHandler { + + protected final JsonFactory jsonFactory; + + public AbstractPlatformErrorHandler(JsonFactory jsonFactory) { + this.jsonFactory = checkNotNull(jsonFactory, "jsonFactory must not be null"); + } + + @Override + protected final FirebaseException httpResponseErrorToBaseException( + HttpResponseException e, IncomingHttpResponse response) { + FirebaseException base = super.httpResponseErrorToBaseException(e, response); + PlatformErrorResponse parsedError = this.parseErrorResponse(e.getContent()); + + ErrorCode code = base.getErrorCode(); + String status = parsedError.getStatus(); + if (!Strings.isNullOrEmpty(status)) { + code = Enum.valueOf(ErrorCode.class, parsedError.getStatus()); + } + + String message = parsedError.getMessage(); + if (Strings.isNullOrEmpty(message)) { + message = base.getMessage(); + } + + return new FirebaseException(code, message, e, response); + } + + private PlatformErrorResponse parseErrorResponse(String content) { + PlatformErrorResponse response = new PlatformErrorResponse(); + if (!Strings.isNullOrEmpty(content)) { + try { + jsonFactory.createJsonParser(content).parseAndClose(response); + } catch (IOException e) { + // Ignore any error that may occur while parsing the error response. The server + // may have responded with a non-json payload. Return an empty return value, and + // let the base class logic come into play. + } + } + + return response; + } + + public static class PlatformErrorResponse { + @Key("error") + private PlatformError error; + + String getStatus() { + return error != null ? error.status : null; + } + + String getMessage() { + return error != null ? error.message : null; + } + } + + public static class PlatformError { + @Key("status") + private String status; + + @Key("message") + private String message; + } +} diff --git a/src/main/java/com/google/firebase/internal/ApacheHttp2AsyncEntityConsumer.java b/src/main/java/com/google/firebase/internal/ApacheHttp2AsyncEntityConsumer.java new file mode 100644 index 000000000..ee8733586 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2AsyncEntityConsumer.java @@ -0,0 +1,85 @@ +/* + * Copyright 2026 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.internal; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.List; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.nio.AsyncEntityConsumer; +import org.apache.hc.core5.http.nio.CapacityChannel; + +public class ApacheHttp2AsyncEntityConsumer implements AsyncEntityConsumer { + + private EntityDetails entityDetails; + private FutureCallback resultCallback; + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + @Override + public void streamStart( + final EntityDetails entityDetails, + final FutureCallback resultCallback) + throws HttpException, IOException { + this.entityDetails = entityDetails; + this.resultCallback = resultCallback; + } + + @Override + public void updateCapacity(CapacityChannel capacityChannel) throws IOException { + capacityChannel.update(Integer.MAX_VALUE); + } + + @Override + public void consume(ByteBuffer src) throws IOException { + if (src.hasArray()) { + buffer.write(src.array(), src.arrayOffset() + src.position(), src.remaining()); + src.position(src.limit()); + } else { + byte[] bytes = new byte[src.remaining()]; + src.get(bytes); + buffer.write(bytes); + } + } + + @Override + public void streamEnd(List trailers) throws HttpException, IOException { + if (resultCallback != null) { + resultCallback.completed(getContent()); + } + } + + @Override + public void failed(Exception cause) { + if (resultCallback != null) { + resultCallback.failed(cause); + } + } + + @Override + public void releaseResources() { + buffer.reset(); + } + + @Override + public ApacheHttp2Entity getContent() { + return new ApacheHttp2Entity(buffer.toByteArray(), entityDetails); + } +} diff --git a/src/main/java/com/google/firebase/internal/ApacheHttp2AsyncEntityProducer.java b/src/main/java/com/google/firebase/internal/ApacheHttp2AsyncEntityProducer.java new file mode 100644 index 000000000..aad6184cb --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2AsyncEntityProducer.java @@ -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.internal; + +import com.google.api.client.util.StreamingContent; +import com.google.common.annotations.VisibleForTesting; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.nio.AsyncEntityProducer; +import org.apache.hc.core5.http.nio.DataStreamChannel; + +public class ApacheHttp2AsyncEntityProducer implements AsyncEntityProducer { + private ByteBuffer bytebuf; + private ByteArrayOutputStream baos; + private final StreamingContent content; + private final ContentType contentType; + private final long contentLength; + private final String contentEncoding; + private final CompletableFuture writeFuture; + private final AtomicReference exception; + + public ApacheHttp2AsyncEntityProducer(StreamingContent content, ContentType contentType, + String contentEncoding, long contentLength, CompletableFuture writeFuture) { + this.content = content; + this.contentType = contentType; + this.contentEncoding = contentEncoding; + this.contentLength = contentLength; + this.writeFuture = writeFuture; + this.bytebuf = null; + + this.baos = new ByteArrayOutputStream((int) (contentLength < 0 ? 0 : contentLength)); + this.exception = new AtomicReference<>(); + } + + public ApacheHttp2AsyncEntityProducer(ApacheHttp2Request request, + CompletableFuture writeFuture) { + this( + request.getStreamingContent(), + ContentType.parse(request.getContentType()), + request.getContentEncoding(), + request.getContentLength(), + writeFuture); + } + + @Override + public boolean isRepeatable() { + return true; + } + + @Override + public String getContentType() { + return contentType != null ? contentType.toString() : null; + } + + @Override + public long getContentLength() { + return contentLength; + } + + @Override + public int available() { + return Integer.MAX_VALUE; + } + + @Override + public String getContentEncoding() { + return contentEncoding; + } + + @Override + public boolean isChunked() { + return contentLength == -1; + } + + @Override + public Set getTrailerNames() { + return null; + } + + @Override + public void produce(DataStreamChannel channel) throws IOException { + if (bytebuf == null) { + if (content != null) { + try { + content.writeTo(baos); + } catch (IOException e) { + failed(e); + throw e; + } + } + + this.bytebuf = ByteBuffer.wrap(baos.toByteArray()); + } + + if (bytebuf.hasRemaining()) { + channel.write(bytebuf); + } + + if (!bytebuf.hasRemaining()) { + channel.endStream(); + writeFuture.complete(null); + releaseResources(); + } + } + + @Override + public void failed(Exception cause) { + if (exception.compareAndSet(null, cause)) { + releaseResources(); + writeFuture.completeExceptionally(cause); + } + } + + public final Exception getException() { + return exception.get(); + } + + @Override + public void releaseResources() { + if (bytebuf != null) { + bytebuf.clear(); + } + } + + @VisibleForTesting + ByteBuffer getBytebuf() { + return bytebuf; + } +} diff --git a/src/main/java/com/google/firebase/internal/ApacheHttp2Entity.java b/src/main/java/com/google/firebase/internal/ApacheHttp2Entity.java new file mode 100644 index 000000000..9769a28a8 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2Entity.java @@ -0,0 +1,37 @@ +/* + * Copyright 2026 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.internal; + +import org.apache.hc.core5.http.EntityDetails; + +public class ApacheHttp2Entity { + private final byte[] content; + private final EntityDetails entityDetails; + + public ApacheHttp2Entity(byte[] content, EntityDetails entityDetails) { + this.content = content; + this.entityDetails = entityDetails; + } + + public byte[] getContent() { + return content; + } + + public EntityDetails getEntityDetails() { + return entityDetails; + } +} diff --git a/src/main/java/com/google/firebase/internal/ApacheHttp2Request.java b/src/main/java/com/google/firebase/internal/ApacheHttp2Request.java new file mode 100644 index 000000000..b06692ee8 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2Request.java @@ -0,0 +1,166 @@ +/* + * 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.internal; + +import com.google.api.client.http.LowLevelHttpRequest; +import com.google.api.client.http.LowLevelHttpResponse; +import com.google.common.annotations.VisibleForTesting; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.apache.hc.client5.http.ConnectTimeoutException; +import org.apache.hc.client5.http.HttpHostConnectException; +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.Message; +import org.apache.hc.core5.http.nio.support.BasicRequestProducer; +import org.apache.hc.core5.http.nio.support.BasicResponseConsumer; +import org.apache.hc.core5.http2.H2StreamResetException; +import org.apache.hc.core5.util.Timeout; + +final class ApacheHttp2Request extends LowLevelHttpRequest { + private final CloseableHttpAsyncClient httpAsyncClient; + private final SimpleRequestBuilder requestBuilder; + private SimpleHttpRequest request; + private final RequestConfig.Builder requestConfig; + private int writeTimeout; + private int readTimeout; + private ApacheHttp2AsyncEntityProducer entityProducer; + private ApacheHttp2AsyncEntityConsumer entityConsumer; + + ApacheHttp2Request( + CloseableHttpAsyncClient httpAsyncClient, SimpleRequestBuilder requestBuilder) { + this.httpAsyncClient = httpAsyncClient; + this.requestBuilder = requestBuilder; + this.writeTimeout = 0; + this.readTimeout = 0; + + this.requestConfig = RequestConfig.custom() + .setRedirectsEnabled(false); + } + + @Override + public void addHeader(String name, String value) { + requestBuilder.addHeader(name, value); + } + + @Override + public void setTimeout(int connectionTimeout, int readTimeout) throws IOException { + this.readTimeout = readTimeout; + requestConfig + .setConnectTimeout(Timeout.ofMilliseconds(connectionTimeout)) + .setResponseTimeout(Timeout.ofMilliseconds(readTimeout)); + } + + @Override + public void setWriteTimeout(int writeTimeout) throws IOException { + this.writeTimeout = writeTimeout; + } + + @Override + public LowLevelHttpResponse execute() throws IOException { + // Set request configs + requestBuilder.setRequestConfig(requestConfig.build()); + + // Build request + request = requestBuilder.build(); + + // Make Entity Producer + CompletableFuture writeFuture = new CompletableFuture<>(); + entityProducer = new ApacheHttp2AsyncEntityProducer(this, writeFuture); + + // Make Entity Consumer + entityConsumer = new ApacheHttp2AsyncEntityConsumer(); + + // Execute + final Future> responseFuture = httpAsyncClient.execute( + new BasicRequestProducer(request, entityProducer), + new BasicResponseConsumer(entityConsumer), + new FutureCallback>() { + @Override + public void completed(final Message response) { + } + + @Override + public void failed(final Exception exception) { + } + + @Override + public void cancelled() { + } + }); + + // Wait for write + try { + if (writeTimeout != 0) { + writeFuture.get(writeTimeout, TimeUnit.MILLISECONDS); + } + } catch (TimeoutException e) { + throw new IOException("Write Timeout", e.getCause()); + } catch (Exception e) { + throw new IOException("Exception in write", e.getCause()); + } + + // Wait for response + try { + final Message response = + readTimeout > 0 + ? responseFuture.get(readTimeout, TimeUnit.MILLISECONDS) + : responseFuture.get(); + return new ApacheHttp2Response(response); + } catch (ExecutionException e) { + if (e.getCause() instanceof ConnectTimeoutException + || e.getCause() instanceof SocketTimeoutException) { + throw new IOException("Connection Timeout", e.getCause()); + } else if (e.getCause() instanceof HttpHostConnectException) { + throw new IOException("Connection exception in request", e.getCause()); + } else if (e.getCause() instanceof H2StreamResetException) { + throw new IOException("Stream exception in request", e.getCause()); + } else { + throw new IOException("Unknown exception in request", e); + } + } catch (InterruptedException e) { + throw new IOException("Request Interrupted", e); + } catch (CancellationException e) { + throw new IOException("Request Cancelled", e); + } catch (TimeoutException e) { + responseFuture.cancel(true); + throw new IOException("Stream exception in request", e); + } + } + + @VisibleForTesting + ApacheHttp2AsyncEntityProducer getEntityProducer() { + return entityProducer; + } + + @VisibleForTesting + ApacheHttp2AsyncEntityConsumer getEntityConsumer() { + return entityConsumer; + } +} diff --git a/src/main/java/com/google/firebase/internal/ApacheHttp2Response.java b/src/main/java/com/google/firebase/internal/ApacheHttp2Response.java new file mode 100644 index 000000000..7d277b972 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2Response.java @@ -0,0 +1,111 @@ +/* + * 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.internal; + +import com.google.api.client.http.LowLevelHttpResponse; +import com.google.common.annotations.VisibleForTesting; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.Message; +import org.apache.hc.core5.http.message.StatusLine; + +public class ApacheHttp2Response extends LowLevelHttpResponse { + private final Message message; + private final HttpResponse response; + private final Header[] allHeaders; + private final EntityDetails entity; + private final byte[] content; + + ApacheHttp2Response(Message message) { + this.message = message; + this.response = message.getHead(); + this.allHeaders = response.getHeaders(); + + ApacheHttp2Entity body = message.getBody(); + this.entity = body != null ? body.getEntityDetails() : null; + this.content = body != null ? body.getContent() : null; + } + + @Override + public int getStatusCode() { + return response.getCode(); + } + + @Override + public InputStream getContent() throws IOException { + return content == null ? null : new ByteArrayInputStream(content); + } + + @Override + public String getContentEncoding() { + return entity == null ? null : entity.getContentEncoding(); + } + + @Override + public long getContentLength() { + if (content != null) { + return content.length; + } + return entity == null ? -1 : entity.getContentLength(); + } + + @Override + public String getContentType() { + return entity == null ? null : entity.getContentType(); + } + + @Override + public String getReasonPhrase() { + return response.getReasonPhrase(); + } + + @Override + public String getStatusLine() { + return new StatusLine(response).toString(); + } + + public String getHeaderValue(String name) { + Header header = response.getLastHeader(name); + return header == null ? null : header.getValue(); + } + + @Override + public String getHeaderValue(int index) { + return allHeaders[index].getValue(); + } + + @Override + public int getHeaderCount() { + return allHeaders.length; + } + + @Override + public String getHeaderName(int index) { + return allHeaders[index].getName(); + } + + @VisibleForTesting + public Message getMessage() { + return message; + } +} diff --git a/src/main/java/com/google/firebase/internal/ApacheHttp2Transport.java b/src/main/java/com/google/firebase/internal/ApacheHttp2Transport.java new file mode 100644 index 000000000..c701a4652 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2Transport.java @@ -0,0 +1,125 @@ +/* + * 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.internal; + +import com.google.api.client.http.HttpTransport; + +import java.io.IOException; +import java.net.ProxySelector; +import java.util.concurrent.TimeUnit; + +import org.apache.hc.client5.http.async.HttpAsyncClient; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.TlsConfig; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; +import org.apache.hc.client5.http.impl.routing.SystemDefaultRoutePlanner; +import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http2.HttpVersionPolicy; +import org.apache.hc.core5.http2.config.H2Config; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.ssl.SSLContexts; + +/** + * HTTP/2 enabled async transport based on the Apache HTTP Client library + */ +public final class ApacheHttp2Transport extends HttpTransport { + + private final CloseableHttpAsyncClient httpAsyncClient; + private final boolean isMtls; + + public ApacheHttp2Transport() { + this(newDefaultHttpAsyncClient(), false); + } + + public ApacheHttp2Transport(CloseableHttpAsyncClient httpAsyncClient) { + this(httpAsyncClient, false); + } + + public ApacheHttp2Transport(CloseableHttpAsyncClient httpAsyncClient, boolean isMtls) { + this.httpAsyncClient = httpAsyncClient; + this.isMtls = isMtls; + + httpAsyncClient.start(); + } + + public static CloseableHttpAsyncClient newDefaultHttpAsyncClient() { + return defaultHttpAsyncClientBuilder().build(); + } + + public static HttpAsyncClientBuilder defaultHttpAsyncClientBuilder() { + PoolingAsyncClientConnectionManager connectionManager = + PoolingAsyncClientConnectionManagerBuilder.create() + // Set Max total connections to match google api client limits + // https://github.com/googleapis/google-http-java-client/blob/f9d4e15bd3c784b1fd3b0f3468000a91c6f79715/google-http-client-apache-v5/src/main/java/com/google/api/client/http/apache/v5/Apache5HttpTransport.java#L151 + .setMaxConnTotal(200) + // Set max connections per route to match the concurrent stream limit of the FCM backend. + .setMaxConnPerRoute(100) + .setDefaultConnectionConfig( + ConnectionConfig.custom().setTimeToLive(-1, TimeUnit.MILLISECONDS).build()) + .setDefaultTlsConfig( + TlsConfig.custom().setVersionPolicy(HttpVersionPolicy.NEGOTIATE).build()) + .setTlsStrategy(ClientTlsStrategyBuilder.create() + .setSslContext(SSLContexts.createSystemDefault()) + .build()) + .build(); + + return HttpAsyncClientBuilder.create() + // Set maxConcurrentStreams to 100 to match the concurrent stream limit of the FCM backend. + .setH2Config(H2Config.custom().setMaxConcurrentStreams(100).build()) + .setHttp1Config(Http1Config.DEFAULT) + .setConnectionManager(connectionManager) + .setRoutePlanner(new SystemDefaultRoutePlanner(ProxySelector.getDefault())) + .disableRedirectHandling() + .disableAutomaticRetries(); + } + + @Override + public boolean supportsMethod(String method) { + return true; + } + + @Override + protected ApacheHttp2Request buildRequest(String method, String url) { + SimpleRequestBuilder requestBuilder = SimpleRequestBuilder.create(method).setUri(url); + return new ApacheHttp2Request(httpAsyncClient, requestBuilder); + } + + /** + * Gracefully shuts down the connection manager and releases allocated resources. This closes all + * connections, whether they are currently used or not. + */ + @Override + public void shutdown() throws IOException { + httpAsyncClient.close(CloseMode.GRACEFUL); + } + + /** Returns the Apache HTTP client. */ + public HttpAsyncClient getHttpClient() { + return httpAsyncClient; + } + + /** Returns if the underlying HTTP client is mTLS. */ + @Override + public boolean isMtls() { + return isMtls; + } +} diff --git a/src/main/java/com/google/firebase/internal/ApiClientUtils.java b/src/main/java/com/google/firebase/internal/ApiClientUtils.java new file mode 100644 index 000000000..6121ddae5 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApiClientUtils.java @@ -0,0 +1,97 @@ +/* + * Copyright 2019 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.internal; + +import com.google.api.client.googleapis.util.Utils; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.common.collect.ImmutableList; +import com.google.firebase.FirebaseApp; + +import java.io.IOException; + +/** + * A set of shared utilities for using the Google API client. + */ +public class ApiClientUtils { + + static final RetryConfig DEFAULT_RETRY_CONFIG = RetryConfig.builder() + .setMaxRetries(4) + .setRetryStatusCodes(ImmutableList.of(503)) + .setMaxIntervalMillis(60 * 1000) + .build(); + + private ApiClientUtils() { } + + /** + * Creates a new {@code HttpRequestFactory} which provides authorization (OAuth2), timeouts and + * automatic retries. + * + * @param app {@link FirebaseApp} from which to obtain authorization credentials. + * @return A new {@code HttpRequestFactory} instance. + */ + public static HttpRequestFactory newAuthorizedRequestFactory(FirebaseApp app) { + return newAuthorizedRequestFactory(app, DEFAULT_RETRY_CONFIG); + } + + /** + * Creates a new {@code HttpRequestFactory} which provides authorization (OAuth2), timeouts and + * automatic retries. + * + * @param app {@link FirebaseApp} from which to obtain authorization credentials. + * @param retryConfig {@link RetryConfig} instance or null to disable retries. + * @return A new {@code HttpRequestFactory} instance. + */ + public static HttpRequestFactory newAuthorizedRequestFactory( + FirebaseApp app, @Nullable RetryConfig retryConfig) { + HttpTransport transport = app.getOptions().getHttpTransport(); + return transport.createRequestFactory(new FirebaseRequestInitializer(app, retryConfig)); + } + + public static HttpRequestFactory newUnauthorizedRequestFactory(FirebaseApp app) { + HttpTransport transport = app.getOptions().getHttpTransport(); + return transport.createRequestFactory(); + } + + public static void disconnectQuietly(HttpResponse response) { + if (response != null) { + try { + response.disconnect(); + } catch (IOException ignored) { + // ignored + } + } + } + + public static JsonFactory getDefaultJsonFactory() { + // Force using the Jackson2 parser for this project for now. Eventually we should switch + // to Gson, but there are some issues that's preventing this migration at the moment. + // See https://github.com/googleapis/google-api-java-client/issues/1779 for details. + return JacksonFactory.getDefaultInstance(); + } + + public static HttpTransport getDefaultTransport() { + return TransportInstanceHolder.INSTANCE; + } + + private static class TransportInstanceHolder { + static final HttpTransport INSTANCE = new ApacheHttp2Transport(); + } +} diff --git a/src/main/java/com/google/firebase/internal/ApplicationDefaultCredentialsProvider.java b/src/main/java/com/google/firebase/internal/ApplicationDefaultCredentialsProvider.java new file mode 100644 index 000000000..e197c796c --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApplicationDefaultCredentialsProvider.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021 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.internal; + +import com.google.auth.oauth2.GoogleCredentials; +import java.io.IOException; + +/** + * Provides a hook to override application default credentials (ADC) lookup for tests. ADC has + * a dependency on environment variables, and Java famously doesn't support environment variable + * manipulation at runtime. With this class, the test cases that require ADC has a way to register + * their own mock credentials as ADC. + * + *

Once we are able to upgrade to Mockito 3.x (requires Java 8+), we can drop this class + * altogether, and use Mockito tools to mock the behavior of the GoogleCredentials static methods. + */ +public class ApplicationDefaultCredentialsProvider { + + private static GoogleCredentials cachedCredentials; + + public static GoogleCredentials getApplicationDefault() throws IOException { + if (cachedCredentials != null) { + return cachedCredentials; + } + + return GoogleCredentials.getApplicationDefault(); + } + + public static void setApplicationDefault(GoogleCredentials credentials) { + cachedCredentials = credentials; + } +} diff --git a/src/main/java/com/google/firebase/internal/CallableOperation.java b/src/main/java/com/google/firebase/internal/CallableOperation.java index ac28e8a7c..053cdf35a 100644 --- a/src/main/java/com/google/firebase/internal/CallableOperation.java +++ b/src/main/java/com/google/firebase/internal/CallableOperation.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/internal/DateUtils.java b/src/main/java/com/google/firebase/internal/DateUtils.java new file mode 100644 index 000000000..a55fac8c0 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/DateUtils.java @@ -0,0 +1,109 @@ +/* + * 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.internal; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.text.ParsePosition; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +/** + * A utility class for parsing and formatting HTTP dates as used in cookies and + * other headers. This class handles dates as defined by RFC 2616 section + * 3.3.1 as well as some other common non-standard formats. + * + *

Most of this class was borrowed from the + * + * Apache HTTP client in order to avoid a direct dependency on it. We currently + * have a transitive dependency on this library (via Google API client), but the API + * client team is working towards removing it, so we won't have it in the classpath for long. + * + *

The original implementation of this class uses + * thread locals to cache the {@code SimpleDateFormat} instances. Instead, this implementation + * uses static constants and explicit locking to ensure thread safety. This is probably slower, + * but also simpler and avoids memory leaks that may result from unreleased thread locals. + */ +final class DateUtils { + + /** + * Date format pattern used to parse HTTP date headers in RFC 1123 format. + */ + static final String PATTERN_RFC1123 = "EEE, dd MMM yyyy HH:mm:ss zzz"; + + /** + * Date format pattern used to parse HTTP date headers in RFC 1036 format. + */ + static final String PATTERN_RFC1036 = "EEE, dd-MMM-yy HH:mm:ss zzz"; + + /** + * Date format pattern used to parse HTTP date headers in ANSI C + * {@code asctime()} format. + */ + static final String PATTERN_ASCTIME = "EEE MMM d HH:mm:ss yyyy"; + + private static final SimpleDateFormat[] DEFAULT_PATTERNS = new SimpleDateFormat[] { + new SimpleDateFormat(PATTERN_RFC1123, Locale.US), + new SimpleDateFormat(PATTERN_RFC1036, Locale.US), + new SimpleDateFormat(PATTERN_ASCTIME, Locale.US) + }; + + static final TimeZone GMT = TimeZone.getTimeZone("GMT"); + + static { + final Calendar calendar = Calendar.getInstance(); + calendar.setTimeZone(GMT); + calendar.set(2000, Calendar.JANUARY, 1, 0, 0, 0); + calendar.set(Calendar.MILLISECOND, 0); + final Date defaultTwoDigitYearStart = calendar.getTime(); + + for (final SimpleDateFormat datePattern : DEFAULT_PATTERNS) { + datePattern.set2DigitYearStart(defaultTwoDigitYearStart); + } + } + + /** + * Parses the date value using the given date formats. + * + * @param dateValue the date value to parse + * @return the parsed date or null if input could not be parsed + */ + public static Date parseDate(final String dateValue) { + String v = checkNotNull(dateValue); + // trim single quotes around date if present + // see issue #5279 + if (v.length() > 1 && v.startsWith("'") && v.endsWith("'")) { + v = v.substring(1, v.length() - 1); + } + + for (final SimpleDateFormat datePattern : DEFAULT_PATTERNS) { + final ParsePosition pos = new ParsePosition(0); + synchronized (datePattern) { + final Date result = datePattern.parse(v, pos); + if (pos.getIndex() != 0) { + return result; + } + } + } + return null; + } + + /** This class should not be instantiated. */ + private DateUtils() { + } +} diff --git a/src/main/java/com/google/firebase/internal/EmulatorCredentials.java b/src/main/java/com/google/firebase/internal/EmulatorCredentials.java new file mode 100644 index 000000000..fb6541d63 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/EmulatorCredentials.java @@ -0,0 +1,52 @@ +/* + * Copyright 2022 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.internal; + +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.common.collect.ImmutableMap; + +import java.io.IOException; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * A Mock Credentials implementation that can be used with the Emulator Suite + */ +public final class EmulatorCredentials extends GoogleCredentials { + + public EmulatorCredentials() { + super(newToken()); + } + + private static AccessToken newToken() { + return new AccessToken("owner", + new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1))); + } + + @Override + public AccessToken refreshAccessToken() { + return newToken(); + } + + @Override + public Map> getRequestMetadata() throws IOException { + return ImmutableMap.of(); + } +} diff --git a/src/main/java/com/google/firebase/internal/ErrorHandlingHttpClient.java b/src/main/java/com/google/firebase/internal/ErrorHandlingHttpClient.java new file mode 100644 index 000000000..77a342042 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ErrorHandlingHttpClient.java @@ -0,0 +1,145 @@ +/* + * Copyright 2020 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.internal; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpResponseException; +import com.google.api.client.http.HttpResponseInterceptor; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.JsonParser; +import com.google.common.io.CharStreams; +import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; + +/** + * An HTTP client implementation that handles any errors that may occur during HTTP calls, and + * converts them into an instance of FirebaseException. + */ +public final class ErrorHandlingHttpClient { + + private final HttpRequestFactory requestFactory; + private final JsonFactory jsonFactory; + private final HttpErrorHandler errorHandler; + + private HttpResponseInterceptor interceptor; + + public ErrorHandlingHttpClient( + HttpRequestFactory requestFactory, + JsonFactory jsonFactory, + HttpErrorHandler errorHandler) { + this.requestFactory = checkNotNull(requestFactory, "requestFactory must not be null"); + this.jsonFactory = checkNotNull(jsonFactory, "jsonFactory must not be null"); + this.errorHandler = checkNotNull(errorHandler, "errorHandler must not be null"); + } + + public ErrorHandlingHttpClient setInterceptor(HttpResponseInterceptor interceptor) { + this.interceptor = interceptor; + return this; + } + + /** + * Sends the given HTTP request to the target endpoint, and parses the response while handling + * any errors that may occur along the way. + * + * @param requestInfo Outgoing request configuration. + * @param responseType Class to parse the response into. + * @param Parsed response type. + * @return Parsed response object. + * @throws T If any error occurs while making the request. + */ + public V sendAndParse(HttpRequestInfo requestInfo, Class responseType) throws T { + IncomingHttpResponse response = send(requestInfo); + return parse(response, responseType); + } + + /** + * Sends the given HTTP request to the target endpoint, and parses the response while handling + * any errors that may occur along the way. This method can be used when the response should + * be parsed into an instance of a private or protected class, which cannot be instantiated + * outside the call-site. + * + * @param requestInfo Outgoing request configuration. + * @param destination Object to parse the response into. + * @throws T If any error occurs while making the request. + */ + public void sendAndParse(HttpRequestInfo requestInfo, Object destination) throws T { + IncomingHttpResponse response = send(requestInfo); + parse(response, destination); + } + + public IncomingHttpResponse send(HttpRequestInfo requestInfo) throws T { + requestInfo.addHeader("X-Goog-Api-Client", SdkUtils.getMetricsHeader()); + HttpRequest request = createHttpRequest(requestInfo); + + HttpResponse response = null; + try { + response = request.execute(); + // Read and buffer the content. Otherwise if a parse error occurs later, + // we lose the content stream. + String content = null; + InputStream stream = response.getContent(); + if (stream != null) { + // Stream is null when the response body is empty (e.g. 204 No Content responses). + content = CharStreams.toString(new InputStreamReader(stream, response.getContentCharset())); + } + + return new IncomingHttpResponse(response, content); + } catch (HttpResponseException e) { + throw errorHandler.handleHttpResponseException(e, new IncomingHttpResponse(e, request)); + } catch (IOException e) { + throw errorHandler.handleIOException(e); + } finally { + ApiClientUtils.disconnectQuietly(response); + } + } + + public V parse(IncomingHttpResponse response, Class responseType) throws T { + checkNotNull(responseType, "responseType must not be null"); + try { + JsonParser parser = jsonFactory.createJsonParser(response.getContent()); + return parser.parseAndClose(responseType); + } catch (IOException e) { + throw errorHandler.handleParseException(e, response); + } + } + + public void parse(IncomingHttpResponse response, Object destination) throws T { + try { + JsonParser parser = jsonFactory.createJsonParser(response.getContent()); + parser.parse(destination); + } catch (IOException e) { + throw errorHandler.handleParseException(e, response); + } + } + + private HttpRequest createHttpRequest(HttpRequestInfo requestInfo) throws T { + try { + return requestInfo.newHttpRequest(requestFactory, jsonFactory) + .setResponseInterceptor(interceptor); + } catch (IOException e) { + // Handle request initialization errors (credential loading and other config errors) + throw errorHandler.handleIOException(e); + } + } +} diff --git a/src/main/java/com/google/firebase/internal/FirebaseAppStore.java b/src/main/java/com/google/firebase/internal/FirebaseAppStore.java deleted file mode 100644 index 778295655..000000000 --- a/src/main/java/com/google/firebase/internal/FirebaseAppStore.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2017 Google Inc. - * - * 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.internal; - -import com.google.common.annotations.VisibleForTesting; -import com.google.firebase.FirebaseApp; -import com.google.firebase.FirebaseOptions; - -import java.util.Collections; -import java.util.Set; -import java.util.concurrent.atomic.AtomicReference; - -/** No-op base class of FirebaseAppStore. */ -public class FirebaseAppStore { - - private static final AtomicReference sInstance = new AtomicReference<>(); - - FirebaseAppStore() {} - - @Nullable - public static FirebaseAppStore getInstance() { - return sInstance.get(); - } - - // TODO: reenable persistence. See b/28158809. - public static FirebaseAppStore initialize() { - sInstance.compareAndSet(null /* expected */, new FirebaseAppStore()); - return sInstance.get(); - } - - /** - * @hide - */ - public static void setInstanceForTest(FirebaseAppStore firebaseAppStore) { - sInstance.set(firebaseAppStore); - } - - @VisibleForTesting - public static void clearInstanceForTest() { - FirebaseAppStore instance = sInstance.get(); - if (instance != null) { - instance.resetStore(); - } - sInstance.set(null); - } - - /** The returned set is mutable. */ - public Set getAllPersistedAppNames() { - return Collections.emptySet(); - } - - public void persistApp(@NonNull FirebaseApp app) {} - - public void removeApp(@NonNull String name) {} - - /** - * @return The restored {@link FirebaseOptions}, or null if it doesn't exist. - */ - public FirebaseOptions restoreAppOptions(@NonNull String name) { - return null; - } - - protected void resetStore() {} -} diff --git a/src/main/java/com/google/firebase/internal/FirebaseProcessEnvironment.java b/src/main/java/com/google/firebase/internal/FirebaseProcessEnvironment.java new file mode 100644 index 000000000..b6fd789d4 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/FirebaseProcessEnvironment.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021 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.internal; + +import com.google.common.base.Strings; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A utility for overriding environment variables during tests. + */ +public class FirebaseProcessEnvironment { + + private static final Map localCache = new ConcurrentHashMap<>(); + + public static String getenv(String name) { + String cachedValue = localCache.get(name); + if (!Strings.isNullOrEmpty(cachedValue)) { + return cachedValue; + } + + return System.getenv(name); + } + + public static void setenv(String name, String value) { + localCache.put(name, value); + } + + public static void clearCache() { + localCache.clear(); + } +} diff --git a/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java b/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java index 0cd4e9393..910526b3a 100644 --- a/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java +++ b/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,32 +19,60 @@ import com.google.api.client.http.HttpRequest; import com.google.api.client.http.HttpRequestInitializer; import com.google.auth.http.HttpCredentialsAdapter; -import com.google.auth.oauth2.GoogleCredentials; +import com.google.common.collect.ImmutableList; import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; import com.google.firebase.ImplFirebaseTrampolines; import java.io.IOException; +import java.util.List; /** - * {@code HttpRequestInitializer} for configuring outgoing REST calls. Handles OAuth2 authorization - * and setting timeout values. + * {@code HttpRequestInitializer} for configuring outgoing REST calls. Initializes requests with + * OAuth2 credentials, timeout and retry settings. */ -public class FirebaseRequestInitializer implements HttpRequestInitializer { +public final class FirebaseRequestInitializer implements HttpRequestInitializer { - private final HttpCredentialsAdapter credentialsAdapter; - private final int connectTimeout; - private final int readTimeout; + private final List initializers; public FirebaseRequestInitializer(FirebaseApp app) { - GoogleCredentials credentials = ImplFirebaseTrampolines.getCredentials(app); - this.credentialsAdapter = new HttpCredentialsAdapter(credentials); - this.connectTimeout = app.getOptions().getConnectTimeout(); - this.readTimeout = app.getOptions().getReadTimeout(); + this(app, null); + } + + public FirebaseRequestInitializer(FirebaseApp app, @Nullable RetryConfig retryConfig) { + ImmutableList.Builder initializers = + ImmutableList.builder() + .add(new HttpCredentialsAdapter(ImplFirebaseTrampolines.getCredentials(app))) + .add(new TimeoutInitializer(app.getOptions())); + if (retryConfig != null) { + initializers.add(new RetryInitializer(retryConfig)); + } + this.initializers = initializers.build(); } @Override - public void initialize(HttpRequest httpRequest) throws IOException { - credentialsAdapter.initialize(httpRequest); - httpRequest.setConnectTimeout(connectTimeout); - httpRequest.setReadTimeout(readTimeout); + public void initialize(HttpRequest request) throws IOException { + for (HttpRequestInitializer initializer : initializers) { + initializer.initialize(request); + } + } + + private static class TimeoutInitializer implements HttpRequestInitializer { + + private final int connectTimeoutMillis; + private final int readTimeoutMillis; + private final int writeTimeoutMillis; + + TimeoutInitializer(FirebaseOptions options) { + this.connectTimeoutMillis = options.getConnectTimeout(); + this.readTimeoutMillis = options.getReadTimeout(); + this.writeTimeoutMillis = options.getWriteTimeout(); + } + + @Override + public void initialize(HttpRequest request) { + request.setConnectTimeout(connectTimeoutMillis); + request.setReadTimeout(readTimeoutMillis); + request.setWriteTimeout(writeTimeoutMillis); + } } } diff --git a/src/main/java/com/google/firebase/internal/FirebaseScheduledExecutor.java b/src/main/java/com/google/firebase/internal/FirebaseScheduledExecutor.java index 0a472ba47..d581a6b91 100644 --- a/src/main/java/com/google/firebase/internal/FirebaseScheduledExecutor.java +++ b/src/main/java/com/google/firebase/internal/FirebaseScheduledExecutor.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/internal/FirebaseService.java b/src/main/java/com/google/firebase/internal/FirebaseService.java index d3e87551f..75de81427 100644 --- a/src/main/java/com/google/firebase/internal/FirebaseService.java +++ b/src/main/java/com/google/firebase/internal/FirebaseService.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,7 +28,7 @@ * * @param Type of the service */ -public abstract class FirebaseService { +public class FirebaseService { private final String id; protected final T instance; @@ -62,5 +62,7 @@ public final T getInstance() { * Tear down this FirebaseService instance and the service object wrapped in it, cleaning up * any allocated resources in the process. */ - public abstract void destroy(); + public void destroy() { + // Child classes can override this method to implement any service-specific cleanup logic. + } } diff --git a/src/main/java/com/google/firebase/internal/FirebaseThreadManagers.java b/src/main/java/com/google/firebase/internal/FirebaseThreadManagers.java index 877450fcc..38db746df 100644 --- a/src/main/java/com/google/firebase/internal/FirebaseThreadManagers.java +++ b/src/main/java/com/google/firebase/internal/FirebaseThreadManagers.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,7 +23,10 @@ import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -84,7 +87,11 @@ private static class DefaultThreadManager extends GlobalThreadManager { protected ExecutorService doInit() { ThreadFactory threadFactory = FirebaseScheduledExecutor.getThreadFactoryWithName( getThreadFactory(), "firebase-default-%d"); - return Executors.newCachedThreadPool(threadFactory); + + ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(100, 100, 60L, + TimeUnit.SECONDS, new LinkedBlockingQueue(), threadFactory); + threadPoolExecutor.allowCoreThreadTimeOut(true); + return threadPoolExecutor; } @Override diff --git a/src/main/java/com/google/firebase/internal/GuardedBy.java b/src/main/java/com/google/firebase/internal/GuardedBy.java index 9f1bd1ef7..a44353262 100644 --- a/src/main/java/com/google/firebase/internal/GuardedBy.java +++ b/src/main/java/com/google/firebase/internal/GuardedBy.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/internal/HttpErrorHandler.java b/src/main/java/com/google/firebase/internal/HttpErrorHandler.java new file mode 100644 index 000000000..29f7ec540 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/HttpErrorHandler.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020 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.internal; + +import com.google.api.client.http.HttpResponseException; +import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; +import java.io.IOException; + +/** + * An interface for handling all sorts of exceptions that may occur while making an HTTP call and + * converting them into some instance of FirebaseException. + */ +public interface HttpErrorHandler { + + /** + * Handle any low-level transport and initialization errors. + */ + T handleIOException(IOException e); + + /** + * Handle HTTP response exceptions (caused by HTTP error responses). + */ + T handleHttpResponseException(HttpResponseException e, IncomingHttpResponse response); + + /** + * Handle any errors that may occur while parsing the response payload. + */ + T handleParseException(IOException e, IncomingHttpResponse response); +} diff --git a/src/main/java/com/google/firebase/internal/HttpRequestInfo.java b/src/main/java/com/google/firebase/internal/HttpRequestInfo.java new file mode 100644 index 000000000..22a2cf2b1 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/HttpRequestInfo.java @@ -0,0 +1,132 @@ +/* + * Copyright 2020 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.internal; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpContent; +import com.google.api.client.http.HttpMethods; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.json.JsonHttpContent; +import com.google.api.client.json.JsonFactory; +import com.google.common.base.Strings; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * Internal API for configuring outgoing HTTP requests. To be used with the + * {@link ErrorHandlingHttpClient} class. + */ +public final class HttpRequestInfo { + + private final String method; + private final GenericUrl url; + private final HttpContent content; + private final Object jsonContent; + private final Map headers = new HashMap<>(); + + private HttpRequestInfo(String method, GenericUrl url, HttpContent content, Object jsonContent) { + checkArgument(!Strings.isNullOrEmpty(method), "method must not be null"); + this.method = method; + this.url = checkNotNull(url, "url must not be null"); + this.content = content; + this.jsonContent = jsonContent; + } + + public HttpRequestInfo addHeader(String name, String value) { + this.headers.put(name, value); + return this; + } + + public HttpRequestInfo addAllHeaders(Map headers) { + this.headers.putAll(headers); + return this; + } + + public HttpRequestInfo addParameter(String name, Object value) { + this.url.put(name, value); + return this; + } + + public HttpRequestInfo addAllParameters(Map params) { + this.url.putAll(params); + return this; + } + + public static HttpRequestInfo buildGetRequest(String url) { + return buildRequest(HttpMethods.GET, url, null); + } + + public static HttpRequestInfo buildDeleteRequest(String url) { + return buildRequest(HttpMethods.DELETE, url, null); + } + + public static HttpRequestInfo buildRequest( + String method, String url, @Nullable HttpContent content) { + return new HttpRequestInfo(method, new GenericUrl(url), content, null); + } + + public static HttpRequestInfo buildJsonPostRequest(String url, @Nullable Object content) { + return buildJsonRequest(HttpMethods.POST, url, content); + } + + public static HttpRequestInfo buildJsonPatchRequest(String url, @Nullable Object content) { + return buildJsonRequest(HttpMethods.PATCH, url, content); + } + + public static HttpRequestInfo buildJsonRequest( + String method, String url, @Nullable Object content) { + return new HttpRequestInfo(method, new GenericUrl(url), null, content); + } + + HttpRequest newHttpRequest( + HttpRequestFactory factory, JsonFactory jsonFactory) throws IOException { + HttpRequest request; + HttpContent httpContent = getContent(jsonFactory); + if (factory.getTransport().supportsMethod(method)) { + request = factory.buildRequest(method, url, httpContent); + } else { + // Some HttpTransport implementations (notably NetHttpTransport) don't support new methods + // like PATCH. We try to emulate such requests over POST by setting the method override + // header, which is recognized by most Google backend APIs. + request = factory.buildPostRequest(url, httpContent); + request.getHeaders().set("X-HTTP-Method-Override", method); + } + + for (Map.Entry entry : headers.entrySet()) { + request.getHeaders().set(entry.getKey(), entry.getValue()); + } + + return request; + } + + private HttpContent getContent(JsonFactory jsonFactory) { + if (content != null) { + return content; + } + + if (jsonContent != null) { + return new JsonHttpContent(jsonFactory, jsonContent); + } + + return null; + } +} diff --git a/src/main/java/com/google/firebase/internal/ListenableFuture2ApiFuture.java b/src/main/java/com/google/firebase/internal/ListenableFuture2ApiFuture.java index 18d98da7e..8f90d4d8a 100644 --- a/src/main/java/com/google/firebase/internal/ListenableFuture2ApiFuture.java +++ b/src/main/java/com/google/firebase/internal/ListenableFuture2ApiFuture.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/internal/MockApacheHttp2AsyncClient.java b/src/main/java/com/google/firebase/internal/MockApacheHttp2AsyncClient.java new file mode 100644 index 000000000..a61751912 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/MockApacheHttp2AsyncClient.java @@ -0,0 +1,73 @@ +/* + * 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.internal; + +import java.io.IOException; +import java.util.concurrent.Future; + +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.function.Supplier; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.nio.AsyncPushConsumer; +import org.apache.hc.core5.http.nio.AsyncRequestProducer; +import org.apache.hc.core5.http.nio.AsyncResponseConsumer; +import org.apache.hc.core5.http.nio.HandlerFactory; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.IOReactorStatus; +import org.apache.hc.core5.util.TimeValue; + +public class MockApacheHttp2AsyncClient extends CloseableHttpAsyncClient { + + @Override + public void close(CloseMode closeMode) { + } + + @Override + public void close() throws IOException { + } + + @Override + public void start() { + } + + @Override + public IOReactorStatus getStatus() { + return null; + } + + @Override + public void awaitShutdown(TimeValue waitTime) throws InterruptedException { + } + + @Override + public void initiateShutdown() { + } + + @Override + protected Future doExecute(HttpHost target, AsyncRequestProducer requestProducer, + AsyncResponseConsumer responseConsumer, + HandlerFactory pushHandlerFactory, + HttpContext context, FutureCallback callback) { + return null; + } + + @Override + public void register(String hostname, String uriPattern, Supplier supplier) { + } +} diff --git a/src/main/java/com/google/firebase/internal/NonNull.java b/src/main/java/com/google/firebase/internal/NonNull.java index a65e082b0..0307a4819 100644 --- a/src/main/java/com/google/firebase/internal/NonNull.java +++ b/src/main/java/com/google/firebase/internal/NonNull.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/internal/Nullable.java b/src/main/java/com/google/firebase/internal/Nullable.java index 48572757e..6f1ef4d08 100644 --- a/src/main/java/com/google/firebase/internal/Nullable.java +++ b/src/main/java/com/google/firebase/internal/Nullable.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/internal/RetryConfig.java b/src/main/java/com/google/firebase/internal/RetryConfig.java new file mode 100644 index 000000000..7096ec659 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/RetryConfig.java @@ -0,0 +1,177 @@ +/* + * Copyright 2019 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.internal; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.util.BackOff; +import com.google.api.client.util.ExponentialBackOff; +import com.google.api.client.util.Sleeper; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Configures when and how HTTP requests should be retried. + */ +public final class RetryConfig { + + private static final int INITIAL_INTERVAL_MILLIS = 500; + + private final List retryStatusCodes; + private final boolean retryOnIOExceptions; + private final int maxRetries; + private final Sleeper sleeper; + private final ExponentialBackOff.Builder backOffBuilder; + + private RetryConfig(Builder builder) { + if (builder.retryStatusCodes != null) { + this.retryStatusCodes = ImmutableList.copyOf(builder.retryStatusCodes); + } else { + this.retryStatusCodes = ImmutableList.of(); + } + + this.retryOnIOExceptions = builder.retryOnIOExceptions; + checkArgument(builder.maxRetries >= 0, "maxRetries must not be negative"); + this.maxRetries = builder.maxRetries; + this.sleeper = checkNotNull(builder.sleeper); + this.backOffBuilder = new ExponentialBackOff.Builder() + .setInitialIntervalMillis(INITIAL_INTERVAL_MILLIS) + .setMaxIntervalMillis(builder.maxIntervalMillis) + .setMultiplier(builder.backOffMultiplier) + .setRandomizationFactor(0); + + // Force validation of arguments by building the BackOff object + this.backOffBuilder.build(); + } + + List getRetryStatusCodes() { + return retryStatusCodes; + } + + boolean isRetryOnIOExceptions() { + return retryOnIOExceptions; + } + + int getMaxRetries() { + return maxRetries; + } + + int getMaxIntervalMillis() { + return backOffBuilder.getMaxIntervalMillis(); + } + + double getBackOffMultiplier() { + return backOffBuilder.getMultiplier(); + } + + Sleeper getSleeper() { + return sleeper; + } + + BackOff newBackOff() { + return backOffBuilder.build(); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private List retryStatusCodes; + private boolean retryOnIOExceptions; + private int maxRetries; + private int maxIntervalMillis = (int) TimeUnit.MINUTES.toMillis(2); + private double backOffMultiplier = 2.0; + private Sleeper sleeper = Sleeper.DEFAULT; + + private Builder() { } + + /** + * Sets a list of HTTP status codes that should be retried. If null or empty, HTTP requests + * will not be retried as long as they result in some HTTP response message. + * + * @param retryStatusCodes A list of status codes. + * @return This builder. + */ + public Builder setRetryStatusCodes(List retryStatusCodes) { + this.retryStatusCodes = retryStatusCodes; + return this; + } + + /** + * Sets whether requests should be retried on IOExceptions. + * + * @param retryOnIOExceptions A boolean indicating whether to retry on IOExceptions. + * @return This builder. + */ + public Builder setRetryOnIOExceptions(boolean retryOnIOExceptions) { + this.retryOnIOExceptions = retryOnIOExceptions; + return this; + } + + /** + * Maximum number of retry attempts for a request. This is the cumulative total for all retries + * regardless of their cause (I/O errors and HTTP error responses). + * + * @param maxRetries A non-negative integer. + * @return This builder. + */ + public Builder setMaxRetries(int maxRetries) { + this.maxRetries = maxRetries; + return this; + } + + /** + * Maximum interval to wait before a request should be retried. Must be at least 500 + * milliseconds. Defaults to 2 minutes. + * + * @param maxIntervalMillis Interval in milliseconds. + * @return This builder. + */ + public Builder setMaxIntervalMillis(int maxIntervalMillis) { + this.maxIntervalMillis = maxIntervalMillis; + return this; + } + + /** + * Factor by which the retry interval is multiplied when employing exponential back + * off to delay consecutive retries of the same request. Must be at least 1. Defaults + * to 2. + * + * @param backOffMultiplier Multiplication factor for exponential back off. + * @return This builder. + */ + public Builder setBackOffMultiplier(double backOffMultiplier) { + this.backOffMultiplier = backOffMultiplier; + return this; + } + + @VisibleForTesting + Builder setSleeper(Sleeper sleeper) { + this.sleeper = sleeper; + return this; + } + + public RetryConfig build() { + return new RetryConfig(this); + } + } +} diff --git a/src/main/java/com/google/firebase/internal/RetryInitializer.java b/src/main/java/com/google/firebase/internal/RetryInitializer.java new file mode 100644 index 000000000..5bba2090b --- /dev/null +++ b/src/main/java/com/google/firebase/internal/RetryInitializer.java @@ -0,0 +1,112 @@ +/* + * Copyright 2019 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.internal; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpBackOffIOExceptionHandler; +import com.google.api.client.http.HttpIOExceptionHandler; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpUnsuccessfulResponseHandler; +import java.io.IOException; + +/** + * Configures HTTP requests to be retried. Requests that encounter I/O errors are retried if + * {@link RetryConfig#isRetryOnIOExceptions()} is set. Requests failing with unsuccessful HTTP + * responses are first referred to the {@code HttpUnsuccessfulResponseHandler} that was originally + * set on the request. If the request does not get retried at that level, + * {@link RetryUnsuccessfulResponseHandler} is used to schedule additional retries. + */ +final class RetryInitializer implements HttpRequestInitializer { + + private final RetryConfig retryConfig; + + RetryInitializer(RetryConfig retryConfig) { + this.retryConfig = checkNotNull(retryConfig); + } + + @Override + public void initialize(HttpRequest request) { + request.setNumberOfRetries(retryConfig.getMaxRetries()); + request.setUnsuccessfulResponseHandler(newUnsuccessfulResponseHandler(request)); + if (retryConfig.isRetryOnIOExceptions()) { + request.setIOExceptionHandler(newIOExceptionHandler()); + } + } + + private HttpUnsuccessfulResponseHandler newUnsuccessfulResponseHandler(HttpRequest request) { + RetryUnsuccessfulResponseHandler retryHandler = new RetryUnsuccessfulResponseHandler( + retryConfig); + return new RetryHandlerDecorator(retryHandler, request); + } + + private HttpIOExceptionHandler newIOExceptionHandler() { + return new HttpBackOffIOExceptionHandler(retryConfig.newBackOff()) + .setSleeper(retryConfig.getSleeper()); + } + + /** + * Makes sure that any error handlers already set on the request are executed before the retry + * handler is called. This is needed since some initializers (e.g. HttpCredentialsAdapter) + * register their own error handlers. + */ + static class RetryHandlerDecorator implements HttpUnsuccessfulResponseHandler { + + private final RetryUnsuccessfulResponseHandler retryHandler; + private final HttpUnsuccessfulResponseHandler preRetryHandler; + + private RetryHandlerDecorator( + RetryUnsuccessfulResponseHandler retryHandler, HttpRequest request) { + this.retryHandler = checkNotNull(retryHandler); + HttpUnsuccessfulResponseHandler preRetryHandler = request.getUnsuccessfulResponseHandler(); + if (preRetryHandler == null) { + preRetryHandler = new HttpUnsuccessfulResponseHandler() { + @Override + public boolean handleResponse( + HttpRequest request, HttpResponse response, boolean supportsRetry) { + return false; + } + }; + } + this.preRetryHandler = preRetryHandler; + } + + @Override + public boolean handleResponse( + HttpRequest request, + HttpResponse response, + boolean supportsRetry) throws IOException { + try { + boolean retry = preRetryHandler.handleResponse(request, response, supportsRetry); + if (!retry) { + retry = retryHandler.handleResponse(request, response, supportsRetry); + } + return retry; + } finally { + // Pre-retry handler may have reset the unsuccessful response handler on the + // request. This changes it back. + request.setUnsuccessfulResponseHandler(this); + } + } + + RetryUnsuccessfulResponseHandler getRetryHandler() { + return retryHandler; + } + } +} diff --git a/src/main/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandler.java b/src/main/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandler.java new file mode 100644 index 000000000..51dd34395 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandler.java @@ -0,0 +1,112 @@ +/* + * Copyright 2019 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.internal; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpUnsuccessfulResponseHandler; +import com.google.api.client.util.BackOff; +import com.google.api.client.util.BackOffUtils; +import com.google.api.client.util.Clock; +import com.google.api.client.util.Sleeper; +import com.google.common.base.Strings; +import java.io.IOException; +import java.util.Date; + +/** + * An {@code HttpUnsuccessfulResponseHandler} that retries failing requests after an interval. The + * interval is determined by checking the Retry-After header on the last response. If that + * header is not present, uses exponential back off to delay subsequent retries. + */ +final class RetryUnsuccessfulResponseHandler implements HttpUnsuccessfulResponseHandler { + + private final RetryConfig retryConfig; + private final BackOff backOff; + private final Sleeper sleeper; + private final Clock clock; + + RetryUnsuccessfulResponseHandler(RetryConfig retryConfig) { + this(retryConfig, Clock.SYSTEM); + } + + RetryUnsuccessfulResponseHandler(RetryConfig retryConfig, Clock clock) { + this.retryConfig = checkNotNull(retryConfig); + this.backOff = retryConfig.newBackOff(); + this.sleeper = retryConfig.getSleeper(); + this.clock = checkNotNull(clock); + } + + @Override + public boolean handleResponse( + HttpRequest request, HttpResponse response, boolean supportsRetry) throws IOException { + + if (!supportsRetry) { + return false; + } + + int statusCode = response.getStatusCode(); + if (!retryConfig.getRetryStatusCodes().contains(statusCode)) { + return false; + } + + try { + return waitAndRetry(response); + } catch (InterruptedException e) { + // ignore + } + return false; + } + + RetryConfig getRetryConfig() { + return retryConfig; + } + + private boolean waitAndRetry(HttpResponse response) throws IOException, InterruptedException { + String retryAfterHeader = response.getHeaders().getRetryAfter(); + if (!Strings.isNullOrEmpty(retryAfterHeader)) { + long intervalMillis = parseRetryAfterHeaderIntoMillis(retryAfterHeader.trim()); + // Retry-after header can specify very long delay intervals (e.g. 24 hours). If we cannot + // wait that long, we should not perform any retries at all. In general it is not correct to + // retry earlier than what the server has recommended to us. + if (intervalMillis > retryConfig.getMaxIntervalMillis()) { + return false; + } + + if (intervalMillis > 0) { + sleeper.sleep(intervalMillis); + return true; + } + } + + return BackOffUtils.next(sleeper, backOff); + } + + private long parseRetryAfterHeaderIntoMillis(String retryAfter) { + try { + return Long.parseLong(retryAfter) * 1000; + } catch (NumberFormatException e) { + Date date = DateUtils.parseDate(retryAfter); + if (date != null) { + return date.getTime() - clock.currentTimeMillis(); + } + } + + return -1L; + } +} diff --git a/src/main/java/com/google/firebase/internal/SdkUtils.java b/src/main/java/com/google/firebase/internal/SdkUtils.java index 2fa5e5767..79f99c2fd 100644 --- a/src/main/java/com/google/firebase/internal/SdkUtils.java +++ b/src/main/java/com/google/firebase/internal/SdkUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,6 +39,14 @@ public static String getVersion() { return SDK_VERSION; } + public static String getJavaVersion() { + return System.getProperty("java.version"); + } + + public static String getMetricsHeader() { + return String.format("gl-java/%s fire-admin/%s", getJavaVersion(), getVersion()); + } + private static String loadSdkVersion() { try (InputStream in = SdkUtils.class.getClassLoader() .getResourceAsStream(ADMIN_SDK_PROPERTIES)) { diff --git a/src/main/java/com/google/firebase/internal/package-info.java b/src/main/java/com/google/firebase/internal/package-info.java index a64cc61be..f38373b2a 100644 --- a/src/main/java/com/google/firebase/internal/package-info.java +++ b/src/main/java/com/google/firebase/internal/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/messaging/AndroidConfig.java b/src/main/java/com/google/firebase/messaging/AndroidConfig.java index 065376a79..22f591680 100644 --- a/src/main/java/com/google/firebase/messaging/AndroidConfig.java +++ b/src/main/java/com/google/firebase/messaging/AndroidConfig.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,6 +49,18 @@ public class AndroidConfig { @Key("notification") private final AndroidNotification notification; + @Key("fcm_options") + private final AndroidFcmOptions fcmOptions; + + @Key("direct_boot_ok") + private final Boolean directBootOk; + + @Key("bandwidth_constrained_ok") + private final Boolean bandwidthConstrainedOk; + + @Key("restricted_satellite_ok") + private final Boolean restrictedSatelliteOk; + private AndroidConfig(Builder builder) { this.collapseKey = builder.collapseKey; if (builder.priority != null) { @@ -71,6 +83,10 @@ private AndroidConfig(Builder builder) { this.restrictedPackageName = builder.restrictedPackageName; this.data = builder.data.isEmpty() ? null : ImmutableMap.copyOf(builder.data); this.notification = builder.notification; + this.fcmOptions = builder.fcmOptions; + this.directBootOk = builder.directBootOk; + this.bandwidthConstrainedOk = builder.bandwidthConstrainedOk; + this.restrictedSatelliteOk = builder.restrictedSatelliteOk; } /** @@ -98,13 +114,20 @@ public static class Builder { private String restrictedPackageName; private final Map data = new HashMap<>(); private AndroidNotification notification; + private AndroidFcmOptions fcmOptions; + private Boolean directBootOk; + private Boolean bandwidthConstrainedOk; + private Boolean restrictedSatelliteOk; private Builder() {} /** - * Sets a collapse key for the message. Collapse key serves as an identifier for a group of + * Sets a collapse key for the message. The collapse key serves as an identifier for a group of * messages that can be collapsed, so that only the last message gets sent when delivery can be * resumed. A maximum of 4 different collapse keys may be active at any given time. + * + *

By default, the collapse key is the app package name registered in + * the Firebase console.

* * @param collapseKey A collapse key string. * @return This builder. @@ -187,6 +210,42 @@ public Builder setNotification(AndroidNotification notification) { return this; } + /** + * Sets the {@link AndroidFcmOptions}, which overrides values set in the {@link FcmOptions} + * for Android messages. + */ + public Builder setFcmOptions(AndroidFcmOptions androidFcmOptions) { + this.fcmOptions = androidFcmOptions; + return this; + } + + /** + * Sets the {@code direct_boot_ok} flag. If set to true, messages are delivered to + * the app while the device is in direct boot mode. + */ + public Builder setDirectBootOk(boolean directBootOk) { + this.directBootOk = directBootOk; + return this; + } + + /** + * Sets the {@code bandwidth_constrained_ok} flag. If set to true, messages will be allowed + * to be delivered to the app while the device is on a bandwidth constrained network. + */ + public Builder setBandwidthConstrainedOk(boolean bandwidthConstrainedOk) { + this.bandwidthConstrainedOk = bandwidthConstrainedOk; + return this; + } + + /** + * Sets the {@code restricted_satellite_ok} flag. If set to true, messages will be allowed + * to be delivered to the app while the device is on a restricted satellite network. + */ + public Builder setRestrictedSatelliteOk(boolean restrictedSatelliteOk) { + this.restrictedSatelliteOk = restrictedSatelliteOk; + return this; + } + /** * Creates a new {@link AndroidConfig} instance from the parameters set on this builder. * diff --git a/src/main/java/com/google/firebase/messaging/AndroidFcmOptions.java b/src/main/java/com/google/firebase/messaging/AndroidFcmOptions.java new file mode 100644 index 000000000..82c29603f --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/AndroidFcmOptions.java @@ -0,0 +1,77 @@ +/* + * 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.messaging; + +import com.google.api.client.util.Key; + +/** + * Represents the Android-specific FCM options that can be included in an {@link AndroidConfig}. + * Instances of this class are thread-safe and immutable. + */ +public final class AndroidFcmOptions { + + @Key("analytics_label") + private final String analyticsLabel; + + private AndroidFcmOptions(Builder builder) { + FcmOptionsUtil.checkAnalyticsLabel(builder.analyticsLabel); + this.analyticsLabel = builder.analyticsLabel; + } + + /** + * Creates a new {@link AndroidFcmOptions} object with the specified analytics label. + * + * @param analyticsLabel An analytics label + * @return An {@link AndroidFcmOptions} object with the analytics label set to the supplied value. + */ + public static AndroidFcmOptions withAnalyticsLabel(String analyticsLabel) { + return builder().setAnalyticsLabel(analyticsLabel).build(); + } + + /** + * Creates a new {@link AndroidFcmOptions.Builder}. + * + * @return A {@link AndroidFcmOptions.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String analyticsLabel; + + private Builder() {} + + /** + * @param analyticsLabel A string representing the analytics label used for Android messages. + * @return This builder + */ + public Builder setAnalyticsLabel(String analyticsLabel) { + this.analyticsLabel = analyticsLabel; + return this; + } + + /** + * Creates a new {@link AndroidFcmOptions} instance from the parameters set on this builder. + * + * @return A new {@link AndroidFcmOptions} instance. + * @throws IllegalArgumentException If any of the parameters set on the builder are invalid. + */ + public AndroidFcmOptions build() { + return new AndroidFcmOptions(this); + } + } +} diff --git a/src/main/java/com/google/firebase/messaging/AndroidNotification.java b/src/main/java/com/google/firebase/messaging/AndroidNotification.java index 27f315591..bcd5cc962 100644 --- a/src/main/java/com/google/firebase/messaging/AndroidNotification.java +++ b/src/main/java/com/google/firebase/messaging/AndroidNotification.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,9 +21,15 @@ import com.google.api.client.util.Key; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.firebase.internal.NonNull; +import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Date; import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import java.util.concurrent.TimeUnit; /** * Represents the Android-specific notification options that can be included in a {@link Message}. @@ -63,7 +69,61 @@ public class AndroidNotification { @Key("title_loc_args") private final List titleLocArgs; - + + @Key("channel_id") + private final String channelId; + + @Key("image") + private final String image; + + @Key("ticker") + private final String ticker; + + @Key("sticky") + private final Boolean sticky; + + @Key("event_time") + private final String eventTime; + + @Key("local_only") + private final Boolean localOnly; + + @Key("notification_priority") + private final String priority; + + @Key("vibrate_timings") + private final List vibrateTimings; + + @Key("default_vibrate_timings") + private final Boolean defaultVibrateTimings; + + @Key("default_sound") + private final Boolean defaultSound; + + @Key("light_settings") + private final LightSettings lightSettings; + + @Key("default_light_settings") + private final Boolean defaultLightSettings; + + @Key("visibility") + private final String visibility; + + @Key("notification_count") + private final Integer notificationCount; + + @Key("proxy") + private final String proxy; + + private static final Map PRIORITY_MAP = + ImmutableMap.builder() + .put(Priority.MIN, "PRIORITY_MIN") + .put(Priority.LOW, "PRIORITY_LOW") + .put(Priority.DEFAULT, "PRIORITY_DEFAULT") + .put(Priority.HIGH, "PRIORITY_HIGH") + .put(Priority.MAX, "PRIORITY_MAX") + .build(); + private AndroidNotification(Builder builder) { this.title = builder.title; this.body = builder.body; @@ -93,6 +153,66 @@ private AndroidNotification(Builder builder) { } else { this.titleLocArgs = null; } + this.channelId = builder.channelId; + this.image = builder.image; + this.ticker = builder.ticker; + this.sticky = builder.sticky; + this.eventTime = builder.eventTime; + this.localOnly = builder.localOnly; + if (builder.priority != null) { + this.priority = builder.priority.toString(); + } else { + this.priority = null; + } + if (!builder.vibrateTimings.isEmpty()) { + this.vibrateTimings = ImmutableList.copyOf(builder.vibrateTimings); + } else { + this.vibrateTimings = null; + } + this.defaultVibrateTimings = builder.defaultVibrateTimings; + this.defaultSound = builder.defaultSound; + this.lightSettings = builder.lightSettings; + this.defaultLightSettings = builder.defaultLightSettings; + if (builder.visibility != null) { + this.visibility = builder.visibility.name().toLowerCase(); + } else { + this.visibility = null; + } + if (builder.notificationCount != null) { + checkArgument(builder.notificationCount >= 0, + "notificationCount if specified must be zero or positive valued"); + } + if (builder.proxy != null) { + this.proxy = builder.proxy.name(); + } else { + this.proxy = null; + } + this.notificationCount = builder.notificationCount; + } + + public enum Priority { + MIN, + LOW, + DEFAULT, + HIGH, + MAX; + + @Override + public String toString() { + return PRIORITY_MAP.get(this); + } + } + + public enum Visibility { + PRIVATE, + PUBLIC, + SECRET, + } + + public enum Proxy { + ALLOW, + DENY, + IF_PRIORITY_LOWERED } /** @@ -117,6 +237,21 @@ public static class Builder { private List bodyLocArgs = new ArrayList<>(); private String titleLocKey; private List titleLocArgs = new ArrayList<>(); + private String channelId; + private String image; + private Integer notificationCount; + private String ticker; + private Boolean sticky; + private String eventTime; + private Boolean localOnly; + private Priority priority; + private List vibrateTimings = new ArrayList<>(); + private Boolean defaultVibrateTimings; + private Boolean defaultSound; + private LightSettings lightSettings; + private Boolean defaultLightSettings; + private Visibility visibility; + private Proxy proxy; private Builder() {} @@ -133,7 +268,7 @@ public Builder setTitle(String title) { } /** - * Sets the body of the Android notification. When provided, overrides the body sent + * Sets the body of the Android notification. When provided, overrides the body set * via {@link Notification}. * * @param body Body of the notification. @@ -273,6 +408,227 @@ public Builder addAllTitleLocalizationArgs(@NonNull List args) { return this; } + /** + * Sets the Android notification channel ID (new in Android O). The app must create a channel + * with this channel ID before any notification with this channel ID is received. If you + * don't send this channel ID in the request, or if the channel ID provided has not yet been + * created by the app, FCM uses the channel ID specified in the app manifest. + * + * @param channelId The notification's channel ID. + * @return This builder. + */ + public Builder setChannelId(String channelId) { + this.channelId = channelId; + return this; + } + + /** + * Sets the URL of the image that is going to be displayed in the notification. When provided, + * overrides the imageUrl set via {@link Notification}. + * + * @param imageUrl URL of the image that is going to be displayed in the notification. + * @return This builder. + */ + public Builder setImage(String imageUrl) { + this.image = imageUrl; + return this; + } + + /** + * Sets the "ticker" text, which is sent to accessibility services. Prior to API level 21 + * (Lollipop), sets the text that is displayed in the status bar when the notification + * first arrives. + * + * @param ticker Ticker name. + * @return This builder. + */ + public Builder setTicker(String ticker) { + this.ticker = ticker; + return this; + } + + /** + * Sets the sticky flag. When set to false or unset, the notification is automatically + * dismissed when the user clicks it in the panel. When set to true, the notification + * persists even when the user clicks it. + * + * @param sticky The sticky flag + * @return This builder. + */ + public Builder setSticky(boolean sticky) { + this.sticky = sticky; + return this; + } + + /** + * For notifications that inform users about events with an absolute time reference, sets + * the time that the event in the notification occurred in milliseconds. Notifications + * in the panel are sorted by this time. The time is formatted in RFC3339 UTC "Zulu" + * format, accurate to nanoseconds. Example: "2014-10-02T15:01:23.045123456Z". Note that + * since the time is in milliseconds, the last section of the time representation always + * has 6 leading zeros. + * + * @param eventTimeInMillis The event time in milliseconds + * @return This builder. + */ + public Builder setEventTimeInMillis(long eventTimeInMillis) { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS000000'Z'"); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + this.eventTime = dateFormat.format(new Date(eventTimeInMillis)); + return this; + } + + /** + * Sets whether or not this notification is relevant only to the current device. Some + * notifications can be bridged to other devices for remote display, such as a Wear + * OS watch. This hint can be set to recommend this notification not be bridged. + * + * @param localOnly The "local only" flag + * @return This builder. + */ + public Builder setLocalOnly(boolean localOnly) { + this.localOnly = localOnly; + return this; + } + + /** + * Sets the relative priority for this notification. Priority is an indication of how much of + * the user's attention should be consumed by this notification. Low-priority notifications + * may be hidden from the user in certain situations, while the user might be interrupted + * for a higher-priority notification. + * + * @param priority The priority value, one of the values in {MIN, LOW, DEFAULT, HIGH, MAX} + * @return This builder. + */ + public Builder setPriority(Priority priority) { + this.priority = priority; + return this; + } + + /** + * Sets a list of vibration timings in milliseconds in the array to use. The first value in the + * array indicates the duration to wait before turning the vibrator on. The next value + * indicates the duration to keep the vibrator on. Subsequent values alternate between + * duration to turn the vibrator off and to turn the vibrator on. If {@code vibrate_timings} + * is set and {@code default_vibrate_timings} is set to true, the default value is used instead + * of the user-specified {@code vibrate_timings}. + * A duration in seconds with up to nine fractional digits, terminated by 's'. Example: "3.5s". + * + * @param vibrateTimingsInMillis List of vibration time in milliseconds + * @return This builder. + */ + public Builder setVibrateTimingsInMillis(long[] vibrateTimingsInMillis) { + List list = new ArrayList<>(); + for (long value : vibrateTimingsInMillis) { + checkArgument(value >= 0, "elements in vibrateTimingsInMillis must not be negative"); + long seconds = TimeUnit.MILLISECONDS.toSeconds(value); + long subsecondNanos = TimeUnit.MILLISECONDS.toNanos(value - seconds * 1000L); + if (subsecondNanos > 0) { + list.add(String.format("%d.%09ds", seconds, subsecondNanos)); + } else { + list.add(String.format("%ds", seconds)); + } + } + this.vibrateTimings = ImmutableList.copyOf(list); + return this; + } + + /** + * Sets the whether to use the default vibration timings. If set to true, use the Android + * framework's default vibrate pattern for the notification. Default values are specified + * in {@code config.xml}. If {@code default_vibrate_timings} is set to true and + * {@code vibrate_timings} is also set, the default value is used instead of the + * user-specified {@code vibrate_timings}. + * + * @param defaultVibrateTimings The flag indicating whether to use the default vibration timings + * @return This builder. + */ + public Builder setDefaultVibrateTimings(boolean defaultVibrateTimings) { + this.defaultVibrateTimings = defaultVibrateTimings; + return this; + } + + /** + * Sets the whether to use the default sound. If set to true, use the Android framework's + * default sound for the notification. Default values are specified in config.xml. + * + * @param defaultSound The flag indicating whether to use the default sound + * @return This builder. + */ + public Builder setDefaultSound(boolean defaultSound) { + this.defaultSound = defaultSound; + return this; + } + + /** + * Sets the settings to control the notification's LED blinking rate and color if LED is + * available on the device. The total blinking time is controlled by the OS. + * + * @param lightSettings The light settings to use + * @return This builder. + */ + public Builder setLightSettings(LightSettings lightSettings) { + this.lightSettings = lightSettings; + return this; + } + + /** + * Sets the whether to use the default light settings. If set to true, use the Android + * framework's default LED light settings for the notification. Default values are + * specified in config.xml. If {@code default_light_settings} is set to true and + * {@code light_settings} is also set, the user-specified {@code light_settings} is used + * instead of the default value. + * + * @param defaultLightSettings The flag indicating whether to use the default light + * settings + * @return This builder. + */ + public Builder setDefaultLightSettings(boolean defaultLightSettings) { + this.defaultLightSettings = defaultLightSettings; + return this; + } + + /** + * Sets the visibility of this notification. + * + * @param visibility The visibility value. one of the values in {PRIVATE, PUBLIC, SECRET} + * @return This builder. + */ + public Builder setVisibility(Visibility visibility) { + this.visibility = visibility; + return this; + } + + /** + * Sets the number of items this notification represents. May be displayed as a badge + * count for launchers that support badging. + * If not invoked then notification count is left unchanged. + * For example, this might be useful if you're using just one notification to represent + * multiple new messages but you want the count here to represent the number of total + * new messages. If zero or unspecified, systems that support badging use the default, + * which is to increment a number displayed on + * the long-press menu each time a new notification arrives. + * + * @param notificationCount Zero or positive value. Zero indicates leave unchanged. + * @return This builder. + */ + public Builder setNotificationCount(int notificationCount) { + this.notificationCount = notificationCount; + return this; + } + + /** + * Sets the proxy of this notification. + * + * @param proxy The proxy value, one of the values in {ALLOW, DENY, IF_PRIORITY_LOWERED} + * + * @return This builder. + */ + public Builder setProxy(Proxy proxy) { + this.proxy = proxy; + return this; + } + /** * Creates a new {@link AndroidNotification} instance from the parameters set on this builder. * diff --git a/src/main/java/com/google/firebase/messaging/ApnsConfig.java b/src/main/java/com/google/firebase/messaging/ApnsConfig.java index 5e503c0ea..093baf3a4 100644 --- a/src/main/java/com/google/firebase/messaging/ApnsConfig.java +++ b/src/main/java/com/google/firebase/messaging/ApnsConfig.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,6 +38,12 @@ public class ApnsConfig { @Key("payload") private final Map payload; + @Key("fcm_options") + private final ApnsFcmOptions fcmOptions; + + @Key("live_activity_token") + private final String liveActivityToken; + private ApnsConfig(Builder builder) { checkArgument(builder.aps != null, "aps must be specified"); checkArgument(!builder.customData.containsKey("aps"), @@ -47,6 +53,8 @@ private ApnsConfig(Builder builder) { .putAll(builder.customData) .put("aps", builder.aps.getFields()) .build(); + this.fcmOptions = builder.fcmOptions; + this.liveActivityToken = builder.liveActivityToken; } /** @@ -63,6 +71,8 @@ public static class Builder { private final Map headers = new HashMap<>(); private final Map customData = new HashMap<>(); private Aps aps; + private ApnsFcmOptions fcmOptions; + private String liveActivityToken; private Builder() {} @@ -123,6 +133,26 @@ public Builder putAllCustomData(@NonNull Map map) { return this; } + /** + * Sets the {@link ApnsFcmOptions}, which will override values set in the {@link FcmOptions} for + * APNS messages. + */ + public Builder setFcmOptions(ApnsFcmOptions apnsFcmOptions) { + this.fcmOptions = apnsFcmOptions; + return this; + } + + /** + * Sets the Live Activity token. + * + * @param liveActivityToken Live Activity token. + * @return This builder. + */ + public Builder setLiveActivityToken(String liveActivityToken) { + this.liveActivityToken = liveActivityToken; + return this; + } + /** * Creates a new {@link ApnsConfig} instance from the parameters set on this builder. * diff --git a/src/main/java/com/google/firebase/messaging/ApnsFcmOptions.java b/src/main/java/com/google/firebase/messaging/ApnsFcmOptions.java new file mode 100644 index 000000000..46bc5702f --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/ApnsFcmOptions.java @@ -0,0 +1,92 @@ +/* + * 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.messaging; + +import com.google.api.client.util.Key; + +/** + * Represents the APNS-specific FCM options that can be included in an {@link ApnsConfig}. Instances + * of this class are thread-safe and immutable. + */ +public final class ApnsFcmOptions { + + @Key("analytics_label") + private final String analyticsLabel; + + @Key("image") + private final String image; + + private ApnsFcmOptions(Builder builder) { + FcmOptionsUtil.checkAnalyticsLabel(builder.analyticsLabel); + this.analyticsLabel = builder.analyticsLabel; + this.image = builder.image; + } + + /** + * Creates a new {@link ApnsFcmOptions} object with the specified analytics label. + * + * @param analyticsLabel An analytics label + * @return An {@link ApnsFcmOptions} object with the analytics label set to the supplied value. + */ + public static ApnsFcmOptions withAnalyticsLabel(String analyticsLabel) { + return builder().setAnalyticsLabel(analyticsLabel).build(); + } + + /** + * Creates a new {@link ApnsFcmOptions.Builder}. + * + * @return An {@link ApnsFcmOptions.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String analyticsLabel; + + private String image; + + private Builder() {} + + /** + * @param analyticsLabel A string representing the analytics label used for APNS messages. + * @return This builder + */ + public Builder setAnalyticsLabel(String analyticsLabel) { + this.analyticsLabel = analyticsLabel; + return this; + } + + /** + * @param imageUrl URL of the image that is going to be displayed in the notification. + * @return This builder + */ + public Builder setImage(String imageUrl) { + this.image = imageUrl; + return this; + } + + /** + * Creates a new {@link ApnsFcmOptions} instance from the parameters set on this builder. + * + * @return A new {@link ApnsFcmOptions} instance. + * @throws IllegalArgumentException If any of the parameters set on the builder are invalid. + */ + public ApnsFcmOptions build() { + return new ApnsFcmOptions(this); + } + } +} diff --git a/src/main/java/com/google/firebase/messaging/Aps.java b/src/main/java/com/google/firebase/messaging/Aps.java index 17912d1e3..6ee12a7a0 100644 --- a/src/main/java/com/google/firebase/messaging/Aps.java +++ b/src/main/java/com/google/firebase/messaging/Aps.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,6 +35,8 @@ public class Aps { private Aps(Builder builder) { checkArgument(Strings.isNullOrEmpty(builder.alertString) || (builder.alert == null), "Multiple alert specifications (string and ApsAlert) found."); + checkArgument(Strings.isNullOrEmpty(builder.sound) || (builder.criticalSound == null), + "Multiple sound specifications (sound and CriticalSound) found."); ImmutableMap.Builder fields = ImmutableMap.builder(); if (builder.alert != null) { fields.put("alert", builder.alert); @@ -44,8 +46,10 @@ private Aps(Builder builder) { if (builder.badge != null) { fields.put("badge", builder.badge); } - if (builder.sound != null) { + if (!Strings.isNullOrEmpty(builder.sound)) { fields.put("sound", builder.sound); + } else if (builder.criticalSound != null) { + fields.put("sound", builder.criticalSound.getFields()); } if (builder.contentAvailable) { fields.put("content-available", 1); @@ -82,6 +86,7 @@ public static class Builder { private ApsAlert alert; private Integer badge; private String sound; + private CriticalSound criticalSound; private boolean contentAvailable; private boolean mutableContent; private String category; @@ -125,7 +130,8 @@ public Builder setBadge(int badge) { } /** - * Sets the sound to be played with the message. + * Sets the sound to be played with the message. For critical alerts use the + * {@link #setSound(CriticalSound)} method. * * @param sound Sound file name or {@code "default"}. * @return This builder. @@ -135,6 +141,17 @@ public Builder setSound(String sound) { return this; } + /** + * Sets the critical alert sound to be played with the message. + * + * @param sound A {@link CriticalSound} instance containing the alert sound configuration. + * @return This builder. + */ + public Builder setSound(CriticalSound sound) { + this.criticalSound = sound; + return this; + } + /** * Specifies whether to configure a background update notification. * diff --git a/src/main/java/com/google/firebase/messaging/ApsAlert.java b/src/main/java/com/google/firebase/messaging/ApsAlert.java index 6f7e249f3..12b553b13 100644 --- a/src/main/java/com/google/firebase/messaging/ApsAlert.java +++ b/src/main/java/com/google/firebase/messaging/ApsAlert.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,6 +34,9 @@ public class ApsAlert { @Key("title") private final String title; + @Key("subtitle") + private final String subtitle; + @Key("body") private final String body; @@ -49,6 +52,12 @@ public class ApsAlert { @Key("title-loc-args") private final List titleLocArgs; + @Key("subtitle-loc-key") + private final String subtitleLocKey; + + @Key("subtitle-loc-args") + private final List subtitleLocArgs; + @Key("action-loc-key") private final String actionLocKey; @@ -57,6 +66,7 @@ public class ApsAlert { private ApsAlert(Builder builder) { this.title = builder.title; + this.subtitle = builder.subtitle; this.body = builder.body; this.actionLocKey = builder.actionLocKey; this.locKey = builder.locKey; @@ -76,6 +86,14 @@ private ApsAlert(Builder builder) { } else { this.titleLocArgs = null; } + this.subtitleLocKey = builder.subtitleLocKey; + if (!builder.subtitleLocArgs.isEmpty()) { + checkArgument(!Strings.isNullOrEmpty(builder.subtitleLocKey), + "subtitleLocKey is required when specifying subtitleLocArgs"); + this.subtitleLocArgs = ImmutableList.copyOf(builder.subtitleLocArgs); + } else { + this.subtitleLocArgs = null; + } this.launchImage = builder.launchImage; } @@ -91,11 +109,14 @@ public static Builder builder() { public static class Builder { private String title; + private String subtitle; private String body; private String locKey; private List locArgs = new ArrayList<>(); private String titleLocKey; private List titleLocArgs = new ArrayList<>(); + private String subtitleLocKey; + private List subtitleLocArgs = new ArrayList<>(); private String actionLocKey; private String launchImage; @@ -113,6 +134,17 @@ public Builder setTitle(String title) { return this; } + /** + * Sets the subtitle of the alert. + * + * @param subtitle Subtitle of the notification. + * @return This builder. + */ + public Builder setSubtitle(String subtitle) { + this.subtitle = subtitle; + return this; + } + /** * Sets the body of the alert. When provided, overrides the body sent * via {@link Notification}. @@ -209,6 +241,42 @@ public Builder addAllTitleLocArgs(@NonNull List args) { return this; } + /** + * Sets the key of the subtitle string in the app's string resources to use to localize + * the subtitle text. + * + * @param subtitleLocKey Resource key string. + * @return This builder. + */ + public Builder setSubtitleLocalizationKey(String subtitleLocKey) { + this.subtitleLocKey = subtitleLocKey; + return this; + } + + /** + * Adds a resource key string that will be used in place of the format specifiers in + * {@code subtitleLocKey}. + * + * @param arg Resource key string. + * @return This builder. + */ + public Builder addSubtitleLocalizationArg(@NonNull String arg) { + this.subtitleLocArgs.add(arg); + return this; + } + + /** + * Adds a list of resource keys that will be used in place of the format specifiers in + * {@code subtitleLocKey}. + * + * @param args List of resource key strings. + * @return This builder. + */ + public Builder addAllSubtitleLocArgs(@NonNull List args) { + this.subtitleLocArgs.addAll(args); + return this; + } + /** * Sets the launch image for the notification action. * diff --git a/src/main/java/com/google/firebase/messaging/BatchResponse.java b/src/main/java/com/google/firebase/messaging/BatchResponse.java new file mode 100644 index 000000000..09a99dbb3 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/BatchResponse.java @@ -0,0 +1,35 @@ +/* + * Copyright 2019 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.messaging; + +import com.google.firebase.internal.NonNull; +import java.util.List; + +/** + * Response from an operation that sends FCM messages to multiple recipients. + * See {@link FirebaseMessaging#sendAll(List)} and {@link + * FirebaseMessaging#sendMulticast(MulticastMessage)}. + */ +public interface BatchResponse { + + @NonNull + List getResponses(); + + int getSuccessCount(); + + int getFailureCount(); +} diff --git a/src/main/java/com/google/firebase/messaging/BatchResponseImpl.java b/src/main/java/com/google/firebase/messaging/BatchResponseImpl.java new file mode 100644 index 000000000..f10c43d25 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/BatchResponseImpl.java @@ -0,0 +1,58 @@ +/* + * Copyright 2020 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.messaging; + +import com.google.common.collect.ImmutableList; +import com.google.firebase.internal.NonNull; + +import java.util.List; + +/** + * Response from an operation that sends FCM messages to multiple recipients. + * See {@link FirebaseMessaging#sendAll(List)} and {@link + * FirebaseMessaging#sendMulticast(MulticastMessage)}. + */ +class BatchResponseImpl implements BatchResponse { + + private final List responses; + private final int successCount; + + BatchResponseImpl(List responses) { + this.responses = ImmutableList.copyOf(responses); + int successCount = 0; + for (SendResponse response : this.responses) { + if (response.isSuccessful()) { + successCount++; + } + } + this.successCount = successCount; + } + + @NonNull + public List getResponses() { + return responses; + } + + public int getSuccessCount() { + return successCount; + } + + public int getFailureCount() { + return responses.size() - successCount; + } + +} diff --git a/src/main/java/com/google/firebase/messaging/CriticalSound.java b/src/main/java/com/google/firebase/messaging/CriticalSound.java new file mode 100644 index 000000000..73d489193 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/CriticalSound.java @@ -0,0 +1,115 @@ +/* + * Copyright 2018 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.messaging; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import java.util.Map; + +/** + * The sound configuration for APNs critical alerts. + */ +public final class CriticalSound { + + private final Map fields; + + private CriticalSound(Builder builder) { + checkArgument(!Strings.isNullOrEmpty(builder.name), "name must not be null or empty"); + ImmutableMap.Builder fields = ImmutableMap.builder() + .put("name", builder.name); + if (builder.critical) { + fields.put("critical", 1); + } + if (builder.volume != null) { + checkArgument(builder.volume >= 0 && builder.volume <= 1, + "volume must be in the interval [0,1]"); + fields.put("volume", builder.volume); + } + this.fields = fields.build(); + } + + Map getFields() { + return fields; + } + + /** + * Creates a new {@link CriticalSound.Builder}. + * + * @return A {@link CriticalSound.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private boolean critical; + private String name; + private Double volume; + + private Builder() { + } + + /** + * Sets the critical alert flag on the sound configuration. + * + * @param critical True to set the critical alert flag. + * @return This builder. + */ + public Builder setCritical(boolean critical) { + this.critical = critical; + return this; + } + + /** + * The name of a sound file in your app's main bundle or in the {@code Library/Sounds} folder + * of your app’s container directory. Specify the string {@code default} to play the system + * sound. + * + * @param name Sound file name. + * @return This builder. + */ + public Builder setName(String name) { + this.name = name; + return this; + } + + /** + * The volume for the critical alert's sound. Must be a value between 0.0 (silent) and 1.0 + * (full volume). + * + * @param volume A volume between 0.0 (inclusive) and 1.0 (inclusive). + * @return This builder. + */ + public Builder setVolume(double volume) { + this.volume = volume; + return this; + } + + /** + * Builds a new {@link CriticalSound} instance from the fields set on this builder. + * + * @return A non-null {@link CriticalSound}. + * @throws IllegalArgumentException If the volume value is out of range. + */ + public CriticalSound build() { + return new CriticalSound(this); + } + } +} diff --git a/src/main/java/com/google/firebase/messaging/FcmOptions.java b/src/main/java/com/google/firebase/messaging/FcmOptions.java new file mode 100644 index 000000000..1d32b9c51 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/FcmOptions.java @@ -0,0 +1,78 @@ +/* + * 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.messaging; + +import com.google.api.client.util.Key; + +/** + * Represents the platform-independent FCM options that can be included in a {@link Message}. + * Instances of this class are thread-safe and immutable. + */ +public final class FcmOptions { + + @Key("analytics_label") + private final String analyticsLabel; + + private FcmOptions(Builder builder) { + FcmOptionsUtil.checkAnalyticsLabel(builder.analyticsLabel); + this.analyticsLabel = builder.analyticsLabel; + } + + /** + * Creates a new {@link FcmOptions} object with the specified analytics label. + * + * @param analyticsLabel An analytics label + * @return An {@link FcmOptions} object with the analytics label set to the supplied value. + */ + public static FcmOptions withAnalyticsLabel(String analyticsLabel) { + return new Builder().setAnalyticsLabel(analyticsLabel).build(); + } + + /** + * Creates a new {@link FcmOptions.Builder}. + * + * @return An {@link FcmOptions.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String analyticsLabel; + + private Builder() {} + + /** + * @param analyticsLabel A string representing the analytics label used for messages where no + * platform-specific analytics label has been specified. + * @return This builder + */ + public Builder setAnalyticsLabel(String analyticsLabel) { + this.analyticsLabel = analyticsLabel; + return this; + } + + /** + * Creates a new {@link FcmOptions} instance from the parameters set on this builder. + * + * @return A new {@link FcmOptions} instance. + * @throws IllegalArgumentException If any of the parameters set on the builder are invalid. + */ + public FcmOptions build() { + return new FcmOptions(this); + } + } +} diff --git a/src/main/java/com/google/firebase/messaging/FcmOptionsUtil.java b/src/main/java/com/google/firebase/messaging/FcmOptionsUtil.java new file mode 100644 index 000000000..b16b2b8ed --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/FcmOptionsUtil.java @@ -0,0 +1,45 @@ +/* + * 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.messaging; + +import static com.google.common.base.Preconditions.checkArgument; + +import java.util.regex.Pattern; + +final class FcmOptionsUtil { + + /** + * Pattern matching a valid analytics labels. + */ + private static final Pattern ANALYTICS_LABEL_REGEX = Pattern.compile("^[a-zA-Z0-9-_.~%]{0,50}$"); + + /** + * Returns false if the supplied {@code analyticsLabel} has a disallowed format. + */ + private static boolean isValid(String analyticsLabel) { + return ANALYTICS_LABEL_REGEX.matcher(analyticsLabel).matches(); + } + + /** + * Validates the format of the supplied label. + * + * @throws IllegalArgumentException If the label is non-null and has a disallowed format. + */ + static void checkAnalyticsLabel(String analyticsLabel) { + checkArgument( + analyticsLabel == null || isValid(analyticsLabel), + "Analytics label must have format matching'^[a-zA-Z0-9-_.~%]{1,50}$"); + } +} diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java index 7ba1edd4d..b38e8d827 100644 --- a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java +++ b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,33 +18,25 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; -import static com.google.common.base.Preconditions.checkState; - -import com.google.api.client.http.GenericUrl; -import com.google.api.client.http.HttpRequest; -import com.google.api.client.http.HttpRequestFactory; -import com.google.api.client.http.HttpResponse; -import com.google.api.client.http.HttpResponseException; -import com.google.api.client.http.HttpResponseInterceptor; -import com.google.api.client.http.HttpTransport; -import com.google.api.client.http.json.JsonHttpContent; -import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.JsonObjectParser; -import com.google.api.client.json.JsonParser; -import com.google.api.client.util.Key; + import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; -import com.google.common.collect.ImmutableMap; +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.firebase.ErrorCode; import com.google.firebase.FirebaseApp; import com.google.firebase.ImplFirebaseTrampolines; import com.google.firebase.internal.CallableOperation; -import com.google.firebase.internal.FirebaseRequestInitializer; import com.google.firebase.internal.FirebaseService; import com.google.firebase.internal.NonNull; -import java.io.IOException; + +import java.util.ArrayList; import java.util.List; -import java.util.Map; +import java.util.concurrent.ExecutionException; /** * This class is the entry point for all server-side Firebase Cloud Messaging actions. @@ -54,60 +46,14 @@ */ public class FirebaseMessaging { - private static final String FCM_URL = "https://fcm.googleapis.com/v1/projects/%s/messages:send"; - private static final String FCM_ERROR_TYPE = - "type.googleapis.com/google.firebase.fcm.v1.FcmErrorCode"; - - private static final String INTERNAL_ERROR = "internal-error"; - private static final String UNKNOWN_ERROR = "unknown-error"; - private static final Map FCM_ERROR_CODES = - ImmutableMap.builder() - // FCM v1 canonical error codes - .put("NOT_FOUND", "registration-token-not-registered") - .put("PERMISSION_DENIED", "mismatched-credential") - .put("RESOURCE_EXHAUSTED", "message-rate-exceeded") - .put("UNAUTHENTICATED", "invalid-apns-credentials") - - // FCM v1 new error codes - .put("APNS_AUTH_ERROR", "invalid-apns-credentials") - .put("INTERNAL", INTERNAL_ERROR) - .put("INVALID_ARGUMENT", "invalid-argument") - .put("QUOTA_EXCEEDED", "message-rate-exceeded") - .put("SENDER_ID_MISMATCH", "mismatched-credential") - .put("UNAVAILABLE", "server-unavailable") - .put("UNREGISTERED", "registration-token-not-registered") - .build(); - static final Map IID_ERROR_CODES = - ImmutableMap.builder() - .put(400, "invalid-argument") - .put(401, "authentication-error") - .put(403, "authentication-error") - .put(500, INTERNAL_ERROR) - .put(503, "server-unavailable") - .build(); - - private static final String IID_HOST = "https://iid.googleapis.com"; - private static final String IID_SUBSCRIBE_PATH = "iid/v1:batchAdd"; - private static final String IID_UNSUBSCRIBE_PATH = "iid/v1:batchRemove"; - private final FirebaseApp app; - private final HttpRequestFactory requestFactory; - private final JsonFactory jsonFactory; - private final String url; + private final Supplier messagingClient; + private final Supplier instanceIdClient; - private HttpResponseInterceptor interceptor; - - private FirebaseMessaging(FirebaseApp app) { - HttpTransport httpTransport = app.getOptions().getHttpTransport(); - this.app = app; - this.requestFactory = httpTransport.createRequestFactory(new FirebaseRequestInitializer(app)); - this.jsonFactory = app.getOptions().getJsonFactory(); - String projectId = ImplFirebaseTrampolines.getProjectId(app); - checkArgument(!Strings.isNullOrEmpty(projectId), - "Project ID is required to access messaging service. Use a service account credential or " - + "set the project ID explicitly via FirebaseOptions. Alternatively you can also " - + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable."); - this.url = String.format(FCM_URL, projectId); + private FirebaseMessaging(Builder builder) { + this.app = checkNotNull(builder.firebaseApp); + this.messagingClient = Suppliers.memoize(builder.messagingClient); + this.instanceIdClient = Suppliers.memoize(builder.instanceIdClient); } /** @@ -138,6 +84,8 @@ public static synchronized FirebaseMessaging getInstance(FirebaseApp app) { * * @param message A non-null {@link Message} to be sent. * @return A message ID string. + * @throws FirebaseMessagingException If an error occurs while handing the message off to FCM for + * delivery. */ public String send(@NonNull Message message) throws FirebaseMessagingException { return send(message, false); @@ -147,11 +95,15 @@ public String send(@NonNull Message message) throws FirebaseMessagingException { * Sends the given {@link Message} via Firebase Cloud Messaging. * *

If the {@code dryRun} option is set to true, the message will not be actually sent. Instead - * FCM performs all the necessary validations, and emulates the send operation. + * FCM performs all the necessary validations and emulates the send operation. The {@code dryRun} + * option is useful for determining whether an FCM registration has been deleted. However, it + * cannot be used to validate APNs tokens. * * @param message A non-null {@link Message} to be sent. * @param dryRun a boolean indicating whether to perform a dry run (validation only) of the send. * @return A message ID string. + * @throws FirebaseMessagingException If an error occurs while handing the message off to FCM for + * delivery. */ public String send(@NonNull Message message, boolean dryRun) throws FirebaseMessagingException { return sendOp(message, dryRun).call(); @@ -180,6 +132,401 @@ public ApiFuture sendAsync(@NonNull Message message, boolean dryRun) { return sendOp(message, dryRun).callAsync(app); } + private CallableOperation sendOp( + final Message message, final boolean dryRun) { + checkNotNull(message, "message must not be null"); + final FirebaseMessagingClient messagingClient = getMessagingClient(); + return new CallableOperation() { + @Override + protected String execute() throws FirebaseMessagingException { + return messagingClient.send(message, dryRun); + } + }; + } + + /** + * Sends each message in the given list via Firebase Cloud Messaging. + * Unlike {@link #sendAll(List)}, this method makes an HTTP call for each message in the + * given array. + * + *

The list of responses obtained by calling {@link BatchResponse#getResponses()} on the return + * value is in the same order as the input list. + * + * @param messages A non-null, non-empty list containing up to 500 messages. + * @return A {@link BatchResponse} indicating the result of the operation. + * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for + * delivery. An exception here or a {@link BatchResponse} with all failures indicates a total + * failure, meaning that none of the messages in the list could be sent. Partial failures or + * no failures are only indicated by a {@link BatchResponse}. + */ + public BatchResponse sendEach(@NonNull List messages) throws FirebaseMessagingException { + return sendEach(messages, false); + } + + + /** + * Sends each message in the given list via Firebase Cloud Messaging. + * Unlike {@link #sendAll(List)}, this method makes an HTTP call for each message in the + * given array. + * + *

If the {@code dryRun} option is set to true, the message will not be actually sent. Instead + * FCM performs all the necessary validations, and emulates the send operation. The {@code dryRun} + * option is useful for determining whether an FCM registration has been deleted. But it cannot be + * used to validate APNs tokens. + * + *

The list of responses obtained by calling {@link BatchResponse#getResponses()} on the return + * value is in the same order as the input list. + * + * @param messages A non-null, non-empty list containing up to 500 messages. + * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. + * @return A {@link BatchResponse} indicating the result of the operation. + * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for + * delivery. An exception here or a {@link BatchResponse} with all failures indicates a total + * failure, meaning that none of the messages in the list could be sent. Partial failures or + * no failures are only indicated by a {@link BatchResponse}. + */ + public BatchResponse sendEach( + @NonNull List messages, boolean dryRun) throws FirebaseMessagingException { + try { + return sendEachOpAsync(messages, dryRun).get(); + } catch (InterruptedException | ExecutionException e) { + throw new FirebaseMessagingException(ErrorCode.CANCELLED, SERVICE_ID); + } + } + + /** + * Similar to {@link #sendEach(List)} but performs the operation asynchronously. + * + * @param messages A non-null, non-empty list containing up to 500 messages. + * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when + * the messages have been sent. + */ + public ApiFuture sendEachAsync(@NonNull List messages) { + return sendEachOpAsync(messages, false); + } + + /** + * Similar to {@link #sendEach(List, boolean)} but performs the operation asynchronously. + * + * @param messages A non-null, non-empty list containing up to 500 messages. + * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. + * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when + * the messages have been sent. + */ + public ApiFuture sendEachAsync(@NonNull List messages, boolean dryRun) { + return sendEachOpAsync(messages, dryRun); + } + + // Returns an ApiFuture directly since this function is non-blocking. Individual child send + // requests are still called async and run in background threads. + private ApiFuture sendEachOpAsync( + final List messages, final boolean dryRun) { + final List immutableMessages = ImmutableList.copyOf(messages); + checkArgument(!immutableMessages.isEmpty(), "messages list must not be empty"); + checkArgument(immutableMessages.size() <= 500, + "messages list must not contain more than 500 elements"); + + List> list = new ArrayList<>(); + for (Message message : immutableMessages) { + // Make async send calls per message + ApiFuture messageId = sendOpForSendResponse(message, dryRun).callAsync(app); + list.add(messageId); + } + + // Gather all futures and combine into a list + ApiFuture> responsesFuture = ApiFutures.allAsList(list); + + // Chain this future to wrap the eventual responses in a BatchResponse without blocking + // the main thread. This uses the current thread to execute, but since the transformation + // function is non-blocking the transformation itself is also non-blocking. + return ApiFutures.transform( + responsesFuture, + (responses) -> { + return new BatchResponseImpl(responses); + }, + MoreExecutors.directExecutor()); + } + + private CallableOperation sendOpForSendResponse( + final Message message, final boolean dryRun) { + checkNotNull(message, "message must not be null"); + final FirebaseMessagingClient messagingClient = getMessagingClient(); + return new CallableOperation() { + @Override + protected SendResponse execute() { + try { + String messageId = messagingClient.send(message, dryRun); + return SendResponse.fromMessageId(messageId); + } catch (FirebaseMessagingException e) { + return SendResponse.fromException(e); + } + } + }; + } + + /** + * Sends the given multicast message to all the FCM registration tokens and/or FIDs + * specified in it. + * + *

This method uses the {@link #sendEach(List)} API under the hood to send the given + * message to all the target recipients. The list of responses obtained by calling + * {@link BatchResponse#getResponses()} on the return value is in the same order as the + * tokens and/or FIDs in the {@link MulticastMessage}. If both tokens and FIDs are + * provided, tokens are processed first, followed by FIDs. + * + * @param message A non-null {@link MulticastMessage} + * @return A {@link BatchResponse} indicating the result of the operation. + * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for + * delivery. An exception here or a {@link BatchResponse} with all failures indicates a total + * failure, meaning that none of the messages in the list could be sent. Partial failures or + * no failures are only indicated by a {@link BatchResponse}. + */ + public BatchResponse sendEachForMulticast( + @NonNull MulticastMessage message) throws FirebaseMessagingException { + return sendEachForMulticast(message, false); + } + + /** + * Sends the given multicast message to all the FCM registration tokens and/or FIDs + * specified in it. + * + *

If the {@code dryRun} option is set to true, the message will not be actually sent. + * Instead FCM performs all the necessary validations, and emulates the send operation. + * The {@code dryRun} option is useful for determining whether an FCM registration has + * been deleted. But it cannot be used to validate APNs tokens. + * + *

This method uses the {@link #sendEach(List)} API under the hood to send the given + * message to all the target recipients. The list of responses obtained by calling + * {@link BatchResponse#getResponses()} on the return value is in the same order as the + * tokens and/or FIDs in the {@link MulticastMessage}. If both tokens and FIDs are + * provided, tokens are processed first, followed by FIDs. + * + * @param message A non-null {@link MulticastMessage}. + * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. + * @return A {@link BatchResponse} indicating the result of the operation. + * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for + * delivery. An exception here or a {@link BatchResponse} with all failures indicates a total + * failure, meaning that none of the messages in the list could be sent. Partial failures or + * no failures are only indicated by a {@link BatchResponse}. + */ + public BatchResponse sendEachForMulticast(@NonNull MulticastMessage message, boolean dryRun) + throws FirebaseMessagingException { + checkNotNull(message, "multicast message must not be null"); + return sendEach(message.getMessageList(), dryRun); + } + + /** + * Similar to {@link #sendEachForMulticast(MulticastMessage)} but performs the operation + * asynchronously. + * + * @param message A non-null {@link MulticastMessage}. + * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when + * the messages have been sent. + */ + public ApiFuture sendEachForMulticastAsync(@NonNull MulticastMessage message) { + return sendEachForMulticastAsync(message, false); + } + + /** + * Similar to {@link #sendEachForMulticast(MulticastMessage, boolean)} but performs the operation + * asynchronously. + * + * @param message A non-null {@link MulticastMessage}. + * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. + * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when + * the messages have been sent. + */ + public ApiFuture sendEachForMulticastAsync( + @NonNull MulticastMessage message, boolean dryRun) { + checkNotNull(message, "multicast message must not be null"); + return sendEachAsync(message.getMessageList(), dryRun); + } + + /** + * Sends all the messages in the given list via Firebase Cloud Messaging. Employs batching to + * send the entire list as a single RPC call. Compared to the {@link #send(Message)} method, this + * is a significantly more efficient way to send multiple messages. + * + *

The responses list obtained by calling {@link BatchResponse#getResponses()} on the return + * value corresponds to the order of input messages. + * + * @param messages A non-null, non-empty list containing up to 500 messages. + * @return A {@link BatchResponse} indicating the result of the operation. + * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for + * delivery. An exception here indicates a total failure, meaning that none of the messages in + * the list could be sent. Partial failures are indicated by a {@link BatchResponse} return + * value. + * @deprecated Use {@link #sendEach(List)} instead. + */ + @Deprecated + public BatchResponse sendAll( + @NonNull List messages) throws FirebaseMessagingException { + return sendAll(messages, false); + } + + /** + * Sends all the messages in the given list via Firebase Cloud Messaging. Employs batching to + * send the entire list as a single RPC call. Compared to the {@link #send(Message)} method, this + * is a significantly more efficient way to send multiple messages. + * + *

If the {@code dryRun} option is set to true, the message will not be actually sent. Instead + * FCM performs all the necessary validations, and emulates the send operation. The {@code dryRun} + * option is useful for determining whether an FCM registration has been deleted. But it cannot be + * used to validate APNs tokens. + * + *

The responses list obtained by calling {@link BatchResponse#getResponses()} on the return + * value corresponds to the order of input messages. + * + * @param messages A non-null, non-empty list containing up to 500 messages. + * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. + * @return A {@link BatchResponse} indicating the result of the operation. + * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for + * delivery. An exception here indicates a total failure, meaning that none of the messages in + * the list could be sent. Partial failures are indicated by a {@link BatchResponse} return + * value. + * @deprecated Use {@link #sendEach(List, boolean)} instead. + */ + @Deprecated + public BatchResponse sendAll( + @NonNull List messages, boolean dryRun) throws FirebaseMessagingException { + return sendAllOp(messages, dryRun).call(); + } + + /** + * Similar to {@link #sendAll(List)} but performs the operation asynchronously. + * + * @param messages A non-null, non-empty list containing up to 500 messages. + * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when + * the messages have been sent. + * @deprecated Use {@link #sendEachAsync(List)} instead. + */ + @Deprecated + public ApiFuture sendAllAsync(@NonNull List messages) { + return sendAllAsync(messages, false); + } + + /** + * Similar to {@link #sendAll(List, boolean)} but performs the operation asynchronously. + * + * @param messages A non-null, non-empty list containing up to 500 messages. + * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. + * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when + * the messages have been sent, or when the emulation has finished. + * @deprecated Use {@link #sendEachAsync(List, boolean)} instead. + */ + @Deprecated + public ApiFuture sendAllAsync( + @NonNull List messages, boolean dryRun) { + return sendAllOp(messages, dryRun).callAsync(app); + } + + /** + * Sends the given multicast message to all the FCM registration tokens and/or FIDs + * specified in it. + * + *

This method uses the {@link #sendAll(List)} API under the hood to send the given + * message to all the target recipients. The responses list obtained by calling + * {@link BatchResponse#getResponses()} on the return value corresponds to the order of + * tokens and/or FIDs in the {@link MulticastMessage}. If both tokens and FIDs are + * provided, tokens are processed first, followed by FIDs. + * + * @param message A non-null {@link MulticastMessage} + * @return A {@link BatchResponse} indicating the result of the operation. + * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for + * delivery. An exception here indicates a total failure, meaning that the messages could not + * be delivered to any recipient. Partial failures are indicated by a {@link BatchResponse} + * return value. + * @deprecated Use {@link #sendEachForMulticast(MulticastMessage)} instead. + */ + @Deprecated + public BatchResponse sendMulticast( + @NonNull MulticastMessage message) throws FirebaseMessagingException { + return sendMulticast(message, false); + } + + /** + * Sends the given multicast message to all the FCM registration tokens and/or FIDs + * specified in it. + * + *

If the {@code dryRun} option is set to true, the message will not be actually sent. + * Instead FCM performs all the necessary validations, and emulates the send operation. + * The {@code dryRun} option is useful for determining whether an FCM registration has + * been deleted. But it cannot be used to validate APNs tokens. + * + *

This method uses the {@link #sendAll(List)} API under the hood to send the given + * message to all the target recipients. The responses list obtained by calling + * {@link BatchResponse#getResponses()} on the return value corresponds to the order of + * tokens and/or FIDs in the {@link MulticastMessage}. If both tokens and FIDs are + * provided, tokens are processed first, followed by FIDs. + * + * @param message A non-null {@link MulticastMessage}. + * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. + * @return A {@link BatchResponse} indicating the result of the operation. + * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for + * delivery. An exception here indicates a total failure, meaning that the messages could not + * be delivered to any recipient. Partial failures are indicated by a {@link BatchResponse} + * return value. + * @deprecated Use {@link #sendEachForMulticast(MulticastMessage, boolean)} instead. + */ + @Deprecated + public BatchResponse sendMulticast( + @NonNull MulticastMessage message, boolean dryRun) throws FirebaseMessagingException { + checkNotNull(message, "multicast message must not be null"); + return sendAll(message.getMessageList(), dryRun); + } + + /** + * Similar to {@link #sendMulticast(MulticastMessage)} but performs the operation + * asynchronously. + * + * @param message A non-null {@link MulticastMessage}. + * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when + * the messages have been sent. + * @deprecated Use {@link #sendEachForMulticastAsync(MulticastMessage)} instead. + */ + @Deprecated + public ApiFuture sendMulticastAsync(@NonNull MulticastMessage message) { + return sendMulticastAsync(message, false); + } + + /** + * Similar to {@link #sendMulticast(MulticastMessage, boolean)} but performs the operation + * asynchronously. + * + * @param message A non-null {@link MulticastMessage}. + * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. + * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when + * the messages have been sent. + * @deprecated Use {@link #sendEachForMulticastAsync(MulticastMessage, boolean)} instead. + */ + @Deprecated + public ApiFuture sendMulticastAsync( + @NonNull MulticastMessage message, boolean dryRun) { + checkNotNull(message, "multicast message must not be null"); + return sendAllAsync(message.getMessageList(), dryRun); + } + + private CallableOperation sendAllOp( + final List messages, final boolean dryRun) { + + final List immutableMessages = ImmutableList.copyOf(messages); + checkArgument(!immutableMessages.isEmpty(), "messages list must not be empty"); + checkArgument(immutableMessages.size() <= 500, + "messages list must not contain more than 500 elements"); + final FirebaseMessagingClient messagingClient = getMessagingClient(); + return new CallableOperation() { + @Override + protected BatchResponse execute() throws FirebaseMessagingException { + return messagingClient.sendAll(messages, dryRun); + } + }; + } + + @VisibleForTesting + FirebaseMessagingClient getMessagingClient() { + return messagingClient.get(); + } + /** * Subscribes a list of registration tokens to a topic. * @@ -190,7 +537,11 @@ public ApiFuture sendAsync(@NonNull Message message, boolean dryRun) { */ public TopicManagementResponse subscribeToTopic(@NonNull List registrationTokens, @NonNull String topic) throws FirebaseMessagingException { - return manageTopicOp(registrationTokens, topic, IID_SUBSCRIBE_PATH).call(); + try { + return subscribeToTopicAsync(registrationTokens, topic).get(); + } catch (InterruptedException | ExecutionException e) { + throw new FirebaseMessagingException(ErrorCode.CANCELLED, SERVICE_ID); + } } /** @@ -203,11 +554,47 @@ public TopicManagementResponse subscribeToTopic(@NonNull List registrati */ public ApiFuture subscribeToTopicAsync( @NonNull List registrationTokens, @NonNull String topic) { - return manageTopicOp(registrationTokens, topic, IID_SUBSCRIBE_PATH).callAsync(app); + return manageTopicAsync(registrationTokens, topic, true); + } + + /** + * Subscribes a list of registration tokens to a topic using the legacy Instance ID API. + * + * @deprecated Use {@link #subscribeToTopic(List, String)} instead. + */ + @Deprecated + public TopicManagementResponse subscribeToTopicLegacy(@NonNull List registrationTokens, + @NonNull String topic) throws FirebaseMessagingException { + return subscribeLegacyOp(registrationTokens, topic).call(); + } + + /** + * Similar to {@link #subscribeToTopicLegacy(List, String)} but performs the operation + * asynchronously. + * + * @deprecated Use {@link #subscribeToTopicAsync(List, String)} instead. + */ + @Deprecated + public ApiFuture subscribeToTopicLegacyAsync( + @NonNull List registrationTokens, @NonNull String topic) { + return subscribeLegacyOp(registrationTokens, topic).callAsync(app); + } + + private CallableOperation subscribeLegacyOp( + final List registrationTokens, final String topic) { + checkRegistrationTokens(registrationTokens); + checkTopic(topic); + final InstanceIdClient instanceIdClient = getInstanceIdClient(); + return new CallableOperation() { + @Override + protected TopicManagementResponse execute() throws FirebaseMessagingException { + return instanceIdClient.subscribeToTopic(topic, registrationTokens); + } + }; } /** - * Unubscribes a list of registration tokens from a topic. + * Unsubscribes a list of registration tokens from a topic. * * @param registrationTokens A non-null, non-empty list of device registration tokens, with at * most 1000 entries. @@ -216,7 +603,11 @@ public ApiFuture subscribeToTopicAsync( */ public TopicManagementResponse unsubscribeFromTopic(@NonNull List registrationTokens, @NonNull String topic) throws FirebaseMessagingException { - return manageTopicOp(registrationTokens, topic, IID_UNSUBSCRIBE_PATH).call(); + try { + return unsubscribeFromTopicAsync(registrationTokens, topic).get(); + } catch (InterruptedException | ExecutionException e) { + throw new FirebaseMessagingException(ErrorCode.CANCELLED, SERVICE_ID); + } } /** @@ -230,146 +621,172 @@ public TopicManagementResponse unsubscribeFromTopic(@NonNull List regist */ public ApiFuture unsubscribeFromTopicAsync( @NonNull List registrationTokens, @NonNull String topic) { - return manageTopicOp(registrationTokens, topic, IID_UNSUBSCRIBE_PATH) - .callAsync(app); + return manageTopicAsync(registrationTokens, topic, false); } - private CallableOperation sendOp( - final Message message, final boolean dryRun) { - checkNotNull(message, "message must not be null"); - return new CallableOperation() { + private ApiFuture manageTopicAsync( + final List registrationTokens, final String topic, final boolean isSubscribe) { + checkRegistrationTokens(registrationTokens); + checkTopic(topic); + final String cleanTopic = topic.startsWith("/topics/") + ? topic.substring("/topics/".length()) : topic; + final List immutableTokens = ImmutableList.copyOf(registrationTokens); + + List> futures = new ArrayList<>(immutableTokens.size()); + for (int i = 0; i < immutableTokens.size(); i++) { + futures.add( + manageTopicSingleOp(immutableTokens.get(i), cleanTopic, isSubscribe, i) + .callAsync(app)); + } + + ApiFuture> resultsFuture = ApiFutures.allAsList(futures); + return ApiFutures.transform( + resultsFuture, + (results) -> { + int successCount = 0; + List errors = new ArrayList<>(); + for (TopicResult result : results) { + if (result.isSuccess()) { + successCount++; + } else { + errors.add(new TopicManagementResponse.Error( + result.getIndex(), result.getReason())); + } + } + return new TopicManagementResponse(successCount, errors); + }, + MoreExecutors.directExecutor()); + } + + private CallableOperation manageTopicSingleOp( + final String token, final String topic, final boolean isSubscribe, final int index) { + final FirebaseMessagingClient messagingClient = getMessagingClient(); + return new CallableOperation() { @Override - protected String execute() throws FirebaseMessagingException { - ImmutableMap.Builder payload = ImmutableMap.builder() - .put("message", message); - if (dryRun) { - payload.put("validate_only", true); - } - HttpResponse response = null; + protected TopicResult execute() { try { - HttpRequest request = requestFactory.buildPostRequest( - new GenericUrl(url), new JsonHttpContent(jsonFactory, payload.build())); - request.setParser(new JsonObjectParser(jsonFactory)); - request.setResponseInterceptor(interceptor); - response = request.execute(); - MessagingServiceResponse parsed = new MessagingServiceResponse(); - jsonFactory.createJsonParser(response.getContent()).parseAndClose(parsed); - return parsed.name; - } catch (HttpResponseException e) { - handleSendHttpError(e); - return null; - } catch (IOException e) { - throw new FirebaseMessagingException( - INTERNAL_ERROR, "Error while calling FCM backend service", e); - } finally { - disconnectQuietly(response); + if (isSubscribe) { + messagingClient.subscribeToTopic(topic, token); + } else { + messagingClient.unsubscribeFromTopic(topic, token); + } + return TopicResult.success(index); + } catch (FirebaseMessagingException e) { + return TopicResult.error(index, extractReason(e)); + } catch (Exception e) { + return TopicResult.error(index, "UNKNOWN_ERROR"); } } }; } - private void handleSendHttpError(HttpResponseException e) throws FirebaseMessagingException { - MessagingServiceErrorResponse response = new MessagingServiceErrorResponse(); - if (e.getContent() != null) { - try { - JsonParser parser = jsonFactory.createJsonParser(e.getContent()); - parser.parseAndClose(response); - } catch (IOException ignored) { - // ignored + @VisibleForTesting + static String extractReason(FirebaseMessagingException e) { + if (e.getMessagingErrorCode() != null) { + return e.getMessagingErrorCode().name(); + } + if (e.getErrorCode() != null && e.getErrorCode() != ErrorCode.UNKNOWN) { + return e.getErrorCode().name(); + } + if (e.getHttpResponse() != null) { + switch (e.getHttpResponse().getStatusCode()) { + case 400: + return "INVALID_ARGUMENT"; + case 401: + case 403: + return "PERMISSION_DENIED"; + case 404: + return "NOT_FOUND"; + case 429: + return "RESOURCE_EXHAUSTED"; + case 500: + return "INTERNAL"; + case 503: + return "UNAVAILABLE"; + case 504: + return "DEADLINE_EXCEEDED"; + default: + return "UNKNOWN_ERROR"; } } - String code = FCM_ERROR_CODES.get(response.getErrorCode()); - if (code == null) { - code = UNKNOWN_ERROR; + return "UNKNOWN_ERROR"; + } + + private static class TopicResult { + private final int index; + private final boolean success; + private final String reason; + + private TopicResult(int index, boolean success, String reason) { + this.index = index; + this.success = success; + this.reason = reason; + } + + static TopicResult success(int index) { + return new TopicResult(index, true, null); } - String msg = response.getErrorMessage(); - if (Strings.isNullOrEmpty(msg)) { - msg = String.format("Unexpected HTTP response with status: %d; body: %s", - e.getStatusCode(), e.getContent()); + + static TopicResult error(int index, String reason) { + return new TopicResult(index, false, reason); } - throw new FirebaseMessagingException(code, msg, e); + + int getIndex() { + return index; + } + + boolean isSuccess() { + return success; + } + + String getReason() { + return reason; + } + } + + /** + * Unsubscribes a list of registration tokens from a topic using the legacy Instance ID API. + * + * @deprecated Use {@link #unsubscribeFromTopic(List, String)} instead. + */ + @Deprecated + public TopicManagementResponse unsubscribeFromTopicLegacy( + @NonNull List registrationTokens, + @NonNull String topic) throws FirebaseMessagingException { + return unsubscribeLegacyOp(registrationTokens, topic).call(); + } + + /** + * Similar to {@link #unsubscribeFromTopicLegacy(List, String)} but performs the operation + * asynchronously. + * + * @deprecated Use {@link #unsubscribeFromTopicAsync(List, String)} instead. + */ + @Deprecated + public ApiFuture unsubscribeFromTopicLegacyAsync( + @NonNull List registrationTokens, @NonNull String topic) { + return unsubscribeLegacyOp(registrationTokens, topic).callAsync(app); } private CallableOperation - manageTopicOp( - final List registrationTokens, final String topic, final String path) { + unsubscribeLegacyOp(final List registrationTokens, final String topic) { checkRegistrationTokens(registrationTokens); checkTopic(topic); + final InstanceIdClient instanceIdClient = getInstanceIdClient(); return new CallableOperation() { @Override protected TopicManagementResponse execute() throws FirebaseMessagingException { - final String prefixedTopic; - if (topic.startsWith("/topics/")) { - prefixedTopic = topic; - } else { - prefixedTopic = "/topics/" + topic; - } - Map payload = ImmutableMap.of( - "to", prefixedTopic, - "registration_tokens", registrationTokens - ); - - final String url = String.format("%s/%s", IID_HOST, path); - HttpResponse response = null; - try { - HttpRequest request = requestFactory.buildPostRequest( - new GenericUrl(url), new JsonHttpContent(jsonFactory, payload)); - request.getHeaders().set("access_token_auth", "true"); - request.setParser(new JsonObjectParser(jsonFactory)); - request.setResponseInterceptor(interceptor); - response = request.execute(); - InstanceIdServiceResponse parsed = new InstanceIdServiceResponse(); - jsonFactory.createJsonParser(response.getContent()).parseAndClose(parsed); - checkState(parsed.results != null && !parsed.results.isEmpty(), - "unexpected response from topic management service"); - return new TopicManagementResponse(parsed.results); - } catch (HttpResponseException e) { - handleTopicManagementHttpError(e); - return null; - } catch (IOException e) { - throw new FirebaseMessagingException( - INTERNAL_ERROR, "Error while calling IID backend service", e); - } finally { - disconnectQuietly(response); - } + return instanceIdClient.unsubscribeFromTopic(topic, registrationTokens); } }; } - private void handleTopicManagementHttpError( - HttpResponseException e) throws FirebaseMessagingException { - InstanceIdServiceErrorResponse response = new InstanceIdServiceErrorResponse(); - try { - JsonParser parser = jsonFactory.createJsonParser(e.getContent()); - parser.parseAndClose(response); - } catch (IOException ignored) { - // ignored - } - - // Infer error code from HTTP status - String code = IID_ERROR_CODES.get(e.getStatusCode()); - if (code == null) { - code = UNKNOWN_ERROR; - } - String msg = response.error; - if (Strings.isNullOrEmpty(msg)) { - msg = String.format("Unexpected HTTP response with status: %d; body: %s", - e.getStatusCode(), e.getContent()); - } - throw new FirebaseMessagingException(code, msg, e); - } - - private static void disconnectQuietly(HttpResponse response) { - if (response != null) { - try { - response.disconnect(); - } catch (IOException ignored) { - // ignored - } - } + @VisibleForTesting + InstanceIdClient getInstanceIdClient() { + return this.instanceIdClient.get(); } - private static void checkRegistrationTokens(List registrationTokens) { + private void checkRegistrationTokens(List registrationTokens) { checkArgument(registrationTokens != null && !registrationTokens.isEmpty(), "registrationTokens list must not be null or empty"); checkArgument(registrationTokens.size() <= 1000, @@ -380,75 +797,67 @@ private static void checkRegistrationTokens(List registrationTokens) { } } - private static void checkTopic(String topic) { + private void checkTopic(String topic) { checkArgument(!Strings.isNullOrEmpty(topic), "topic must not be null or empty"); checkArgument(topic.matches("^(/topics/)?(private/)?[a-zA-Z0-9-_.~%]+$"), "invalid topic name"); } - @VisibleForTesting - void setInterceptor(HttpResponseInterceptor interceptor) { - this.interceptor = interceptor; - } - private static final String SERVICE_ID = FirebaseMessaging.class.getName(); private static class FirebaseMessagingService extends FirebaseService { FirebaseMessagingService(FirebaseApp app) { - super(SERVICE_ID, new FirebaseMessaging(app)); + super(SERVICE_ID, FirebaseMessaging.fromApp(app)); } + } - @Override - public void destroy() { - // NOTE: We don't explicitly tear down anything here, but public methods of FirebaseMessaging - // will now fail because calls to getOptions() and getToken() will hit FirebaseApp, - // which will throw once the app is deleted. - } + private static FirebaseMessaging fromApp(final FirebaseApp app) { + return FirebaseMessaging.builder() + .setFirebaseApp(app) + .setMessagingClient(new Supplier() { + @Override + public FirebaseMessagingClient get() { + return FirebaseMessagingClientImpl.fromApp(app); + } + }) + .setInstanceIdClient(new Supplier() { + @Override + public InstanceIdClientImpl get() { + return InstanceIdClientImpl.fromApp(app); + } + }) + .build(); } - private static class MessagingServiceResponse { - @Key("name") - private String name; + static Builder builder() { + return new Builder(); } - private static class MessagingServiceErrorResponse { - @Key("error") - private Map error; + static class Builder { + private FirebaseApp firebaseApp; + private Supplier messagingClient; + private Supplier instanceIdClient; - String getErrorCode() { - if (error == null) { - return null; - } - Object details = error.get("details"); - if (details != null && details instanceof List) { - for (Object detail : (List) details) { - if (detail instanceof Map) { - Map detailMap = (Map) detail; - if (FCM_ERROR_TYPE.equals(detailMap.get("@type"))) { - return (String) detailMap.get("errorCode"); - } - } - } - } - return (String) error.get("status"); + private Builder() { } + + Builder setFirebaseApp(FirebaseApp firebaseApp) { + this.firebaseApp = firebaseApp; + return this; } - String getErrorMessage() { - if (error != null) { - return (String) error.get("message"); - } - return null; + Builder setMessagingClient(Supplier messagingClient) { + this.messagingClient = messagingClient; + return this; } - } - private static class InstanceIdServiceResponse { - @Key("results") - private List> results; - } + Builder setInstanceIdClient(Supplier instanceIdClient) { + this.instanceIdClient = instanceIdClient; + return this; + } - private static class InstanceIdServiceErrorResponse { - @Key("error") - private String error; + FirebaseMessaging build() { + return new FirebaseMessaging(this); + } } } diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java new file mode 100644 index 000000000..a4e436a61 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java @@ -0,0 +1,51 @@ +package com.google.firebase.messaging; + +import java.util.List; + +/** + * An interface for sending Firebase Cloud Messaging (FCM) messages. + */ +interface FirebaseMessagingClient { + + /** + * Sends the given message with FCM. + * + * @param message A non-null {@link Message} to be sent. + * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. + * @return A message ID string. + * @throws FirebaseMessagingException If an error occurs while handing the message off to FCM for + * delivery. + */ + String send(Message message, boolean dryRun) throws FirebaseMessagingException; + + /** + * Sends all the messages in the given list with FCM. + * + * @param messages A non-null, non-empty list of messages. + * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. + * @return A {@link BatchResponse} indicating the result of the operation. + * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for + * delivery. + */ + BatchResponse sendAll(List messages, boolean dryRun) throws FirebaseMessagingException; + + /** + * Subscribes a registration token to a topic via the FCM v1 API. + * + * @param topic Name of the topic. + * @param registrationToken A registration token. + * @throws FirebaseMessagingException If an error occurs. + */ + void subscribeToTopic( + String topic, String registrationToken) throws FirebaseMessagingException; + + /** + * Unsubscribes a registration token from a topic via the FCM v1 API. + * + * @param topic Name of the topic. + * @param registrationToken A registration token. + * @throws FirebaseMessagingException If an error occurs. + */ + void unsubscribeFromTopic( + String topic, String registrationToken) throws FirebaseMessagingException; +} diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java new file mode 100644 index 000000000..9b38ac769 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java @@ -0,0 +1,420 @@ +/* + * Copyright 2019 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.messaging; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.googleapis.batch.BatchCallback; +import com.google.api.client.googleapis.batch.BatchRequest; +import com.google.api.client.googleapis.services.json.AbstractGoogleJsonClient; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpMethods; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.http.HttpResponseException; +import com.google.api.client.http.HttpResponseInterceptor; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.json.JsonHttpContent; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.JsonObjectParser; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseException; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.OutgoingHttpRequest; +import com.google.firebase.internal.AbstractPlatformErrorHandler; +import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.ErrorHandlingHttpClient; +import com.google.firebase.internal.HttpRequestInfo; +import com.google.firebase.internal.SdkUtils; +import com.google.firebase.messaging.internal.MessagingServiceErrorResponse; +import com.google.firebase.messaging.internal.MessagingServiceResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +/** + * A helper class for interacting with Firebase Cloud Messaging service. + */ +final class FirebaseMessagingClientImpl implements FirebaseMessagingClient { + + private static final String DEFAULT_FCM_HOST = "https://fcm.googleapis.com"; + private static final String FCM_URL = "%s/v1/projects/%s/messages:send"; + + private static final Map COMMON_HEADERS = + ImmutableMap.of( + "X-GOOG-API-FORMAT-VERSION", "2", + "X-Firebase-Client", "fire-admin-java/" + SdkUtils.getVersion()); + + private final String projectId; + private final String fcmHost; + private final String fcmSendUrl; + private final HttpRequestFactory requestFactory; + private final HttpRequestFactory childRequestFactory; + private final JsonFactory jsonFactory; + private final HttpResponseInterceptor responseInterceptor; + private final MessagingErrorHandler errorHandler; + private final ErrorHandlingHttpClient httpClient; + private final MessagingBatchClient batchClient; + + private FirebaseMessagingClientImpl(Builder builder) { + checkArgument(!Strings.isNullOrEmpty(builder.projectId)); + this.projectId = builder.projectId; + String host = Strings.isNullOrEmpty(builder.fcmHost) ? DEFAULT_FCM_HOST : builder.fcmHost; + while (host.endsWith("/")) { + host = host.substring(0, host.length() - 1); + } + this.fcmHost = host; + this.fcmSendUrl = String.format(FCM_URL, this.fcmHost, builder.projectId); + this.requestFactory = checkNotNull(builder.requestFactory); + this.childRequestFactory = checkNotNull(builder.childRequestFactory); + this.jsonFactory = checkNotNull(builder.jsonFactory); + this.responseInterceptor = builder.responseInterceptor; + this.errorHandler = new MessagingErrorHandler(this.jsonFactory); + this.httpClient = new ErrorHandlingHttpClient<>(requestFactory, jsonFactory, errorHandler) + .setInterceptor(responseInterceptor); + this.batchClient = new MessagingBatchClient(requestFactory.getTransport(), jsonFactory); + } + + @VisibleForTesting + String getFcmSendUrl() { + return fcmSendUrl; + } + + @VisibleForTesting + HttpRequestFactory getRequestFactory() { + return requestFactory; + } + + @VisibleForTesting + HttpRequestFactory getChildRequestFactory() { + return childRequestFactory; + } + + @VisibleForTesting + JsonFactory getJsonFactory() { + return jsonFactory; + } + + public String send(Message message, boolean dryRun) throws FirebaseMessagingException { + return sendSingleRequest(message, dryRun); + } + + public BatchResponse sendAll( + List messages, boolean dryRun) throws FirebaseMessagingException { + return sendBatchRequest(messages, dryRun); + } + + private String sendSingleRequest( + Message message, boolean dryRun) throws FirebaseMessagingException { + HttpRequestInfo request = + HttpRequestInfo.buildJsonPostRequest( + fcmSendUrl, message.wrapForTransport(dryRun)) + .addAllHeaders(COMMON_HEADERS); + MessagingServiceResponse parsed = httpClient.sendAndParse( + request, MessagingServiceResponse.class); + return parsed.getMessageId(); + } + + private BatchResponse sendBatchRequest( + List messages, boolean dryRun) throws FirebaseMessagingException { + + MessagingBatchCallback callback = new MessagingBatchCallback(); + try { + BatchRequest batch = newBatchRequest(messages, dryRun, callback); + batch.execute(); + return new BatchResponseImpl(callback.getResponses()); + } catch (HttpResponseException e) { + OutgoingHttpRequest req = new OutgoingHttpRequest( + HttpMethods.POST, MessagingBatchClient.FCM_BATCH_URL); + IncomingHttpResponse resp = new IncomingHttpResponse(e, req); + throw errorHandler.handleHttpResponseException(e, resp); + } catch (IOException e) { + throw errorHandler.handleIOException(e); + } + } + + private BatchRequest newBatchRequest( + List messages, boolean dryRun, MessagingBatchCallback callback) throws IOException { + + BatchRequest batch = batchClient.batch(getBatchRequestInitializer()); + final JsonObjectParser jsonParser = new JsonObjectParser(this.jsonFactory); + final GenericUrl sendUrl = new GenericUrl(fcmSendUrl); + for (Message message : messages) { + // Using a separate request factory without authorization is faster for large batches. + // A simple performance test showed a 400-500ms speed up for batches of 1000 messages. + HttpRequest request = childRequestFactory.buildPostRequest( + sendUrl, + new JsonHttpContent(jsonFactory, message.wrapForTransport(dryRun))); + request.setParser(jsonParser); + request.getHeaders().putAll(COMMON_HEADERS); + batch.queue( + request, MessagingServiceResponse.class, MessagingServiceErrorResponse.class, callback); + } + return batch; + } + + private HttpRequestInitializer getBatchRequestInitializer() { + return new HttpRequestInitializer() { + @Override + public void initialize(HttpRequest request) throws IOException { + // Batch requests are not executed on the ErrorHandlingHttpClient. Therefore, they + // require some special handling at initialization. + HttpRequestInitializer initializer = requestFactory.getInitializer(); + if (initializer != null) { + initializer.initialize(request); + } + request.setResponseInterceptor(responseInterceptor); + } + }; + } + + @Override + public void subscribeToTopic( + String topic, String registrationToken) throws FirebaseMessagingException { + sendSingleTopicRequest(registrationToken, topic, true); + } + + @Override + public void unsubscribeFromTopic( + String topic, String registrationToken) throws FirebaseMessagingException { + sendSingleTopicRequest(registrationToken, topic, false); + } + + private void sendSingleTopicRequest( + String token, String topic, boolean isSubscribe) throws FirebaseMessagingException { + try { + String topicName = topic.startsWith("/topics/") + ? topic.substring("/topics/".length()) : topic; + String encodedToken = URLEncoder.encode(token, StandardCharsets.UTF_8.name()); + String encodedTopic = URLEncoder.encode(topicName, StandardCharsets.UTF_8.name()); + HttpRequestInfo requestInfo; + if (isSubscribe) { + String url = String.format( + "%s/v1/projects/%s/registrations/%s/topicSubscriptions?topic_name=%s", + fcmHost, projectId, encodedToken, encodedTopic); + requestInfo = HttpRequestInfo.buildJsonPostRequest(url, ImmutableMap.of()) + .addAllHeaders(COMMON_HEADERS); + } else { + String url = String.format( + "%s/v1/projects/%s/registrations/%s/topicSubscriptions/%s?allow_missing=true", + fcmHost, projectId, encodedToken, encodedTopic); + requestInfo = HttpRequestInfo.buildDeleteRequest(url) + .addAllHeaders(COMMON_HEADERS); + } + + httpClient.send(requestInfo); + } catch (FirebaseMessagingException e) { + if (isSubscribe && isAlreadyExists(e)) { + return; + } + throw e; + } catch (IOException e) { + throw errorHandler.handleIOException(e); + } + } + + private boolean isAlreadyExists(FirebaseMessagingException e) { + if (e.getHttpResponse() != null && e.getHttpResponse().getStatusCode() == 409) { + return true; + } + if (e.getErrorCode() == ErrorCode.ALREADY_EXISTS || e.getErrorCode() == ErrorCode.CONFLICT) { + return true; + } + return false; + } + + static FirebaseMessagingClientImpl fromApp(FirebaseApp app) { + String projectId = ImplFirebaseTrampolines.getProjectId(app); + checkArgument(!Strings.isNullOrEmpty(projectId), + "Project ID is required to access messaging service. Use a service account credential or " + + "set the project ID explicitly via FirebaseOptions. Alternatively you can also " + + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable."); + return FirebaseMessagingClientImpl.builder() + .setProjectId(projectId) + .setRequestFactory(ApiClientUtils.newAuthorizedRequestFactory(app)) + .setChildRequestFactory(ApiClientUtils.newUnauthorizedRequestFactory(app)) + .setJsonFactory(app.getOptions().getJsonFactory()) + .build(); + } + + static Builder builder() { + return new Builder(); + } + + static final class Builder { + + private String projectId; + private String fcmHost = DEFAULT_FCM_HOST; + private HttpRequestFactory requestFactory; + private HttpRequestFactory childRequestFactory; + private JsonFactory jsonFactory; + private HttpResponseInterceptor responseInterceptor; + + private Builder() { } + + Builder setProjectId(String projectId) { + this.projectId = projectId; + return this; + } + + Builder setFcmHost(String fcmHost) { + this.fcmHost = fcmHost; + return this; + } + + Builder setRequestFactory(HttpRequestFactory requestFactory) { + this.requestFactory = requestFactory; + return this; + } + + Builder setChildRequestFactory(HttpRequestFactory childRequestFactory) { + this.childRequestFactory = childRequestFactory; + return this; + } + + Builder setJsonFactory(JsonFactory jsonFactory) { + this.jsonFactory = jsonFactory; + return this; + } + + Builder setResponseInterceptor(HttpResponseInterceptor responseInterceptor) { + this.responseInterceptor = responseInterceptor; + return this; + } + + FirebaseMessagingClientImpl build() { + return new FirebaseMessagingClientImpl(this); + } + } + + private static class MessagingBatchCallback + implements BatchCallback { + + private final ImmutableList.Builder responses = ImmutableList.builder(); + + @Override + public void onSuccess( + MessagingServiceResponse response, HttpHeaders responseHeaders) { + responses.add(SendResponse.fromMessageId(response.getMessageId())); + } + + @Override + public void onFailure(MessagingServiceErrorResponse error, HttpHeaders responseHeaders) { + // We only specify error codes and message for these partial failures. Recall that these + // exceptions are never actually thrown, but only made accessible via SendResponse. + FirebaseException base = createFirebaseException(error); + FirebaseMessagingException exception = FirebaseMessagingException.withMessagingErrorCode( + base, error.getMessagingErrorCode()); + responses.add(SendResponse.fromException(exception)); + } + + List getResponses() { + return this.responses.build(); + } + + private FirebaseException createFirebaseException(MessagingServiceErrorResponse error) { + String status = error.getStatus(); + ErrorCode errorCode = Strings.isNullOrEmpty(status) + ? ErrorCode.UNKNOWN : Enum.valueOf(ErrorCode.class, status); + + String msg = error.getErrorMessage(); + if (Strings.isNullOrEmpty(msg)) { + msg = String.format("Unexpected HTTP response: %s", error.toString()); + } + + return new FirebaseException(errorCode, msg, null); + } + } + + private static class MessagingErrorHandler + extends AbstractPlatformErrorHandler { + + private MessagingErrorHandler(JsonFactory jsonFactory) { + super(jsonFactory); + } + + @Override + protected FirebaseMessagingException createException(FirebaseException base) { + String response = getResponse(base); + MessagingServiceErrorResponse parsed = safeParse(response); + return FirebaseMessagingException.withMessagingErrorCode( + base, parsed.getMessagingErrorCode()); + } + + private String getResponse(FirebaseException base) { + if (base.getHttpResponse() == null) { + return null; + } + + return base.getHttpResponse().getContent(); + } + + private MessagingServiceErrorResponse safeParse(String response) { + if (!Strings.isNullOrEmpty(response)) { + try { + return jsonFactory.createJsonParser(response) + .parseAndClose(MessagingServiceErrorResponse.class); + } catch (Exception ignore) { + // Ignore any error that may occur while parsing the error response. The server + // may have responded with a non-json payload. + } + } + + return new MessagingServiceErrorResponse(); + } + } + + private static class MessagingBatchClient extends AbstractGoogleJsonClient { + + private static final String FCM_ROOT_URL = "https://fcm.googleapis.com"; + private static final String FCM_BATCH_PATH = "batch"; + private static final String FCM_BATCH_URL = String.format( + "%s/%s", FCM_ROOT_URL, FCM_BATCH_PATH); + + MessagingBatchClient(HttpTransport transport, JsonFactory jsonFactory) { + super(new Builder(transport, jsonFactory)); + } + + private MessagingBatchClient(Builder builder) { + super(builder); + } + + private static class Builder extends AbstractGoogleJsonClient.Builder { + Builder(HttpTransport transport, JsonFactory jsonFactory) { + super(transport, jsonFactory, FCM_ROOT_URL, "", null, false); + setBatchPath(FCM_BATCH_PATH); + setApplicationName("fire-admin-java"); + } + + @Override + public AbstractGoogleJsonClient build() { + return new MessagingBatchClient(this); + } + } + } +} diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessagingException.java b/src/main/java/com/google/firebase/messaging/FirebaseMessagingException.java index 5f57474ea..9ff70e59e 100644 --- a/src/main/java/com/google/firebase/messaging/FirebaseMessagingException.java +++ b/src/main/java/com/google/firebase/messaging/FirebaseMessagingException.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,26 +16,54 @@ package com.google.firebase.messaging; -import static com.google.common.base.Preconditions.checkArgument; - -import com.google.common.base.Strings; +import com.google.common.annotations.VisibleForTesting; +import com.google.firebase.ErrorCode; import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; + +public final class FirebaseMessagingException extends FirebaseException { -public class FirebaseMessagingException extends FirebaseException { + private final MessagingErrorCode errorCode; - private final String errorCode; + @VisibleForTesting + FirebaseMessagingException(@NonNull ErrorCode code, @NonNull String message) { + this(code, message, null, null, null); + } - FirebaseMessagingException(String errorCode, String message, Throwable cause) { - super(message, cause); - checkArgument(!Strings.isNullOrEmpty(errorCode)); + private FirebaseMessagingException( + @NonNull ErrorCode code, + @NonNull String message, + @Nullable Throwable cause, + @Nullable IncomingHttpResponse response, + @Nullable MessagingErrorCode errorCode) { + super(code, message, cause, response); this.errorCode = errorCode; } + static FirebaseMessagingException withMessagingErrorCode( + FirebaseException base, @Nullable MessagingErrorCode errorCode) { + return new FirebaseMessagingException( + base.getErrorCode(), + base.getMessage(), + base.getCause(), + base.getHttpResponse(), + errorCode); + } + + static FirebaseMessagingException withCustomMessage(FirebaseException base, String message) { + return new FirebaseMessagingException( + base.getErrorCode(), + message, + base.getCause(), + base.getHttpResponse(), + null); + } /** Returns an error code that may provide more information about the error. */ - @NonNull - public String getErrorCode() { + @Nullable + public MessagingErrorCode getMessagingErrorCode() { return errorCode; } } diff --git a/src/main/java/com/google/firebase/messaging/InstanceIdClient.java b/src/main/java/com/google/firebase/messaging/InstanceIdClient.java new file mode 100644 index 000000000..8c1b70a3b --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/InstanceIdClient.java @@ -0,0 +1,30 @@ +package com.google.firebase.messaging; + +import java.util.List; + +/** + * An interface for managing FCM topic subscriptions. + */ +interface InstanceIdClient { + + /** + * Subscribes a list of registration tokens to a topic. + * + * @param registrationTokens A non-null, non-empty list of device registration tokens. + * @param topic Name of the topic to subscribe to. May contain the {@code /topics/} prefix. + * @return A {@link TopicManagementResponse}. + */ + TopicManagementResponse subscribeToTopic( + String topic, List registrationTokens) throws FirebaseMessagingException; + + /** + * Unsubscribes a list of registration tokens from a topic. + * + * @param registrationTokens A non-null, non-empty list of device registration tokens. + * @param topic Name of the topic to unsubscribe from. May contain the {@code /topics/} prefix. + * @return A {@link TopicManagementResponse}. + */ + TopicManagementResponse unsubscribeFromTopic( + String topic, List registrationTokens) throws FirebaseMessagingException; + +} diff --git a/src/main/java/com/google/firebase/messaging/InstanceIdClientImpl.java b/src/main/java/com/google/firebase/messaging/InstanceIdClientImpl.java new file mode 100644 index 000000000..34dc17d6f --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/InstanceIdClientImpl.java @@ -0,0 +1,165 @@ +/* + * Copyright 2019 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.messaging; + +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponseInterceptor; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.util.Key; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseException; +import com.google.firebase.internal.AbstractHttpErrorHandler; +import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.ErrorHandlingHttpClient; +import com.google.firebase.internal.HttpRequestInfo; +import com.google.firebase.internal.Nullable; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * A helper class for interacting with the Firebase Instance ID service. Implements the FCM + * topic management functionality. + */ +final class InstanceIdClientImpl implements InstanceIdClient { + + private static final String IID_HOST = "https://iid.googleapis.com"; + + private static final String IID_SUBSCRIBE_PATH = "iid/v1:batchAdd"; + + private static final String IID_UNSUBSCRIBE_PATH = "iid/v1:batchRemove"; + + private final ErrorHandlingHttpClient requestFactory; + + InstanceIdClientImpl(HttpRequestFactory requestFactory, JsonFactory jsonFactory) { + this(requestFactory, jsonFactory, null); + } + + InstanceIdClientImpl( + HttpRequestFactory requestFactory, + JsonFactory jsonFactory, + @Nullable HttpResponseInterceptor responseInterceptor) { + InstanceIdErrorHandler errorHandler = new InstanceIdErrorHandler(jsonFactory); + this.requestFactory = new ErrorHandlingHttpClient<>(requestFactory, jsonFactory, errorHandler) + .setInterceptor(responseInterceptor); + } + + static InstanceIdClientImpl fromApp(FirebaseApp app) { + return new InstanceIdClientImpl( + ApiClientUtils.newAuthorizedRequestFactory(app), + app.getOptions().getJsonFactory()); + } + + public TopicManagementResponse subscribeToTopic( + String topic, List registrationTokens) throws FirebaseMessagingException { + return sendInstanceIdRequest(topic, registrationTokens, IID_SUBSCRIBE_PATH); + } + + public TopicManagementResponse unsubscribeFromTopic( + String topic, List registrationTokens) throws FirebaseMessagingException { + return sendInstanceIdRequest(topic, registrationTokens, IID_UNSUBSCRIBE_PATH); + } + + private TopicManagementResponse sendInstanceIdRequest( + String topic, + List registrationTokens, + String path) throws FirebaseMessagingException { + + String url = String.format("%s/%s", IID_HOST, path); + Map payload = ImmutableMap.of( + "to", getPrefixedTopic(topic), + "registration_tokens", registrationTokens + ); + + HttpRequestInfo request = HttpRequestInfo.buildJsonPostRequest(url, payload) + .addHeader("access_token_auth", "true"); + InstanceIdServiceResponse response = new InstanceIdServiceResponse(); + requestFactory.sendAndParse(request, response); + return new TopicManagementResponse(response.results); + } + + private String getPrefixedTopic(String topic) { + if (topic.startsWith("/topics/")) { + return topic; + } else { + return "/topics/" + topic; + } + } + + private static class InstanceIdServiceResponse { + @Key("results") + private List results; + } + + private static class InstanceIdServiceErrorResponse { + @Key("error") + private String error; + } + + private static class InstanceIdErrorHandler + extends AbstractHttpErrorHandler { + + private final JsonFactory jsonFactory; + + InstanceIdErrorHandler(JsonFactory jsonFactory) { + this.jsonFactory = jsonFactory; + } + + @Override + protected FirebaseMessagingException createException(FirebaseException base) { + String message = getCustomMessage(base); + return FirebaseMessagingException.withCustomMessage(base, message); + } + + private String getCustomMessage(FirebaseException base) { + String response = getResponse(base); + InstanceIdServiceErrorResponse parsed = safeParse(response); + if (!Strings.isNullOrEmpty(parsed.error)) { + return "Error while calling the IID service: " + parsed.error; + } + + return base.getMessage(); + } + + private String getResponse(FirebaseException base) { + if (base.getHttpResponse() == null) { + return null; + } + + return base.getHttpResponse().getContent(); + } + + private InstanceIdServiceErrorResponse safeParse(String response) { + InstanceIdServiceErrorResponse parsed = new InstanceIdServiceErrorResponse(); + if (!Strings.isNullOrEmpty(response)) { + // Parse the error response from the IID service. + // Sample response: {"error": "error message text"} + try { + jsonFactory.createJsonParser(response).parse(parsed); + } catch (IOException ignore) { + // Ignore any error that may occur while parsing the error response. The server + // may have responded with a non-json payload. + } + } + + return parsed; + } + } +} diff --git a/src/main/java/com/google/firebase/messaging/LightSettings.java b/src/main/java/com/google/firebase/messaging/LightSettings.java new file mode 100644 index 000000000..0052cffbf --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/LightSettings.java @@ -0,0 +1,128 @@ +/* + * Copyright 2019 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.messaging; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.client.util.Key; +import java.util.concurrent.TimeUnit; + +/** + * A class representing light settings in an Android Notification. + */ +public final class LightSettings { + + @Key("color") + private final LightSettingsColor color; + + @Key("light_on_duration") + private final String lightOnDuration; + + @Key("light_off_duration") + private final String lightOffDuration; + + private LightSettings(Builder builder) { + this.color = builder.color; + this.lightOnDuration = builder.lightOnDuration; + this.lightOffDuration = builder.lightOffDuration; + } + + /** + * Creates a new {@link LightSettings.Builder}. + * + * @return A {@link LightSettings.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private LightSettingsColor color; + private String lightOnDuration; + private String lightOffDuration; + + private Builder() {} + + /** + * Sets the lightSettingsColor value with a string. + * + * @param color LightSettingsColor specified in the {@code #rrggbb} format. + * @return This builder. + */ + public Builder setColorFromString(String color) { + this.color = LightSettingsColor.fromString(color); + return this; + } + + /** + * Sets the lightSettingsColor value in the light settings. + * + * @param color Color to be used in the light settings. + * @return This builder. + */ + public Builder setColor(LightSettingsColor color) { + this.color = color; + return this; + } + + /** + * Sets the light on duration in milliseconds. + * + * @param lightOnDurationInMillis The time duration in milliseconds for the LED light to be on. + * @return This builder. + */ + public Builder setLightOnDurationInMillis(long lightOnDurationInMillis) { + this.lightOnDuration = convertToSecondsAndNanosFormat(lightOnDurationInMillis); + return this; + } + + /** + * Sets the light off duration in milliseconds. + * + * @param lightOffDurationInMillis The time duration in milliseconds for the LED light to be + * off. + * @return This builder. + */ + public Builder setLightOffDurationInMillis(long lightOffDurationInMillis) { + this.lightOffDuration = convertToSecondsAndNanosFormat(lightOffDurationInMillis); + return this; + } + + private String convertToSecondsAndNanosFormat(long millis) { + checkArgument(millis >= 0, "Milliseconds duration must not be negative"); + long seconds = TimeUnit.MILLISECONDS.toSeconds(millis); + long subsecondNanos = TimeUnit.MILLISECONDS + .toNanos(millis - seconds * 1000L); + if (subsecondNanos > 0) { + return String.format("%d.%09ds", seconds, subsecondNanos); + } else { + return String.format("%ds", seconds); + } + } + + /** + * Builds a new {@link LightSettings} instance from the fields set on this builder. + * + * @return A non-null {@link LightSettings}. + * @throws IllegalArgumentException If the volume value is out of range. + */ + public LightSettings build() { + return new LightSettings(this); + } + } +} diff --git a/src/main/java/com/google/firebase/messaging/LightSettingsColor.java b/src/main/java/com/google/firebase/messaging/LightSettingsColor.java new file mode 100644 index 000000000..28f064f47 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/LightSettingsColor.java @@ -0,0 +1,71 @@ +/* + * Copyright 2019 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.messaging; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.client.util.Key; + +/** + * A class representing color in LightSettings. + */ +public final class LightSettingsColor { + + @Key("red") + private final Float red; + + @Key("green") + private final Float green; + + @Key("blue") + private final Float blue; + + @Key("alpha") + private final Float alpha; + + /** + * Creates a new {@link LightSettingsColor} using the given red, green, blue, and + * alpha values. + * + * @param red The red component. + * @param green The green component. + * @param blue The blue component. + * @param alpha The alpha component. + */ + public LightSettingsColor(float red, float green, float blue, float alpha) { + this.red = red; + this.green = green; + this.blue = blue; + this.alpha = alpha; + } + + /** + * Creates a new {@link LightSettingsColor} with a string. Alpha of the color will be + * set to 1. + * + * @param rrggbb LightSettingsColor specified in the {@code #rrggbb} format. + * @return A {@link LightSettingsColor} instance. + */ + public static LightSettingsColor fromString(String rrggbb) { + checkArgument(rrggbb.matches("^#[0-9a-fA-F]{6}$"), + "LightSettingsColor must be in the form #RRGGBB"); + float red = (float) Integer.parseInt(rrggbb.substring(1, 3), 16) / 255.0f; + float green = (float) Integer.valueOf(rrggbb.substring(3, 5), 16) / 255.0f; + float blue = (float) Integer.valueOf(rrggbb.substring(5, 7), 16) / 255.0f; + return new LightSettingsColor(red, green, blue, 1.0f); + } +} diff --git a/src/main/java/com/google/firebase/messaging/Message.java b/src/main/java/com/google/firebase/messaging/Message.java index 77cce7ae3..da790fa7f 100644 --- a/src/main/java/com/google/firebase/messaging/Message.java +++ b/src/main/java/com/google/firebase/messaging/Message.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ import static com.google.common.base.Preconditions.checkArgument; import com.google.api.client.util.Key; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.collect.ImmutableMap; import com.google.common.primitives.Booleans; @@ -53,15 +54,25 @@ public class Message { @Key("apns") private final ApnsConfig apnsConfig; + /** + * @deprecated Use {@link #fid} instead. + */ + @Deprecated @Key("token") private final String token; + @Key("fid") + private final String fid; + @Key("topic") private final String topic; @Key("condition") private final String condition; + @Key("fcm_options") + private final FcmOptions fcmOptions; + private Message(Builder builder) { this.data = builder.data.isEmpty() ? null : ImmutableMap.copyOf(builder.data); this.notification = builder.notification; @@ -70,13 +81,80 @@ private Message(Builder builder) { this.apnsConfig = builder.apnsConfig; int count = Booleans.countTrue( !Strings.isNullOrEmpty(builder.token), + !Strings.isNullOrEmpty(builder.fid), !Strings.isNullOrEmpty(builder.topic), !Strings.isNullOrEmpty(builder.condition) ); - checkArgument(count == 1, "Exactly one of token, topic or condition must be specified"); + checkArgument(count == 1, + "Exactly one of token, fid, topic or condition must be specified"); this.token = builder.token; + this.fid = builder.fid; this.topic = stripPrefix(builder.topic); this.condition = builder.condition; + this.fcmOptions = builder.fcmOptions; + } + + @VisibleForTesting + Map getData() { + return data; + } + + @VisibleForTesting + Notification getNotification() { + return notification; + } + + @VisibleForTesting + AndroidConfig getAndroidConfig() { + return androidConfig; + } + + @VisibleForTesting + WebpushConfig getWebpushConfig() { + return webpushConfig; + } + + @VisibleForTesting + ApnsConfig getApnsConfig() { + return apnsConfig; + } + + /** + * @deprecated Use {@link #getFid()} instead. + */ + @Deprecated + @VisibleForTesting + String getToken() { + return token; + } + + @VisibleForTesting + String getFid() { + return fid; + } + + @VisibleForTesting + String getTopic() { + return topic; + } + + @VisibleForTesting + String getCondition() { + return condition; + } + + @VisibleForTesting + FcmOptions getFcmOptions() { + return fcmOptions; + } + + Map wrapForTransport(boolean dryRun) { + ImmutableMap.Builder payload = ImmutableMap.builder() + .put("message", this); + if (dryRun) { + payload.put("validate_only", true); + } + return payload.build(); } private static String stripPrefix(String topic) { @@ -107,9 +185,12 @@ public static class Builder { private AndroidConfig androidConfig; private WebpushConfig webpushConfig; private ApnsConfig apnsConfig; + @Deprecated private String token; + private String fid; private String topic; private String condition; + private FcmOptions fcmOptions; private Builder() {} @@ -162,12 +243,26 @@ public Builder setApnsConfig(ApnsConfig apnsConfig) { * * @param token A valid device registration token. * @return This builder. + * @deprecated Use {@link #setFid(String)} instead. */ + @Deprecated public Builder setToken(String token) { this.token = token; return this; } + /** + * Sets the Firebase Installation ID (FID) of the app instance to which the message + * should be sent. + * + * @param fid A valid Firebase Installation ID. + * @return This builder. + */ + public Builder setFid(String fid) { + this.fid = fid; + return this; + } + /** * Sets the name of the FCM topic to which the message should be sent. Topic names may * contain the {@code /topics/} prefix. @@ -216,6 +311,15 @@ public Builder putAllData(@NonNull Map map) { return this; } + /** + * Sets the {@link FcmOptions}, which can be overridden by the platform-specific {@code + * fcm_options} fields. + */ + public Builder setFcmOptions(FcmOptions fcmOptions) { + this.fcmOptions = fcmOptions; + return this; + } + /** * Creates a new {@link Message} instance from the parameters set on this builder. * @@ -226,5 +330,4 @@ public Message build() { return new Message(this); } } - } diff --git a/src/main/java/com/google/firebase/messaging/MessagingErrorCode.java b/src/main/java/com/google/firebase/messaging/MessagingErrorCode.java new file mode 100644 index 000000000..b566befbd --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/MessagingErrorCode.java @@ -0,0 +1,43 @@ +package com.google.firebase.messaging; + +/** + * Error codes that can be raised by the Cloud Messaging APIs. + */ +public enum MessagingErrorCode { + + /** + * APNs certificate or web push auth key was invalid or missing. + */ + THIRD_PARTY_AUTH_ERROR, + + /** + * One or more arguments specified in the request were invalid. + */ + INVALID_ARGUMENT, + + /** + * Internal server error. + */ + INTERNAL, + + /** + * Sending limit exceeded for the message target. + */ + QUOTA_EXCEEDED, + + /** + * The authenticated sender ID is different from the sender ID for the registration token. + */ + SENDER_ID_MISMATCH, + + /** + * Cloud Messaging service is temporarily unavailable. + */ + UNAVAILABLE, + + /** + * App instance was unregistered from FCM. This usually means that the token used is no longer + * valid and a new one must be used. + */ + UNREGISTERED, +} diff --git a/src/main/java/com/google/firebase/messaging/MulticastMessage.java b/src/main/java/com/google/firebase/messaging/MulticastMessage.java new file mode 100644 index 000000000..a880a88f2 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/MulticastMessage.java @@ -0,0 +1,282 @@ +/* + * Copyright 2019 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.messaging; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.client.util.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.internal.NonNull; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Represents a message that can be sent to multiple devices via Firebase Cloud Messaging (FCM). + * Contains payload information as well as the list of device registration tokens and/or + * Firebase Installation IDs (FIDs) to which the message should be sent. A single + * {@code MulticastMessage} may contain up to 500 registration tokens and FIDs combined. + * + *

Instances of this class are thread-safe and immutable. Use {@link MulticastMessage.Builder} + * to create new instances. See {@link FirebaseMessaging#sendMulticast(MulticastMessage)} for + * details on how to send the message to FCM for multicast delivery. + * + *

This class and the associated Builder retain the order of tokens and FIDs. Therefore + * the order of the responses list obtained by calling {@link BatchResponse#getResponses()} + * on the return value of {@link FirebaseMessaging#sendMulticast(MulticastMessage)} + * corresponds to the order in which targets were added to the + * {@link MulticastMessage.Builder}. If both tokens and FIDs are provided, tokens are + * processed first, followed by FIDs. + */ +public class MulticastMessage { + + @Deprecated + private final List tokens; + private final List fids; + private final Map data; + private final Notification notification; + private final AndroidConfig androidConfig; + private final WebpushConfig webpushConfig; + private final ApnsConfig apnsConfig; + private final FcmOptions fcmOptions; + + private MulticastMessage(Builder builder) { + this.tokens = builder.tokens.build(); + this.fids = builder.fids.build(); + int tokensSize = this.tokens.size(); + int fidsSize = this.fids.size(); + checkArgument(tokensSize + fidsSize > 0, "at least one token or fid must be specified"); + checkArgument(tokensSize + fidsSize <= 500, + "no more than 500 tokens and fids combined can be specified"); + for (String token : this.tokens) { + checkArgument(!Strings.isNullOrEmpty(token), "none of the tokens can be null or empty"); + } + for (String fid : this.fids) { + checkArgument(!Strings.isNullOrEmpty(fid), "none of the fids can be null or empty"); + } + this.data = builder.data.isEmpty() ? null : ImmutableMap.copyOf(builder.data); + this.notification = builder.notification; + this.androidConfig = builder.androidConfig; + this.webpushConfig = builder.webpushConfig; + this.apnsConfig = builder.apnsConfig; + this.fcmOptions = builder.fcmOptions; + } + + List getMessageList() { + ImmutableList.Builder messages = ImmutableList.builder(); + + if (!this.tokens.isEmpty()) { + Message.Builder tokenBuilder = getMetadataBuilder(); + for (String token : this.tokens) { + messages.add(tokenBuilder.setToken(token).build()); + } + } + + if (!this.fids.isEmpty()) { + Message.Builder fidBuilder = getMetadataBuilder(); + for (String fid : this.fids) { + messages.add(fidBuilder.setFid(fid).build()); + } + } + + return messages.build(); + } + + private Message.Builder getMetadataBuilder() { + Message.Builder builder = Message.builder() + .setNotification(this.notification) + .setAndroidConfig(this.androidConfig) + .setApnsConfig(this.apnsConfig) + .setWebpushConfig(this.webpushConfig) + .setFcmOptions(this.fcmOptions); + if (this.data != null) { + builder.putAllData(this.data); + } + return builder; + } + + /** + * Creates a new {@link MulticastMessage.Builder}. + * + * @return A {@link MulticastMessage.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + @Deprecated + private final ImmutableList.Builder tokens = ImmutableList.builder(); + private final ImmutableList.Builder fids = ImmutableList.builder(); + private final Map data = new HashMap<>(); + private Notification notification; + private AndroidConfig androidConfig; + private WebpushConfig webpushConfig; + private ApnsConfig apnsConfig; + private FcmOptions fcmOptions; + + private Builder() {} + + /** + * Adds a token to which the message should be sent. Up to 500 tokens + * and FIDs combined can be specified on a single instance of + * {@link MulticastMessage}. + * + * @param token A non-null, non-empty Firebase device registration token. + * @return This builder. + * @deprecated Use {@link #addFid(String)} instead. + */ + @Deprecated + public Builder addToken(@NonNull String token) { + this.tokens.add(token); + return this; + } + + /** + * Adds a Firebase Installation ID (FID) to which the message should be sent. + * Up to 500 tokens and FIDs combined can be specified on a single instance + * of {@link MulticastMessage}. + * + * @param fid A non-null, non-empty Firebase Installation ID. + * @return This builder. + */ + public Builder addFid(@NonNull String fid) { + this.fids.add(fid); + return this; + } + + /** + * Adds a collection of tokens to which the message should be sent. Up to 500 + * tokens and FIDs combined can be specified on a single instance of + * {@link MulticastMessage}. + * + * @param tokens Collection of Firebase device registration tokens. + * @return This builder. + * @deprecated Use {@link #addAllFids(Collection)} instead. + */ + @Deprecated + public Builder addAllTokens(@NonNull Collection tokens) { + this.tokens.addAll(tokens); + return this; + } + + /** + * Adds a collection of Firebase Installation IDs (FIDs) to which the message + * should be sent. Up to 500 tokens and FIDs combined can be specified on a + * single instance of {@link MulticastMessage}. + * + * @param fids Collection of Firebase Installation IDs. + * @return This builder. + */ + public Builder addAllFids(@NonNull Collection fids) { + this.fids.addAll(fids); + return this; + } + + /** + * Sets the notification information to be included in the message. + * + * @param notification A {@link Notification} instance. + * @return This builder. + */ + public Builder setNotification(Notification notification) { + this.notification = notification; + return this; + } + + /** + * Sets the Android-specific information to be included in the message. + * + * @param androidConfig An {@link AndroidConfig} instance. + * @return This builder. + */ + public Builder setAndroidConfig(AndroidConfig androidConfig) { + this.androidConfig = androidConfig; + return this; + } + + /** + * Sets the Webpush-specific information to be included in the message. + * + * @param webpushConfig A {@link WebpushConfig} instance. + * @return This builder. + */ + public Builder setWebpushConfig(WebpushConfig webpushConfig) { + this.webpushConfig = webpushConfig; + return this; + } + + /** + * Sets the information specific to APNS (Apple Push Notification Service). + * + * @param apnsConfig An {@link ApnsConfig} instance. + * @return This builder. + */ + public Builder setApnsConfig(ApnsConfig apnsConfig) { + this.apnsConfig = apnsConfig; + return this; + } + + /** + * Sets the {@link FcmOptions}, which can be overridden by the platform-specific {@code + * fcm_options} fields. + */ + public Builder setFcmOptions(FcmOptions fcmOptions) { + this.fcmOptions = fcmOptions; + return this; + } + + /** + * Adds the given key-value pair to the message as a data field. Key or the value may not be + * null. + * + * @param key Name of the data field. Must not be null. + * @param value Value of the data field. Must not be null. + * @return This builder. + */ + public Builder putData(@NonNull String key, @NonNull String value) { + this.data.put(key, value); + return this; + } + + /** + * Adds all the key-value pairs in the given map to the message as data fields. None of the + * keys or values may be null. + * + * @param map A non-null map of data fields. Map must not contain null keys or values. + * @return This builder. + */ + public Builder putAllData(@NonNull Map map) { + this.data.putAll(map); + return this; + } + + /** + * Creates a new {@link MulticastMessage} instance from the parameters set on this builder. + * + * @return A new {@link MulticastMessage} instance. + * @throws IllegalArgumentException If any of the parameters set on the builder are invalid. + */ + public MulticastMessage build() { + return new MulticastMessage(this); + } + } +} diff --git a/src/main/java/com/google/firebase/messaging/Notification.java b/src/main/java/com/google/firebase/messaging/Notification.java index eaa1e1443..8dcf1e5e0 100644 --- a/src/main/java/com/google/firebase/messaging/Notification.java +++ b/src/main/java/com/google/firebase/messaging/Notification.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,16 +29,75 @@ public class Notification { @Key("body") private final String body; + + @Key("image") + private final String image; + + private Notification(Builder builder) { + this.title = builder.title; + this.body = builder.body; + this.image = builder.image; + } /** - * Creates a new {@code Notification} using the given title and body. + * Creates a new {@link Builder}. * - * @param title Title of the notification. - * @param body Body of the notification. + * @return A {@link Builder} instance. */ - public Notification(String title, String body) { - this.title = title; - this.body = body; + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String title; + private String body; + private String image; + + private Builder() {} + + /** + * Sets the title of the notification. + * + * @param title Title of the notification. + * @return This builder. + */ + public Builder setTitle(String title) { + this.title = title; + return this; + } + + /** + * Sets the body of the notification. + * + * @param body Body of the notification. + * @return This builder. + */ + public Builder setBody(String body) { + this.body = body; + return this; + } + + /** + * Sets the URL of the image that is going to be displayed in the notification. + * + * @param imageUrl URL of the image that is going to be displayed in the notification. + * @return This builder. + */ + public Builder setImage(String imageUrl) { + this.image = imageUrl; + return this; + } + + /** + * Creates a new {@link Notification} instance from the parameters set on this builder. + * + * @return A new {@link Notification} instance. + * @throws IllegalArgumentException If any of the parameters set on the builder are invalid. + */ + public Notification build() { + return new Notification(this); + } } } diff --git a/src/main/java/com/google/firebase/messaging/SendResponse.java b/src/main/java/com/google/firebase/messaging/SendResponse.java new file mode 100644 index 000000000..58c4fe970 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/SendResponse.java @@ -0,0 +1,79 @@ +/* + * Copyright 2019 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.messaging; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Strings; +import com.google.firebase.internal.Nullable; + +/** + * The result of an individual send operation that was executed as part of a batch. See + * {@link BatchResponse} for more details. + */ +public final class SendResponse { + + private final String messageId; + private final FirebaseMessagingException exception; + + private SendResponse(String messageId, FirebaseMessagingException exception) { + this.messageId = messageId; + this.exception = exception; + } + + /** + * Returns a message ID string if the send operation was successful. Otherwise returns null. + * + * @return A message ID string or null. + */ + @Nullable + public String getMessageId() { + return this.messageId; + } + + /** + * Returns an exception if the send operation failed. Otherwise returns null. + * + * @return A {@link FirebaseMessagingException} or null. + */ + @Nullable + public FirebaseMessagingException getException() { + return this.exception; + } + + /** + * Returns whether the send operation was successful or not. When this method returns true, + * {@link #getMessageId()} is guaranteed to return a non-null value. When this method returns + * false {@link #getException()} is guaranteed to return a non-null value. + * + * @return A boolean indicating success of the operation. + */ + public boolean isSuccessful() { + return !Strings.isNullOrEmpty(this.messageId); + } + + static SendResponse fromMessageId(String messageId) { + checkArgument(!Strings.isNullOrEmpty(messageId), "messageId must not be null or empty"); + return new SendResponse(messageId, null); + } + + static SendResponse fromException(FirebaseMessagingException exception) { + checkNotNull(exception, "exception must not be null"); + return new SendResponse(null, exception); + } +} diff --git a/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java b/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java index 230c64df2..28664efce 100644 --- a/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java +++ b/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,11 @@ package com.google.firebase.messaging; +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; + +import com.google.api.client.json.GenericJson; +import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.firebase.internal.NonNull; @@ -32,16 +37,16 @@ public class TopicManagementResponse { // Server error codes as defined in https://developers.google.com/instance-id/reference/server // TODO: Should we handle other error codes here (e.g. PERMISSION_DENIED)? private static final Map ERROR_CODES = ImmutableMap.builder() - .put("INVALID_ARGUMENT", "invalid-argument") .put("NOT_FOUND", "registration-token-not-registered") .put("INTERNAL", "internal-error") - .put("TOO_MANY_TOPICS", "too-many-topics") .build(); private final int successCount; private final List errors; - TopicManagementResponse(List> results) { + TopicManagementResponse(List results) { + checkArgument(results != null && !results.isEmpty(), + "unexpected response from topic management service"); int successCount = 0; ImmutableList.Builder errors = ImmutableList.builder(); for (int i = 0; i < results.size(); i++) { @@ -56,6 +61,11 @@ public class TopicManagementResponse { this.errors = errors.build(); } + TopicManagementResponse(int successCount, List errors) { + this.successCount = successCount; + this.errors = ImmutableList.copyOf(errors); + } + /** * Gets the number of registration tokens that were successfully subscribed or unsubscribed. * @@ -92,10 +102,13 @@ public static class Error { private final int index; private final String reason; - private Error(int index, String reason) { + Error(int index, String reason) { this.index = index; - this.reason = ERROR_CODES.containsKey(reason) - ? ERROR_CODES.get(reason) : UNKNOWN_ERROR; + if (reason == null || reason.trim().isEmpty()) { + this.reason = UNKNOWN_ERROR; + } else { + this.reason = ERROR_CODES.getOrDefault(reason, reason.toLowerCase().replace('_', '-')); + } } /** @@ -116,5 +129,13 @@ public int getIndex() { public String getReason() { return reason; } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("index", index) + .add("reason", reason) + .toString(); + } } } diff --git a/src/main/java/com/google/firebase/messaging/WebpushConfig.java b/src/main/java/com/google/firebase/messaging/WebpushConfig.java index 7c99580c6..c73e0bee0 100644 --- a/src/main/java/com/google/firebase/messaging/WebpushConfig.java +++ b/src/main/java/com/google/firebase/messaging/WebpushConfig.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ import com.google.api.client.util.Key; import com.google.common.collect.ImmutableMap; import com.google.firebase.internal.NonNull; + import java.util.HashMap; import java.util.Map; @@ -37,10 +38,14 @@ public class WebpushConfig { @Key("notification") private final Map notification; + @Key("fcm_options") + private final WebpushFcmOptions fcmOptions; + private WebpushConfig(Builder builder) { this.headers = builder.headers.isEmpty() ? null : ImmutableMap.copyOf(builder.headers); this.data = builder.data.isEmpty() ? null : ImmutableMap.copyOf(builder.data); this.notification = builder.notification != null ? builder.notification.getFields() : null; + this.fcmOptions = builder.fcmOptions; } /** @@ -57,6 +62,8 @@ public static class Builder { private final Map headers = new HashMap<>(); private final Map data = new HashMap<>(); private WebpushNotification notification; + private WebpushFcmOptions fcmOptions; + private Builder() {} @@ -125,6 +132,17 @@ public Builder setNotification(WebpushNotification notification) { return this; } + /** + * Sets the Webpush FCM options to be included in the Webpush config. + * + * @param fcmOptions A {@link WebpushFcmOptions} instance. + * @return This builder. + */ + public Builder setFcmOptions(WebpushFcmOptions fcmOptions) { + this.fcmOptions = fcmOptions; + return this; + } + /** * Creates a new {@link WebpushConfig} instance from the parameters set on this builder. * diff --git a/src/main/java/com/google/firebase/messaging/WebpushFcmOptions.java b/src/main/java/com/google/firebase/messaging/WebpushFcmOptions.java new file mode 100644 index 000000000..bef49ec10 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/WebpushFcmOptions.java @@ -0,0 +1,62 @@ +package com.google.firebase.messaging; + +import com.google.api.client.util.Key; + +/** + * Represents options for features provided by the FCM SDK for Web. + * Can be included in {@link WebpushConfig}. Instances of this class are thread-safe and immutable. + */ +public final class WebpushFcmOptions { + + @Key("link") + private final String link; + + private WebpushFcmOptions(Builder builder) { + this.link = builder.link; + } + + /** + * Creates a new {@code WebpushFcmOptions} using given link. + * + * @param link The link to open when the user clicks on the notification. + * For all URL values, HTTPS is required. + */ + public static WebpushFcmOptions withLink(String link) { + return new Builder().setLink(link).build(); + } + + /** + * Creates a new {@link WebpushFcmOptions.Builder}. + * + * @return An {@link WebpushFcmOptions.Builder} instance. + */ + public static Builder builder() { + return new WebpushFcmOptions.Builder(); + } + + public static class Builder { + + private String link; + + private Builder() {} + + /** + * @param link The link to open when the user clicks on the notification. + * For all URL values, HTTPS is required. + * @return This builder + */ + public Builder setLink(String link) { + this.link = link; + return this; + } + + /** + * Creates a new {@link WebpushFcmOptions} instance from the parameters set on this builder. + * + * @return A new {@link WebpushFcmOptions} instance. + */ + public WebpushFcmOptions build() { + return new WebpushFcmOptions(this); + } + } +} diff --git a/src/main/java/com/google/firebase/messaging/WebpushNotification.java b/src/main/java/com/google/firebase/messaging/WebpushNotification.java index d17380543..b86393afb 100644 --- a/src/main/java/com/google/firebase/messaging/WebpushNotification.java +++ b/src/main/java/com/google/firebase/messaging/WebpushNotification.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/google/firebase/messaging/internal/MessagingServiceErrorResponse.java b/src/main/java/com/google/firebase/messaging/internal/MessagingServiceErrorResponse.java new file mode 100644 index 000000000..7c21199cc --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/internal/MessagingServiceErrorResponse.java @@ -0,0 +1,73 @@ +package com.google.firebase.messaging.internal; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.util.Key; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.internal.Nullable; +import com.google.firebase.messaging.MessagingErrorCode; +import java.util.List; +import java.util.Map; + +/** + * The DTO for parsing error responses from the FCM service. + */ +public final class MessagingServiceErrorResponse extends GenericJson { + + private static final Map MESSAGING_ERROR_CODES = + ImmutableMap.builder() + .put("APNS_AUTH_ERROR", MessagingErrorCode.THIRD_PARTY_AUTH_ERROR) + .put("INTERNAL", MessagingErrorCode.INTERNAL) + .put("INVALID_ARGUMENT", MessagingErrorCode.INVALID_ARGUMENT) + .put("QUOTA_EXCEEDED", MessagingErrorCode.QUOTA_EXCEEDED) + .put("SENDER_ID_MISMATCH", MessagingErrorCode.SENDER_ID_MISMATCH) + .put("THIRD_PARTY_AUTH_ERROR", MessagingErrorCode.THIRD_PARTY_AUTH_ERROR) + .put("UNAVAILABLE", MessagingErrorCode.UNAVAILABLE) + .put("UNREGISTERED", MessagingErrorCode.UNREGISTERED) + .build(); + + private static final String FCM_ERROR_TYPE = + "type.googleapis.com/google.firebase.fcm.v1.FcmError"; + + @Key("error") + private Map error; + + public String getStatus() { + if (error == null) { + return null; + } + + return (String) error.get("status"); + } + + + @Nullable + public MessagingErrorCode getMessagingErrorCode() { + if (error == null) { + return null; + } + + Object details = error.get("details"); + if (details instanceof List) { + for (Object detail : (List) details) { + if (detail instanceof Map) { + Map detailMap = (Map) detail; + if (FCM_ERROR_TYPE.equals(detailMap.get("@type"))) { + String errorCode = (String) detailMap.get("errorCode"); + return MESSAGING_ERROR_CODES.get(errorCode); + } + } + } + } + + return null; + } + + @Nullable + public String getErrorMessage() { + if (error != null) { + return (String) error.get("message"); + } + + return null; + } +} diff --git a/src/main/java/com/google/firebase/messaging/internal/MessagingServiceResponse.java b/src/main/java/com/google/firebase/messaging/internal/MessagingServiceResponse.java new file mode 100644 index 000000000..b3105ec61 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/internal/MessagingServiceResponse.java @@ -0,0 +1,16 @@ +package com.google.firebase.messaging.internal; + +import com.google.api.client.util.Key; + +/** + * The DTO for parsing success responses from the FCM service. + */ +public class MessagingServiceResponse { + + @Key("name") + private String messageId; + + public String getMessageId() { + return this.messageId; + } +} diff --git a/src/main/java/com/google/firebase/messaging/internal/package-info.java b/src/main/java/com/google/firebase/messaging/internal/package-info.java new file mode 100644 index 000000000..5c55f2534 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/internal/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2019 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. + */ + +/** + * @hide + */ +package com.google.firebase.messaging.internal; diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java new file mode 100644 index 000000000..6960f5038 --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java @@ -0,0 +1,84 @@ +/* + * Copyright 2026 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.phonenumberverification; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.internal.FirebaseService; +import com.google.firebase.phonenumberverification.internal.FirebasePhoneNumberVerificationTokenVerifier; + +/** + * This class is the entry point for the Firebase Phone Number Verification service. + * + *

You can get an instance of {@link FirebasePhoneNumberVerification} via {@link #getInstance()}, + * or {@link #getInstance(FirebaseApp)}. + */ +public final class FirebasePhoneNumberVerification { + private static final String SERVICE_ID = FirebasePhoneNumberVerification.class.getName(); + private final FirebasePhoneNumberVerificationTokenVerifier tokenVerifier; + + private FirebasePhoneNumberVerification(FirebaseApp app) { + this.tokenVerifier = new FirebasePhoneNumberVerificationTokenVerifier(app); + } + + /** + * Gets the {@link FirebasePhoneNumberVerification} instance for the default {@link FirebaseApp}. + * + * @return The {@link FirebasePhoneNumberVerification} instance for the default + * {@link FirebaseApp}. + */ + public static FirebasePhoneNumberVerification getInstance() { + return getInstance(FirebaseApp.getInstance()); + } + + /** + * Gets the {@link FirebasePhoneNumberVerification} instance for the specified + * {@link FirebaseApp}. + * + * @return The {@link FirebasePhoneNumberVerification} instance for the specified + * {@link FirebaseApp}. + */ + public static synchronized FirebasePhoneNumberVerification getInstance(FirebaseApp app) { + FirebasePhoneNumberVerificationService service = + ImplFirebaseTrampolines.getService(app, SERVICE_ID, + FirebasePhoneNumberVerificationService.class); + if (service == null) { + service = ImplFirebaseTrampolines.addService( + app, new FirebasePhoneNumberVerificationService(app)); + } + return service.getInstance(); + } + + /** + * Verifies a Firebase Phone Number Verification token (JWT). + * + * @param phoneNumberVerificationJwt The JWT string to verify. + * @return A verified {@link FirebasePhoneNumberVerificationToken}. + * @throws FirebasePhoneNumberVerificationException If verification fails. + */ + public FirebasePhoneNumberVerificationToken verifyToken(String phoneNumberVerificationJwt) + throws FirebasePhoneNumberVerificationException { + return this.tokenVerifier.verifyToken(phoneNumberVerificationJwt); + } + + private static class FirebasePhoneNumberVerificationService + extends FirebaseService { + FirebasePhoneNumberVerificationService(FirebaseApp app) { + super(SERVICE_ID, new FirebasePhoneNumberVerification(app)); + } + } +} diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCode.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCode.java new file mode 100644 index 000000000..767f91c1e --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCode.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 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.phonenumberverification; + +/** + * Error codes that can be raised by the Phone Number Verification APIs. + */ +public enum FirebasePhoneNumberVerificationErrorCode { + + /** + * One or more arguments specified in the request were invalid. + */ + INVALID_ARGUMENT, + + /** + * The provided phone number verification token is invalid or malformed. + */ + INVALID_TOKEN, + + /** + * The provided phone number verification token has expired. + */ + TOKEN_EXPIRED, + + /** + * Internal error encountered during phone number verification. + */ + INTERNAL_ERROR, + + /** + * Phone number verification service is temporarily unavailable. + */ + SERVICE_ERROR, +} diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java new file mode 100644 index 000000000..c2e1ddfe7 --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026 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.phonenumberverification; + +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; + +/** + * Generic exception related to Firebase Phone Number Verification. Check the error code and message + * for more details. + */ +public class FirebasePhoneNumberVerificationException extends FirebaseException { + + private final FirebasePhoneNumberVerificationErrorCode errorCode; + + public FirebasePhoneNumberVerificationException( + @NonNull ErrorCode errorCode, + @NonNull String message, + Throwable cause, + IncomingHttpResponse response, + FirebasePhoneNumberVerificationErrorCode phoneErrorCode) { + super(errorCode, message, cause, response); + this.errorCode = phoneErrorCode; + } + + public FirebasePhoneNumberVerificationException(FirebaseException base) { + this(base.getErrorCode(), base.getMessage(), base.getCause(), base.getHttpResponse(), null); + } + + @Nullable + public FirebasePhoneNumberVerificationErrorCode getPhoneNumberVerificationErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java new file mode 100644 index 000000000..15404cdc2 --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java @@ -0,0 +1,102 @@ +/* + * Copyright 2026 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.phonenumberverification; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.List; +import java.util.Map; + +/** + * Represents a verified Firebase Phone Number Verification token. + */ +public class FirebasePhoneNumberVerificationToken { + private final Map claims; + + /** + * Create an instance of {@link FirebasePhoneNumberVerificationToken} from a map of JWT claims. + * + * @param claims A map of JWT claims. + */ + public FirebasePhoneNumberVerificationToken(Map claims) { + checkNotNull(claims, "Claims map must not be null"); + checkArgument(claims.containsKey("sub"), "Claims map must contain sub"); + this.claims = ImmutableMap.copyOf(claims); + } + + /** + * Returns the issuer identifier for the issuer of the response. + */ + public String getIssuer() { + return (String) claims.get("iss"); + } + + /** + * Returns the phone number of the user. + * This corresponds to the 'sub' claim in the JWT. + */ + public String getPhoneNumber() { + return (String) claims.get("sub"); + } + + /** + * Returns the audience for which this token is intended. + */ + public List getAudience() { + Object audience = claims.get("aud"); + if (audience instanceof String) { + return ImmutableList.of((String) audience); + } else if (audience instanceof List) { + @SuppressWarnings("unchecked") + List audienceList = (List) audience; + return ImmutableList.copyOf(audienceList); + } + return ImmutableList.of(); + } + + /** + * Returns the expiration time in seconds since the Unix epoch. + */ + public long getExpirationTime() { + Object exp = claims.get("exp"); + if (exp instanceof java.util.Date) { + return ((java.util.Date) exp).getTime() / 1000L; + } + return exp instanceof Number ? ((Number) exp).longValue() : 0L; + } + + /** + * Returns the issued-at time in seconds since the Unix epoch. + */ + public long getIssuedAt() { + Object iat = claims.get("iat"); + if (iat instanceof java.util.Date) { + return ((java.util.Date) iat).getTime() / 1000L; + } + return iat instanceof Number ? ((Number) iat).longValue() : 0L; + } + + /** + * Returns the entire map of claims. + */ + public Map getClaims() { + return claims; + } +} diff --git a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java new file mode 100644 index 000000000..c51c8f9bf --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java @@ -0,0 +1,252 @@ +/* + * Copyright 2026 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.phonenumberverification.internal; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Strings; +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationErrorCode; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationException; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationToken; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import com.nimbusds.jwt.proc.ExpiredJWTException; +import java.net.MalformedURLException; +import java.net.URI; +import java.text.ParseException; + +/** + * Internal class to verify Firebase Phone Number Verification tokens. + */ +public class FirebasePhoneNumberVerificationTokenVerifier { + private static final String FPNV_JWKS_URL = "https://fpnv.googleapis.com/v1beta/jwks"; + private static final String HEADER_TYP = "JWT"; + + private final String projectId; + private volatile DefaultJWTProcessor jwtProcessor; + + /** + * Create {@link FirebasePhoneNumberVerificationTokenVerifier} for internal purposes. + * + * @param app The {@link FirebaseApp} to get a project ID from. + */ + public FirebasePhoneNumberVerificationTokenVerifier(FirebaseApp app) { + this.projectId = getProjectId(app); + } + + /** + * Package-private constructor designed explicitly for dependency injection + * during isolated unit testing flows. + */ + FirebasePhoneNumberVerificationTokenVerifier( + String projectId, DefaultJWTProcessor jwtProcessor) { + this.projectId = projectId; + this.jwtProcessor = jwtProcessor; + } + + private DefaultJWTProcessor getJwtProcessor() { + DefaultJWTProcessor processor = this.jwtProcessor; + if (processor == null) { + synchronized (this) { + processor = this.jwtProcessor; + if (processor == null) { + processor = createJwtProcessor(); + this.jwtProcessor = processor; + } + } + } + return processor; + } + + /** + * Main method that performs token verification steps. + * + * @param token String input data + * @return {@link FirebasePhoneNumberVerificationToken} + * @throws FirebasePhoneNumberVerificationException If verification fails + */ + public FirebasePhoneNumberVerificationToken verifyToken(String token) + throws FirebasePhoneNumberVerificationException { + checkArgument(!Strings.isNullOrEmpty(token), + "Firebase Phone Number Verification token must not be null or empty"); + + try { + SignedJWT signedJwt = SignedJWT.parse(token); + verifyHeader(signedJwt.getHeader()); + + JWTClaimsSet claims = getJwtProcessor().process(signedJwt, null); + verifyClaims(claims); + + return new FirebasePhoneNumberVerificationToken(claims.getClaims()); + } catch (ParseException e) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Failed to parse JWT token: " + e.getMessage(), + e + ); + } catch (ExpiredJWTException e) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.TOKEN_EXPIRED, + "Firebase Phone Number Verification token has expired.", + e + ); + } catch (BadJOSEException e) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Check your project: " + projectId + ". " + + "Firebase Phone Number Verification token is invalid: " + + e.getMessage(), + e + ); + } catch (JOSEException e) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INTERNAL_ERROR, + "Check your project: " + projectId + ". Failed to verify " + + "Firebase Phone Number Verification token signature: " + e.getMessage(), + e + ); + } + } + + private void verifyHeader(JWSHeader header) throws FirebasePhoneNumberVerificationException { + if (!JWSAlgorithm.ES256.equals(header.getAlgorithm())) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + "Firebase Phone Number Verification token has incorrect 'algorithm'. " + + "Expected " + JWSAlgorithm.ES256.getName() + " but got " + header.getAlgorithm()); + } + if (Strings.isNullOrEmpty(header.getKeyID())) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + "Firebase Phone Number Verification token has no 'kid' claim." + ); + } + if (!JOSEObjectType.JWT.equals(header.getType())) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + "Firebase Phone Number Verification token has incorrect 'typ'. Expected " + HEADER_TYP + + " but got " + header.getType() + ); + } + } + + private void verifyClaims(JWTClaimsSet claims) throws FirebasePhoneNumberVerificationException { + checkNotNull(claims, "JWTClaimsSet claims must not be null"); + String issuer = claims.getIssuer(); + + if (Strings.isNullOrEmpty(issuer)) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + "Firebase Phone Number Verification token has no 'iss' (issuer) claim."); + } + + String expectedIssuer = "https://fpnv.googleapis.com/projects/" + this.projectId; + if (!expectedIssuer.equals(issuer)) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Firebase Phone Number Verification token has an incorrect 'iss' (issuer) claim."); + } + + if (claims.getAudience().isEmpty() || !claims.getAudience().contains(issuer)) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Invalid audience. Expected to contain: " + issuer + + " but found: " + claims.getAudience() + ); + } + + if (Strings.isNullOrEmpty(claims.getSubject())) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Token has an empty 'sub' (phone number)." + ); + } + } + + private DefaultJWTProcessor createJwtProcessor() { + DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); + try { + JWKSource keySource = createKeySource(); + JWSKeySelector keySelector = + new JWSVerificationKeySelector<>(JWSAlgorithm.ES256, keySource); + processor.setJWSKeySelector(keySelector); + } catch (MalformedURLException e) { + throw new IllegalStateException("Invalid JWKS URL", e); + } + return processor; + } + + protected JWKSource createKeySource() throws MalformedURLException { + return JWKSourceBuilder + .create(URI.create(FPNV_JWKS_URL).toURL()) + .retrying(true) + .build(); + } + + private String getProjectId(FirebaseApp app) { + String projectId = ImplFirebaseTrampolines.getProjectId(app); + if (Strings.isNullOrEmpty(projectId)) { + throw new IllegalArgumentException("Project ID is required in FirebaseOptions."); + } + return projectId; + } + + private FirebasePhoneNumberVerificationException newException( + FirebasePhoneNumberVerificationErrorCode errorCode, String message) { + return newException(errorCode, message, null); + } + + private FirebasePhoneNumberVerificationException newException( + FirebasePhoneNumberVerificationErrorCode errorCode, String message, Throwable cause) { + ErrorCode baseCode = ErrorCode.INTERNAL; + if (errorCode != null) { + switch (errorCode) { + case INVALID_ARGUMENT: + baseCode = ErrorCode.INVALID_ARGUMENT; + break; + case TOKEN_EXPIRED: + case INVALID_TOKEN: + baseCode = ErrorCode.UNAUTHENTICATED; + break; + case SERVICE_ERROR: + baseCode = ErrorCode.UNAVAILABLE; + break; + case INTERNAL_ERROR: + default: + baseCode = ErrorCode.INTERNAL; + break; + } + } + return new FirebasePhoneNumberVerificationException( + baseCode, message, cause, null, errorCode); + } +} diff --git a/src/main/java/com/google/firebase/projectmanagement/AndroidApp.java b/src/main/java/com/google/firebase/projectmanagement/AndroidApp.java new file mode 100644 index 000000000..891280c72 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/AndroidApp.java @@ -0,0 +1,166 @@ +/* Copyright 2018 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.projectmanagement; + +import com.google.api.core.ApiFuture; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import java.util.List; + +/** + * An instance of this class is a reference to an Android App within a Firebase Project; it can be + * used to query detailed information about the App, modify the display name of the App, or download + * the configuration file for the App. + * + *

Note: the methods in this class make RPCs. + */ +public class AndroidApp { + + private final AndroidAppService androidAppService; + private final String appId; + + AndroidApp(String appId, AndroidAppService androidAppService) { + Preconditions.checkArgument(!Strings.isNullOrEmpty(appId), "app ID cannot be null or empty"); + this.appId = appId; + this.androidAppService = androidAppService; + } + + String getAppId() { + return appId; + } + + /** + * Retrieves detailed information about this Android App. + * + * @return an {@link AndroidAppMetadata} instance describing this App + * @throws FirebaseProjectManagementException if there was an error during the RPC + */ + public AndroidAppMetadata getMetadata() throws Exception { + return androidAppService.getAndroidApp(appId); + } + + /** + * Asynchronously retrieves information about this Android App. + * + * @return an {@code ApiFuture} containing an {@link AndroidAppMetadata} instance describing this + * App + */ + public ApiFuture getMetadataAsync() { + return androidAppService.getAndroidAppAsync(appId); + } + + /** + * Updates the Display Name attribute of this Android App to the one given. + * + * @throws FirebaseProjectManagementException if there was an error during the RPC + */ + public void setDisplayName(String newDisplayName) throws FirebaseProjectManagementException { + androidAppService.setAndroidDisplayName(appId, newDisplayName); + } + + /** + * Asynchronously updates the Display Name attribute of this Android App to the one given. + */ + public ApiFuture setDisplayNameAsync(String newDisplayName) { + return androidAppService.setAndroidDisplayNameAsync(appId, newDisplayName); + } + + /** + * Retrieves the configuration artifact associated with this Android App. + * + * @return a modified UTF-8 encoded {@code String} containing the contents of the artifact + * @throws FirebaseProjectManagementException if there was an error during the RPC + */ + public String getConfig() throws FirebaseProjectManagementException { + return androidAppService.getAndroidConfig(appId); + } + + /** + * Asynchronously retrieves the configuration artifact associated with this Android App. + * + * @return an {@code ApiFuture} of a UTF-8 encoded {@code String} containing the contents of the + * artifact + */ + public ApiFuture getConfigAsync() { + return androidAppService.getAndroidConfigAsync(appId); + } + + /** + * Retrieves the entire list of SHA certificates associated with this Android app. + * + * @return a list of {@link ShaCertificate} containing resource name, SHA hash and certificate + * type + * @throws FirebaseProjectManagementException if there was an error during the RPC + */ + public List getShaCertificates() throws FirebaseProjectManagementException { + return androidAppService.getShaCertificates(appId); + } + + /** + * Asynchronously retrieves the entire list of SHA certificates associated with this Android app. + * + * @return an {@code ApiFuture} of a list of {@link ShaCertificate} containing resource name, + * SHA hash and certificate type + */ + public ApiFuture> getShaCertificatesAsync() { + return androidAppService.getShaCertificatesAsync(appId); + } + + /** + * Adds the given SHA certificate to this Android app. + * + * @param certificateToAdd the SHA certificate to be added to this Android app + * @return a {@link ShaCertificate} that was created for this Android app, containing resource + * name, SHA hash, and certificate type + * @throws FirebaseProjectManagementException if there was an error during the RPC + */ + public ShaCertificate createShaCertificate(ShaCertificate certificateToAdd) + throws FirebaseProjectManagementException { + return androidAppService.createShaCertificate(appId, certificateToAdd); + } + + /** + * Asynchronously adds the given SHA certificate to this Android app. + * + * @param certificateToAdd the SHA certificate to be added to this Android app + * @return a {@code ApiFuture} of a {@link ShaCertificate} that was created for this Android app, + * containing resource name, SHA hash, and certificate type + */ + public ApiFuture createShaCertificateAsync(ShaCertificate certificateToAdd) { + return androidAppService.createShaCertificateAsync(appId, certificateToAdd); + } + + /** + * Removes the given SHA certificate from this Android app. + * + * @param certificateToRemove the SHA certificate to be removed from this Android app + * @throws FirebaseProjectManagementException if there was an error during the RPC + */ + public void deleteShaCertificate(ShaCertificate certificateToRemove) + throws FirebaseProjectManagementException { + androidAppService.deleteShaCertificate(certificateToRemove.getName()); + } + + /** + * Asynchronously removes the given SHA certificate from this Android app. + * + * @param certificateToRemove the SHA certificate to be removed from this Android app + */ + public ApiFuture deleteShaCertificateAsync(ShaCertificate certificateToRemove) { + return androidAppService.deleteShaCertificateAsync(certificateToRemove.getName()); + } + +} diff --git a/src/main/java/com/google/firebase/projectmanagement/AndroidAppMetadata.java b/src/main/java/com/google/firebase/projectmanagement/AndroidAppMetadata.java new file mode 100644 index 000000000..61263fc12 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/AndroidAppMetadata.java @@ -0,0 +1,114 @@ +/* Copyright 2018 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.projectmanagement; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Objects; +import com.google.common.base.Preconditions; +import com.google.firebase.internal.Nullable; + +/** + * Contains detailed information about an Android App. Instances of this class are immutable. + */ +public class AndroidAppMetadata { + + private final String name; + private final String appId; + private final String displayName; + private final String projectId; + private final String packageName; + + AndroidAppMetadata( + String name, String appId, String displayName, String projectId, String packageName) { + this.name = Preconditions.checkNotNull(name, "Null name"); + this.appId = Preconditions.checkNotNull(appId, "Null appId"); + this.displayName = displayName; + this.projectId = Preconditions.checkNotNull(projectId, "Null projectId"); + this.packageName = Preconditions.checkNotNull(packageName, "Null packageName"); + } + + /** + * Returns the fully qualified resource name of this Android App. + */ + String getName() { + return name; + } + + /** + * Returns the globally unique, Firebase-assigned identifier of this Android App. This ID is + * unique even across Apps of different platforms, such as iOS Apps. + */ + public String getAppId() { + return appId; + } + + /** + * Returns the user-assigned display name of this Android App. Returns {@code null} if it has + * never been set. + */ + @Nullable + public String getDisplayName() { + return displayName; + } + + /** + * Returns the permanent, globally unique, user-assigned ID of the parent Project for this Android + * App. + */ + public String getProjectId() { + return projectId; + } + + /** + * Returns the canonical package name of this Android app as it would appear in Play store. + */ + public String getPackageName() { + return packageName; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper("AndroidAppMetadata") + .add("name", name) + .add("appId", appId) + .add("displayName", displayName) + .add("projectId", projectId) + .add("packageName", packageName) + .toString(); + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o instanceof AndroidAppMetadata) { + AndroidAppMetadata that = (AndroidAppMetadata) o; + return Objects.equal(this.name, that.name) + && Objects.equal(this.appId, that.appId) + && Objects.equal(this.displayName, that.displayName) + && Objects.equal(this.projectId, that.projectId) + && Objects.equal(this.packageName, that.packageName); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(name, appId, displayName, projectId, packageName); + } + +} diff --git a/src/main/java/com/google/firebase/projectmanagement/AndroidAppService.java b/src/main/java/com/google/firebase/projectmanagement/AndroidAppService.java new file mode 100644 index 000000000..d04314b4e --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/AndroidAppService.java @@ -0,0 +1,175 @@ +/* Copyright 2018 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.projectmanagement; + +import com.google.api.core.ApiFuture; +import java.util.List; + +/** + * An interface to interact with the Android-specific functionalities in the Firebase Project + * Management Service. + * + *

Note: Implementations of methods in this service may make RPCs. + */ +interface AndroidAppService { + /** + * Creates a new Android App in the given project. + * + * @param projectId the Project ID of the project in which to create the App + * @param packageName the package name of the Android App to be created + * @param displayName the user-defined display name for the Android App to be created + * @return an {@link AndroidApp} reference + */ + AndroidApp createAndroidApp(String projectId, String packageName, String displayName) + throws FirebaseProjectManagementException; + + /** + * Creates a new Android App in the given project. + * + * @param projectId the Project ID of the project in which to create the App + * @param packageName the package name of the Android App to be created + * @param displayName the user-defined display name for the Android App to be created + * @return an {@link AndroidApp} reference + */ + ApiFuture createAndroidAppAsync( + String projectId, String packageName, String displayName); + + /** + * Retrieve information about an existing Android App, identified by its App ID. + * + * @param appId the App ID of the Android App + * @return an {@link AndroidAppMetadata} instance describing the Android App + */ + AndroidAppMetadata getAndroidApp(String appId) throws FirebaseProjectManagementException; + + /** + * Asynchronously retrieves information about an existing Android App, identified by its App ID. + * + * @param appId the App ID of the iOS App + * @return an {@link AndroidAppMetadata} instance describing the Android App + */ + ApiFuture getAndroidAppAsync(String appId); + + /** + * Lists all the Android Apps belonging to the given project. The returned list cannot be + * modified. + * + * @param projectId the Project ID of the project + * @return a read-only list of {@link AndroidApp} references + */ + List listAndroidApps(String projectId) throws FirebaseProjectManagementException; + + /** + * Asynchronously lists all the Android Apps belonging to the given project. The returned list + * cannot be modified. + * + * @param projectId the project ID of the project + * @return an {@link ApiFuture} of a read-only list of {@link AndroidApp} references + */ + ApiFuture> listAndroidAppsAsync(String projectId); + + /** + * Updates the Display Name of the given Android App. + * + * @param appId the App ID of the Android App + * @param newDisplayName the new Display Name + */ + void setAndroidDisplayName(String appId, String newDisplayName) + throws FirebaseProjectManagementException; + + /** + * Asynchronously updates the Display Name of the given Android App. + * + * @param appId the App ID of the iOS App + * @param newDisplayName the new Display Name + */ + ApiFuture setAndroidDisplayNameAsync(String appId, String newDisplayName); + + /** + * Retrieves the configuration artifact associated with the specified Android App. + * + * @param appId the App ID of the Android App + * @return a modified UTF-8 encoded {@code String} containing the contents of the artifact + */ + String getAndroidConfig(String appId) throws FirebaseProjectManagementException; + + /** + * Asynchronously retrieves the configuration artifact associated with the specified Android App. + * + * @param appId the App ID of the Android App + * @return an {@link ApiFuture} of a modified UTF-8 encoded {@code String} containing the contents + * of the artifact + */ + ApiFuture getAndroidConfigAsync(String appId); + + /** + * Retrieves the entire list of SHA certificates associated with this Android App. + * + * @param appId the App ID of the Android App + * @return a list of {@link ShaCertificate} containing resource name, SHA hash and certificate + * type + * @throws FirebaseProjectManagementException if there was an error during the RPC + */ + List getShaCertificates(String appId) throws FirebaseProjectManagementException; + + /** + * Asynchronously retrieves the entire list of SHA certificates associated with this Android App. + * + * @param appId the App ID of the Android App + * @return an {@link ApiFuture} of a list of {@link ShaCertificate} containing resource name, + * SHA hash and certificate type + */ + ApiFuture> getShaCertificatesAsync(String appId); + + + /** + * Adds a SHA certificate to this Android App. + * + * @param appId the App ID of the Android App + * @param certificateToAdd the SHA certificate to be added to this Android App + * @return a {@link ShaCertificate} that was created for this Android App, containing resource + * name, SHA hash, and certificate type + * @throws FirebaseProjectManagementException if there was an error during the RPC + */ + ShaCertificate createShaCertificate(String appId, ShaCertificate certificateToAdd) + throws FirebaseProjectManagementException; + + /** + * Asynchronously adds a SHA certificate to this Android App. + * + * @param appId the App ID of the Android App + * @param certificateToAdd the SHA certificate to be added to this Android App + * @return a {@link ApiFuture} of a {@link ShaCertificate} that was created for this Android App, + * containing resource name, SHA hash, and certificate type + */ + ApiFuture createShaCertificateAsync( + String appId, ShaCertificate certificateToAdd); + + /** + * Removes a SHA certificate from this Android App. + * + * @param resourceName the fully qualified resource name of the SHA certificate + * @throws FirebaseProjectManagementException if there was an error during the RPC + */ + void deleteShaCertificate(String resourceName) throws FirebaseProjectManagementException; + + /** + * Asynchronously removes a SHA certificate from this Android App. + * + * @param resourceName the fully qualified resource name of the SHA certificate + */ + ApiFuture deleteShaCertificateAsync(String resourceName); +} diff --git a/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagement.java b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagement.java new file mode 100644 index 000000000..be60f0b70 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagement.java @@ -0,0 +1,290 @@ +/* + * Copyright 2018 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.projectmanagement; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.core.ApiFuture; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.internal.FirebaseService; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import java.util.List; + +/** + * This class is the entry point for all Firebase Project Management actions. + * + *

You can get an instance of FirebaseProjectManagement via {@link #getInstance(FirebaseApp)}, + * and then use it to modify or retrieve information about your Firebase project, as well as create, + * modify, or retrieve information about the Android or iOS Apps in your Firebase project. + */ +public class FirebaseProjectManagement { + private static final String SERVICE_ID = FirebaseProjectManagement.class.getName(); + + private static final Object GET_INSTANCE_LOCK = new Object(); + + private final String projectId; + private AndroidAppService androidAppService; + private IosAppService iosAppService; + + private FirebaseProjectManagement(String projectId) { + checkArgument(!Strings.isNullOrEmpty(projectId), + "Project ID is required to access the Firebase Project Management service. Use a service " + + "account credential or set the project ID explicitly via FirebaseOptions. " + + "Alternatively you can also set the project ID via the GOOGLE_CLOUD_PROJECT " + + "environment variable."); + + this.projectId = projectId; + } + + @VisibleForTesting + void setAndroidAppService(AndroidAppService androidAppService) { + this.androidAppService = androidAppService; + } + + @VisibleForTesting + void setIosAppService(IosAppService iosAppService) { + this.iosAppService = iosAppService; + } + + /** + * Gets the {@link FirebaseProjectManagement} instance for the default {@link FirebaseApp}. + * + * @return the {@link FirebaseProjectManagement} instance for the default {@link FirebaseApp} + */ + @NonNull + public static FirebaseProjectManagement getInstance() { + return getInstance(FirebaseApp.getInstance()); + } + + /** + * Gets the {@link FirebaseProjectManagement} instance for the specified {@link FirebaseApp}. + * + * @return the {@link FirebaseProjectManagement} instance for the specified {@link FirebaseApp} + */ + @NonNull + public static FirebaseProjectManagement getInstance(FirebaseApp app) { + synchronized (GET_INSTANCE_LOCK) { + FirebaseProjectManagementService service = ImplFirebaseTrampolines.getService( + app, SERVICE_ID, FirebaseProjectManagementService.class); + if (service == null) { + service = + ImplFirebaseTrampolines.addService(app, new FirebaseProjectManagementService(app)); + } + return service.getInstance(); + } + } + + /* Android App */ + + /** + * Obtains an {@link AndroidApp} reference to an Android App in the associated Firebase project. + * + * @param appId the App ID that identifies this Android App. + * @see AndroidApp + */ + @NonNull + public AndroidApp getAndroidApp(@NonNull String appId) { + return new AndroidApp(appId, androidAppService); + } + + /** + * Lists all Android Apps in the associated Firebase project, returning a list of {@link + * AndroidApp} references to each. This returned list is read-only and cannot be modified. + * + * @throws FirebaseProjectManagementException if there was an error during the RPC + * @see AndroidApp + */ + @NonNull + public List listAndroidApps() throws FirebaseProjectManagementException { + return androidAppService.listAndroidApps(projectId); + } + + /** + * Asynchronously lists all Android Apps in the associated Firebase project, returning an {@code + * ApiFuture} of a list of {@link AndroidApp} references to each. This returned list is read-only + * and cannot be modified. + * + * @see AndroidApp + */ + @NonNull + public ApiFuture> listAndroidAppsAsync() { + return androidAppService.listAndroidAppsAsync(projectId); + } + + /** + * Creates a new Android App in the associated Firebase project and returns an {@link AndroidApp} + * reference to it. + * + * @param packageName the package name of the Android App to be created + * @throws FirebaseProjectManagementException if there was an error during the RPC + * @see AndroidApp + */ + @NonNull + public AndroidApp createAndroidApp(@NonNull String packageName) + throws FirebaseProjectManagementException { + return createAndroidApp(packageName, /* displayName= */ null); + } + + /** + * Creates a new Android App in the associated Firebase project and returns an {@link AndroidApp} + * reference to it. + * + * @param packageName the package name of the Android App to be created + * @param displayName a nickname for this Android App + * @throws FirebaseProjectManagementException if there was an error during the RPC + * @see AndroidApp + */ + @NonNull + public AndroidApp createAndroidApp(@NonNull String packageName, @Nullable String displayName) + throws FirebaseProjectManagementException { + return androidAppService.createAndroidApp(projectId, packageName, displayName); + } + + /** + * Asynchronously creates a new Android App in the associated Firebase project and returns an + * {@code ApiFuture} that will eventually contain the {@link AndroidApp} reference to it. + * + * @param packageName the package name of the Android App to be created + * @see AndroidApp + */ + @NonNull + public ApiFuture createAndroidAppAsync(@NonNull String packageName) { + return createAndroidAppAsync(packageName, /* displayName= */ null); + } + + /** + * Asynchronously creates a new Android App in the associated Firebase project and returns an + * {@code ApiFuture} that will eventually contain the {@link AndroidApp} reference to it. + * + * @param packageName the package name of the Android App to be created + * @param displayName a nickname for this Android App + * @see AndroidApp + */ + @NonNull + public ApiFuture createAndroidAppAsync( + @NonNull String packageName, @Nullable String displayName) { + return androidAppService.createAndroidAppAsync(projectId, packageName, displayName); + } + + /* iOS App */ + + /** + * Obtains an {@link IosApp} reference to an iOS App in the associated Firebase project. + * + * @param appId the App ID that identifies this iOS App. + * @see IosApp + */ + @NonNull + public IosApp getIosApp(@NonNull String appId) { + return new IosApp(appId, iosAppService); + } + + /** + * Lists all iOS Apps in the associated Firebase project, returning a list of {@link IosApp} + * references to each. This returned list is read-only and cannot be modified. + * + * @throws FirebaseProjectManagementException if there was an error during the RPC + * @see IosApp + */ + @NonNull + public List listIosApps() throws FirebaseProjectManagementException { + return iosAppService.listIosApps(projectId); + } + + /** + * Asynchronously lists all iOS Apps in the associated Firebase project, returning an {@code + * ApiFuture} of a list of {@link IosApp} references to each. This returned list is read-only and + * cannot be modified. + * + * @see IosApp + */ + @NonNull + public ApiFuture> listIosAppsAsync() { + return iosAppService.listIosAppsAsync(projectId); + } + + /** + * Creates a new iOS App in the associated Firebase project and returns an {@link IosApp} + * reference to it. + * + * @param bundleId the bundle ID of the iOS App to be created + * @throws FirebaseProjectManagementException if there was an error during the RPC + * @see IosApp + */ + @NonNull + public IosApp createIosApp(@NonNull String bundleId) throws FirebaseProjectManagementException { + return createIosApp(bundleId, /* displayName= */ null); + } + + /** + * Creates a new iOS App in the associated Firebase project and returns an {@link IosApp} + * reference to it. + * + * @param bundleId the bundle ID of the iOS App to be created + * @param displayName a nickname for this iOS App + * @throws FirebaseProjectManagementException if there was an error during the RPC + * @see IosApp + */ + @NonNull + public IosApp createIosApp(@NonNull String bundleId, @Nullable String displayName) + throws FirebaseProjectManagementException { + return iosAppService.createIosApp(projectId, bundleId, displayName); + } + + /** + * Asynchronously creates a new iOS App in the associated Firebase project and returns an {@code + * ApiFuture} that will eventually contain the {@link IosApp} reference to it. + * + * @param bundleId the bundle ID of the iOS App to be created + * @see IosApp + */ + @NonNull + public ApiFuture createIosAppAsync(@NonNull String bundleId) { + return createIosAppAsync(bundleId, /* displayName= */ null); + } + + /** + * Asynchronously creates a new iOS App in the associated Firebase project and returns an {@code + * ApiFuture} that will eventually contain the {@link IosApp} reference to it. + * + * @param bundleId the bundle ID of the iOS App to be created + * @param displayName a nickname for this iOS App + * @see IosApp + */ + @NonNull + public ApiFuture createIosAppAsync( + @NonNull String bundleId, @Nullable String displayName) { + return iosAppService.createIosAppAsync(projectId, bundleId, displayName); + } + + private static class FirebaseProjectManagementService + extends FirebaseService { + private final FirebaseProjectManagementServiceImpl serviceImpl; + + private FirebaseProjectManagementService(FirebaseApp app) { + super(SERVICE_ID, new FirebaseProjectManagement(ImplFirebaseTrampolines.getProjectId(app))); + serviceImpl = new FirebaseProjectManagementServiceImpl(app); + FirebaseProjectManagement serviceInstance = getInstance(); + serviceInstance.setAndroidAppService(serviceImpl); + serviceInstance.setIosAppService(serviceImpl); + } + } +} diff --git a/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementException.java b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementException.java new file mode 100644 index 000000000..04fc48c2f --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementException.java @@ -0,0 +1,41 @@ +/* Copyright 2018 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.projectmanagement; + +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.database.annotations.Nullable; +import com.google.firebase.internal.NonNull; + +/** + * An exception encountered while interacting with the Firebase Project Management Service. + */ +public final class FirebaseProjectManagementException extends FirebaseException { + + FirebaseProjectManagementException(@NonNull FirebaseException base) { + this(base, base.getMessage()); + } + + FirebaseProjectManagementException(@NonNull FirebaseException base, @NonNull String message) { + super(base.getErrorCode(), message, base.getCause(), base.getHttpResponse()); + } + + FirebaseProjectManagementException( + @NonNull ErrorCode code, @NonNull String message, @Nullable IncomingHttpResponse response) { + super(code, message, null, response); + } +} diff --git a/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java new file mode 100644 index 000000000..1387d2226 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java @@ -0,0 +1,790 @@ +/* Copyright 2018 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.projectmanagement; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponseInterceptor; +import com.google.api.client.util.Base64; +import com.google.api.client.util.Key; +import com.google.api.client.util.Sleeper; +import com.google.api.core.ApiAsyncFunction; +import com.google.api.core.ApiFunction; +import com.google.api.core.ApiFuture; +import com.google.api.core.SettableApiFuture; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.CallableOperation; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +class FirebaseProjectManagementServiceImpl implements AndroidAppService, IosAppService { + + @VisibleForTesting static final String FIREBASE_PROJECT_MANAGEMENT_URL = + "https://firebase.googleapis.com"; + @VisibleForTesting static final int MAXIMUM_LIST_APPS_PAGE_SIZE = 100; + + private static final int MAXIMUM_POLLING_ATTEMPTS = 7; + private static final long POLL_BASE_WAIT_TIME_MILLIS = 500L; + private static final double POLL_EXPONENTIAL_BACKOFF_FACTOR = 1.5; + private static final String ANDROID_APPS_RESOURCE_NAME = "androidApps"; + private static final String IOS_APPS_RESOURCE_NAME = "iosApps"; + private static final String ANDROID_NAMESPACE_PROPERTY = "package_name"; + private static final String IOS_NAMESPACE_PROPERTY = "bundle_id"; + + private final FirebaseApp app; + private final Sleeper sleeper; + private final Scheduler scheduler; + private final HttpHelper httpHelper; + + private final CreateAndroidAppFromAppIdFunction createAndroidAppFromAppIdFunction = + new CreateAndroidAppFromAppIdFunction(); + private final CreateIosAppFromAppIdFunction createIosAppFromAppIdFunction = + new CreateIosAppFromAppIdFunction(); + + FirebaseProjectManagementServiceImpl(FirebaseApp app) { + this( + app, + Sleeper.DEFAULT, + new FirebaseAppScheduler(app), + ApiClientUtils.newAuthorizedRequestFactory(app)); + } + + @VisibleForTesting + FirebaseProjectManagementServiceImpl( + FirebaseApp app, Sleeper sleeper, Scheduler scheduler, HttpRequestFactory requestFactory) { + this.app = checkNotNull(app); + this.sleeper = checkNotNull(sleeper); + this.scheduler = checkNotNull(scheduler); + this.httpHelper = new HttpHelper(app.getOptions().getJsonFactory(), requestFactory); + } + + @VisibleForTesting + void setInterceptor(HttpResponseInterceptor interceptor) { + httpHelper.setInterceptor(interceptor); + } + + /* getAndroidApp */ + + @Override + public AndroidAppMetadata getAndroidApp(String appId) throws FirebaseProjectManagementException { + return getAndroidAppOp(appId).call(); + } + + @Override + public ApiFuture getAndroidAppAsync(String appId) { + return getAndroidAppOp(appId).callAsync(app); + } + + private CallableOperation getAndroidAppOp( + final String appId) { + return new CallableOperation() { + @Override + protected AndroidAppMetadata execute() throws FirebaseProjectManagementException { + String url = String.format( + "%s/v1beta1/projects/-/androidApps/%s", FIREBASE_PROJECT_MANAGEMENT_URL, appId); + AndroidAppResponse parsedResponse = new AndroidAppResponse(); + httpHelper.makeGetRequest(url, parsedResponse, appId, "App ID"); + return new AndroidAppMetadata( + parsedResponse.name, + parsedResponse.appId, + Strings.emptyToNull(parsedResponse.displayName), + parsedResponse.projectId, + parsedResponse.packageName); + } + }; + } + + /* getIosApp */ + + @Override + public IosAppMetadata getIosApp(String appId) throws FirebaseProjectManagementException { + return getIosAppOp(appId).call(); + } + + @Override + public ApiFuture getIosAppAsync(String appId) { + return getIosAppOp(appId).callAsync(app); + } + + private CallableOperation getIosAppOp( + final String appId) { + return new CallableOperation() { + @Override + protected IosAppMetadata execute() throws FirebaseProjectManagementException { + String url = String.format( + "%s/v1beta1/projects/-/iosApps/%s", FIREBASE_PROJECT_MANAGEMENT_URL, appId); + IosAppResponse parsedResponse = new IosAppResponse(); + httpHelper.makeGetRequest(url, parsedResponse, appId, "App ID"); + return new IosAppMetadata( + parsedResponse.name, + parsedResponse.appId, + Strings.emptyToNull(parsedResponse.displayName), + parsedResponse.projectId, + parsedResponse.bundleId); + } + }; + } + + /* listAndroidApps, listIosApps */ + + @Override + public List listAndroidApps(String projectId) + throws FirebaseProjectManagementException { + return listAndroidAppsOp(projectId).call(); + } + + @Override + public ApiFuture> listAndroidAppsAsync(String projectId) { + return listAndroidAppsOp(projectId).callAsync(app); + } + + @Override + public List listIosApps(String projectId) throws FirebaseProjectManagementException { + return listIosAppsOp(projectId).call(); + } + + @Override + public ApiFuture> listIosAppsAsync(String projectId) { + return listIosAppsOp(projectId).callAsync(app); + } + + private CallableOperation, FirebaseProjectManagementException> listAndroidAppsOp( + String projectId) { + return listAppsOp(projectId, ANDROID_APPS_RESOURCE_NAME, createAndroidAppFromAppIdFunction); + } + + private CallableOperation, FirebaseProjectManagementException> listIosAppsOp( + String projectId) { + return listAppsOp(projectId, IOS_APPS_RESOURCE_NAME, createIosAppFromAppIdFunction); + } + + private CallableOperation, FirebaseProjectManagementException> listAppsOp( + final String projectId, + final String platformResourceName, + final CreateAppFromAppIdFunction createAppFromAppIdFunction) { + return new CallableOperation, FirebaseProjectManagementException>() { + @Override + protected List execute() throws FirebaseProjectManagementException { + String url = String.format( + "%s/v1beta1/projects/%s/%s?page_size=%d", + FIREBASE_PROJECT_MANAGEMENT_URL, + projectId, + platformResourceName, + MAXIMUM_LIST_APPS_PAGE_SIZE); + ImmutableList.Builder builder = ImmutableList.builder(); + ListAppsResponse parsedResponse; + do { + parsedResponse = new ListAppsResponse(); + httpHelper.makeGetRequest(url, parsedResponse, projectId, "Project ID"); + if (parsedResponse.apps == null) { + break; + } + for (AppResponse app : parsedResponse.apps) { + builder.add(createAppFromAppIdFunction.apply(app.appId)); + } + url = String.format( + "%s/v1beta1/projects/%s/%s?page_token=%s&page_size=%d", + FIREBASE_PROJECT_MANAGEMENT_URL, + projectId, + platformResourceName, + parsedResponse.nextPageToken, + MAXIMUM_LIST_APPS_PAGE_SIZE); + } while (!Strings.isNullOrEmpty(parsedResponse.nextPageToken)); + return builder.build(); + } + }; + } + + private static class ListAppsResponse { + @Key("apps") + private List apps; + + @Key("nextPageToken") + private String nextPageToken; + } + + /* createAndroidApp, createIosApp */ + + @Override + public AndroidApp createAndroidApp(String projectId, String packageName, String displayName) + throws FirebaseProjectManagementException { + String operationName = createAndroidAppOp(projectId, packageName, displayName).call(); + String appId = pollOperation(projectId, operationName); + return new AndroidApp(appId, this); + } + + @Override + public ApiFuture createAndroidAppAsync( + String projectId, String packageName, String displayName) { + checkArgument(!Strings.isNullOrEmpty(packageName), "package name must not be null or empty"); + return + ImplFirebaseTrampolines.transform( + ImplFirebaseTrampolines.transformAsync( + createAndroidAppOp(projectId, packageName, displayName).callAsync(app), + new WaitOperationFunction(projectId), + app), + createAndroidAppFromAppIdFunction, + app); + } + + @Override + public IosApp createIosApp(String projectId, String bundleId, String displayName) + throws FirebaseProjectManagementException { + String operationName = createIosAppOp(projectId, bundleId, displayName).call(); + String appId = pollOperation(projectId, operationName); + return new IosApp(appId, this); + } + + @Override + public ApiFuture createIosAppAsync( + String projectId, String bundleId, String displayName) { + checkArgument(!Strings.isNullOrEmpty(bundleId), "bundle ID must not be null or empty"); + return + ImplFirebaseTrampolines.transform( + ImplFirebaseTrampolines.transformAsync( + createIosAppOp(projectId, bundleId, displayName).callAsync(app), + new WaitOperationFunction(projectId), + app), + createIosAppFromAppIdFunction, + app); + } + + private CallableOperation createAndroidAppOp( + String projectId, String namespace, String displayName) { + return createAppOp( + projectId, namespace, displayName, ANDROID_NAMESPACE_PROPERTY, ANDROID_APPS_RESOURCE_NAME); + } + + private CallableOperation createIosAppOp( + String projectId, String namespace, String displayName) { + return createAppOp( + projectId, namespace, displayName, IOS_NAMESPACE_PROPERTY, IOS_APPS_RESOURCE_NAME); + } + + private CallableOperation createAppOp( + final String projectId, + final String namespace, + final String displayName, + final String platformNamespaceProperty, + final String platformResourceName) { + return new CallableOperation() { + @Override + protected String execute() throws FirebaseProjectManagementException { + String url = String.format( + "%s/v1beta1/projects/%s/%s", + FIREBASE_PROJECT_MANAGEMENT_URL, + projectId, + platformResourceName); + ImmutableMap.Builder payloadBuilder = + ImmutableMap.builder().put(platformNamespaceProperty, namespace); + if (!Strings.isNullOrEmpty(displayName)) { + payloadBuilder.put("display_name", displayName); + } + OperationResponse operationResponseInstance = new OperationResponse(); + IncomingHttpResponse response = httpHelper.makePostRequest( + url, payloadBuilder.build(), operationResponseInstance, projectId, "Project ID"); + if (Strings.isNullOrEmpty(operationResponseInstance.name)) { + String message = buildMessage( + namespace, + "Bundle ID", + "Unable to create App: server returned null operation name."); + throw new FirebaseProjectManagementException(ErrorCode.INTERNAL, message, response); + } + return operationResponseInstance.name; + } + }; + } + + private String pollOperation(String projectId, String operationName) + throws FirebaseProjectManagementException { + String url = String.format("%s/v1/%s", FIREBASE_PROJECT_MANAGEMENT_URL, operationName); + for (int currentAttempt = 0; currentAttempt < MAXIMUM_POLLING_ATTEMPTS; currentAttempt++) { + long delayMillis = (long) ( + POLL_BASE_WAIT_TIME_MILLIS + * Math.pow(POLL_EXPONENTIAL_BACKOFF_FACTOR, currentAttempt)); + sleepOrThrow(projectId, delayMillis); + OperationResponse operationResponseInstance = new OperationResponse(); + IncomingHttpResponse response = httpHelper.makeGetRequest( + url, operationResponseInstance, projectId, "Project ID"); + if (!operationResponseInstance.done) { + continue; + } + // The Long Running Operation API guarantees that when done == true, exactly one of 'response' + // or 'error' is set. + if (operationResponseInstance.response == null + || Strings.isNullOrEmpty(operationResponseInstance.response.appId)) { + String message = buildMessage( + projectId, + "Project ID", + "Unable to create App: internal server error."); + throw new FirebaseProjectManagementException(ErrorCode.INTERNAL, message, response); + } + return operationResponseInstance.response.appId; + } + + String message = buildMessage( + projectId, + "Project ID", + "Unable to create App: deadline exceeded."); + throw new FirebaseProjectManagementException(ErrorCode.DEADLINE_EXCEEDED, message, null); + } + + /** + * An {@link ApiAsyncFunction} that transforms a Long Running Operation name to an {@link IosApp} + * or an {@link AndroidApp} instance by repeatedly polling the server (with exponential backoff) + * until the App is created successfully, or until the number of poll attempts exceeds the maximum + * allowed. + */ + private class WaitOperationFunction implements ApiAsyncFunction { + private final String projectId; + + private WaitOperationFunction(String projectId) { + this.projectId = projectId; + } + + /** + * Returns an {@link ApiFuture} that will eventually contain the App ID of the new created App, + * or an exception if an error occurred during polling. + */ + @Override + public ApiFuture apply(String operationName) { + SettableApiFuture settableFuture = SettableApiFuture.create(); + scheduler.schedule( + new WaitOperationRunnable( + /* numberOfPreviousPolls= */ 0, + operationName, + projectId, + settableFuture), + /* delayMillis= */ POLL_BASE_WAIT_TIME_MILLIS); + return settableFuture; + } + } + + /** + * A poller that repeatedly polls a Long Running Operation (with exponential backoff) until its + * status is "done", or until the number of polling attempts exceeds the maximum allowed. + */ + private class WaitOperationRunnable implements Runnable { + private final int numberOfPreviousPolls; + private final String operationName; + private final String projectId; + private final SettableApiFuture settableFuture; + + private WaitOperationRunnable( + int numberOfPreviousPolls, + String operationName, + String projectId, + SettableApiFuture settableFuture) { + this.numberOfPreviousPolls = numberOfPreviousPolls; + this.operationName = operationName; + this.projectId = projectId; + this.settableFuture = settableFuture; + } + + @Override + public void run() { + String url = String.format("%s/v1/%s", FIREBASE_PROJECT_MANAGEMENT_URL, operationName); + OperationResponse operationResponseInstance = new OperationResponse(); + IncomingHttpResponse httpResponse; + try { + httpResponse = httpHelper.makeGetRequest( + url, operationResponseInstance, projectId, "Project ID"); + } catch (FirebaseProjectManagementException e) { + settableFuture.setException(e); + return; + } + if (!operationResponseInstance.done) { + if (numberOfPreviousPolls + 1 >= MAXIMUM_POLLING_ATTEMPTS) { + String message = buildMessage(projectId, + "Project ID", + "Unable to create App: deadline exceeded."); + FirebaseProjectManagementException exception = new FirebaseProjectManagementException( + ErrorCode.DEADLINE_EXCEEDED, message, httpResponse); + settableFuture.setException(exception); + } else { + long delayMillis = (long) ( + POLL_BASE_WAIT_TIME_MILLIS + * Math.pow(POLL_EXPONENTIAL_BACKOFF_FACTOR, numberOfPreviousPolls + 1)); + scheduler.schedule( + new WaitOperationRunnable( + numberOfPreviousPolls + 1, + operationName, + projectId, + settableFuture), + delayMillis); + } + return; + } + // The Long Running Operation API guarantees that when done == true, exactly one of 'response' + // or 'error' is set. + if (operationResponseInstance.response == null + || Strings.isNullOrEmpty(operationResponseInstance.response.appId)) { + String message = buildMessage(projectId, + "Project ID", + "Unable to create App: internal server error."); + FirebaseProjectManagementException exception = new FirebaseProjectManagementException( + ErrorCode.INTERNAL, message, httpResponse); + settableFuture.setException(exception); + } else { + settableFuture.set(operationResponseInstance.response.appId); + } + } + } + + // This class is public due to the way parsing nested JSON objects work, and is needed by + // create{Android|Ios}App and list{Android|Ios}Apps. In any case, the containing class, + // FirebaseProjectManagementServiceImpl, is package-private. + public static class AppResponse { + @Key("name") + protected String name; + + @Key("appId") + protected String appId; + + @Key("displayName") + protected String displayName; + + @Key("projectId") + protected String projectId; + } + + private static class AndroidAppResponse extends AppResponse { + @Key("packageName") + private String packageName; + } + + private static class IosAppResponse extends AppResponse { + @Key("bundleId") + private String bundleId; + } + + // This class is public due to the way parsing nested JSON objects work, and is needed by + // createIosApp and createAndroidApp. In any case, the containing class, + // FirebaseProjectManagementServiceImpl, is package-private. + public static class StatusResponse { + @Key("code") + private int code; + + @Key("message") + private String message; + } + + private static class OperationResponse { + @Key("name") + private String name; + + @Key("metadata") + private String metadata; + + @Key("done") + private boolean done; + + @Key("error") + private StatusResponse error; + + @Key("response") + private AppResponse response; + } + + /* setAndroidDisplayName, setIosDisplayName */ + + @Override + public void setAndroidDisplayName(String appId, String newDisplayName) + throws FirebaseProjectManagementException { + setAndroidDisplayNameOp(appId, newDisplayName).call(); + } + + @Override + public ApiFuture setAndroidDisplayNameAsync(String appId, String newDisplayName) { + return setAndroidDisplayNameOp(appId, newDisplayName).callAsync(app); + } + + @Override + public void setIosDisplayName(String appId, String newDisplayName) + throws FirebaseProjectManagementException { + setIosDisplayNameOp(appId, newDisplayName).call(); + } + + @Override + public ApiFuture setIosDisplayNameAsync(String appId, String newDisplayName) { + return setIosDisplayNameOp(appId, newDisplayName).callAsync(app); + } + + private CallableOperation setAndroidDisplayNameOp( + String appId, String newDisplayName) { + return setDisplayNameOp(appId, newDisplayName, ANDROID_APPS_RESOURCE_NAME); + } + + private CallableOperation setIosDisplayNameOp( + String appId, String newDisplayName) { + return setDisplayNameOp(appId, newDisplayName, IOS_APPS_RESOURCE_NAME); + } + + private CallableOperation setDisplayNameOp( + final String appId, final String newDisplayName, final String platformResourceName) { + checkArgument( + !Strings.isNullOrEmpty(newDisplayName), "new Display Name must not be null or empty"); + return new CallableOperation() { + @Override + protected Void execute() throws FirebaseProjectManagementException { + String url = String.format( + "%s/v1beta1/projects/-/%s/%s?update_mask=display_name", + FIREBASE_PROJECT_MANAGEMENT_URL, + platformResourceName, + appId); + ImmutableMap payload = + ImmutableMap.builder().put("display_name", newDisplayName).build(); + EmptyResponse emptyResponseInstance = new EmptyResponse(); + httpHelper.makePatchRequest(url, payload, emptyResponseInstance, appId, "App ID"); + return null; + } + }; + } + + private static class EmptyResponse {} + + /* getAndroidConfig, getIosConfig */ + + @Override + public String getAndroidConfig(String appId) throws FirebaseProjectManagementException { + return getAndroidConfigOp(appId).call(); + } + + @Override + public ApiFuture getAndroidConfigAsync(String appId) { + return getAndroidConfigOp(appId).callAsync(app); + } + + @Override + public String getIosConfig(String appId) throws FirebaseProjectManagementException { + return getIosConfigOp(appId).call(); + } + + @Override + public ApiFuture getIosConfigAsync(String appId) { + return getIosConfigOp(appId).callAsync(app); + } + + private CallableOperation getAndroidConfigOp( + String appId) { + return getConfigOp(appId, ANDROID_APPS_RESOURCE_NAME); + } + + private CallableOperation getIosConfigOp( + String appId) { + return getConfigOp(appId, IOS_APPS_RESOURCE_NAME); + } + + private CallableOperation getConfigOp( + final String appId, final String platformResourceName) { + return new CallableOperation() { + @Override + protected String execute() throws FirebaseProjectManagementException { + String url = String.format( + "%s/v1beta1/projects/-/%s/%s/config", + FIREBASE_PROJECT_MANAGEMENT_URL, + platformResourceName, + appId); + AppConfigResponse parsedResponse = new AppConfigResponse(); + httpHelper.makeGetRequest(url, parsedResponse, appId, "App ID"); + return new String( + Base64.decodeBase64(parsedResponse.configFileContents), StandardCharsets.UTF_8); + } + }; + } + + private static class AppConfigResponse { + @Key("configFilename") + String configFilename; + + @Key("configFileContents") + String configFileContents; + } + + /* getShaCertificates */ + + @Override + public List getShaCertificates(String appId) + throws FirebaseProjectManagementException { + return getShaCertificatesOp(appId).call(); + } + + @Override + public ApiFuture> getShaCertificatesAsync(String appId) { + return getShaCertificatesOp(appId).callAsync(app); + } + + private CallableOperation, FirebaseProjectManagementException> + getShaCertificatesOp(final String appId) { + return new CallableOperation, FirebaseProjectManagementException>() { + @Override + protected List execute() throws FirebaseProjectManagementException { + String url = String.format( + "%s/v1beta1/projects/-/androidApps/%s/sha", FIREBASE_PROJECT_MANAGEMENT_URL, appId); + ListShaCertificateResponse parsedResponse = new ListShaCertificateResponse(); + httpHelper.makeGetRequest(url, parsedResponse, appId, "App ID"); + List certificates = new ArrayList<>(); + if (parsedResponse.certificates == null) { + return certificates; + } + for (ShaCertificateResponse certificate : parsedResponse.certificates) { + certificates.add(ShaCertificate.create(certificate.name, certificate.shaHash)); + } + return certificates; + } + }; + } + + /* createShaCertificate */ + + @Override + public ShaCertificate createShaCertificate(String appId, ShaCertificate certificateToAdd) + throws FirebaseProjectManagementException { + return createShaCertificateOp(appId, certificateToAdd).call(); + } + + @Override + public ApiFuture createShaCertificateAsync( + String appId, ShaCertificate certificateToAdd) { + return createShaCertificateOp(appId, certificateToAdd).callAsync(app); + } + + private CallableOperation + createShaCertificateOp(final String appId, final ShaCertificate certificateToAdd) { + return new CallableOperation() { + @Override + protected ShaCertificate execute() throws FirebaseProjectManagementException { + String url = String.format( + "%s/v1beta1/projects/-/androidApps/%s/sha", FIREBASE_PROJECT_MANAGEMENT_URL, appId); + ShaCertificateResponse parsedResponse = new ShaCertificateResponse(); + ImmutableMap payload = ImmutableMap.builder() + .put("sha_hash", certificateToAdd.getShaHash()) + .put("cert_type", certificateToAdd.getCertType().toString()) + .build(); + httpHelper.makePostRequest(url, payload, parsedResponse, appId, "App ID"); + return ShaCertificate.create(parsedResponse.name, parsedResponse.shaHash); + } + }; + } + + /* deleteShaCertificate */ + + @Override + public void deleteShaCertificate(String resourceName) + throws FirebaseProjectManagementException { + deleteShaCertificateOp(resourceName).call(); + } + + @Override + public ApiFuture deleteShaCertificateAsync(String resourceName) { + return deleteShaCertificateOp(resourceName).callAsync(app); + } + + private CallableOperation deleteShaCertificateOp( + final String resourceName) { + return new CallableOperation() { + @Override + protected Void execute() throws FirebaseProjectManagementException { + String url = String.format( + "%s/v1beta1/%s", FIREBASE_PROJECT_MANAGEMENT_URL, resourceName); + EmptyResponse parsedResponse = new EmptyResponse(); + httpHelper.makeDeleteRequest(url, parsedResponse, resourceName, "SHA name"); + return null; + } + }; + } + + private static class ListShaCertificateResponse { + @Key("certificates") + private List certificates; + } + + // This class is public due to the way parsing nested JSON objects work, and is needed by + // getShaCertificates. In any case, the containing class, FirebaseProjectManagementServiceImpl, is + // package-private. + public static class ShaCertificateResponse { + @Key("name") + private String name; + + @Key("shaHash") + private String shaHash; + + @Key("certType") + private String certType; + } + + private static class FirebaseAppScheduler implements Scheduler { + + private final FirebaseApp app; + + FirebaseAppScheduler(FirebaseApp app) { + this.app = checkNotNull(app); + } + + @Override + public void schedule(Runnable runnable, long delayMillis) { + ImplFirebaseTrampolines.schedule(app, runnable, delayMillis); + } + } + + /* Helper methods. */ + + private void sleepOrThrow(String projectId, long delayMillis) + throws FirebaseProjectManagementException { + try { + sleeper.sleep(delayMillis); + } catch (InterruptedException e) { + String message = buildMessage(projectId, + "Project ID", + "Unable to create App: exponential backoff interrupted."); + throw new FirebaseProjectManagementException(ErrorCode.ABORTED, message, null); + } + } + + private String buildMessage(String resourceId, String resourceIdName, String description) { + return String.format("%s \"%s\": %s", resourceIdName, resourceId, description); + } + + /* Helper types. */ + + private interface CreateAppFromAppIdFunction extends ApiFunction {} + + private class CreateAndroidAppFromAppIdFunction + implements CreateAppFromAppIdFunction { + @Override + public AndroidApp apply(String appId) { + return new AndroidApp(appId, FirebaseProjectManagementServiceImpl.this); + } + } + + private class CreateIosAppFromAppIdFunction implements CreateAppFromAppIdFunction { + @Override + public IosApp apply(String appId) { + return new IosApp(appId, FirebaseProjectManagementServiceImpl.this); + } + } +} diff --git a/src/main/java/com/google/firebase/projectmanagement/HttpHelper.java b/src/main/java/com/google/firebase/projectmanagement/HttpHelper.java new file mode 100644 index 000000000..d0f5942b1 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/HttpHelper.java @@ -0,0 +1,126 @@ +/* + * Copyright 2018 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.projectmanagement; + +import com.google.api.client.http.HttpMethods; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponseInterceptor; +import com.google.api.client.json.JsonFactory; +import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.internal.AbstractPlatformErrorHandler; +import com.google.firebase.internal.ErrorHandlingHttpClient; +import com.google.firebase.internal.HttpRequestInfo; +import com.google.firebase.internal.SdkUtils; + +final class HttpHelper { + + private static final String CLIENT_VERSION_HEADER = "X-Client-Version"; + + private static final String CLIENT_VERSION = "Java/Admin/" + SdkUtils.getVersion(); + + private final ErrorHandlingHttpClient httpClient; + + HttpHelper(JsonFactory jsonFactory, HttpRequestFactory requestFactory) { + ProjectManagementErrorHandler errorHandler = new ProjectManagementErrorHandler(jsonFactory); + this.httpClient = new ErrorHandlingHttpClient<>(requestFactory, jsonFactory, errorHandler); + } + + void setInterceptor(HttpResponseInterceptor interceptor) { + httpClient.setInterceptor(interceptor); + } + + IncomingHttpResponse makeGetRequest( + String url, + T parsedResponseInstance, + String requestIdentifier, + String requestIdentifierDescription) throws FirebaseProjectManagementException { + return makeRequest( + HttpRequestInfo.buildGetRequest(url), + parsedResponseInstance, + requestIdentifier, + requestIdentifierDescription); + } + + IncomingHttpResponse makePostRequest( + String url, + Object payload, + T parsedResponseInstance, + String requestIdentifier, + String requestIdentifierDescription) throws FirebaseProjectManagementException { + return makeRequest( + HttpRequestInfo.buildJsonPostRequest(url, payload), + parsedResponseInstance, + requestIdentifier, + requestIdentifierDescription); + } + + void makePatchRequest( + String url, + Object payload, + T parsedResponseInstance, + String requestIdentifier, + String requestIdentifierDescription) throws FirebaseProjectManagementException { + makeRequest( + HttpRequestInfo.buildJsonRequest(HttpMethods.PATCH, url, payload), + parsedResponseInstance, + requestIdentifier, + requestIdentifierDescription); + } + + void makeDeleteRequest( + String url, + T parsedResponseInstance, + String requestIdentifier, + String requestIdentifierDescription) throws FirebaseProjectManagementException { + makeRequest( + HttpRequestInfo.buildDeleteRequest(url), + parsedResponseInstance, + requestIdentifier, + requestIdentifierDescription); + } + + private IncomingHttpResponse makeRequest( + HttpRequestInfo baseRequest, + T parsedResponseInstance, + String requestIdentifier, + String requestIdentifierDescription) throws FirebaseProjectManagementException { + try { + baseRequest.addHeader(CLIENT_VERSION_HEADER, CLIENT_VERSION); + IncomingHttpResponse response = httpClient.send(baseRequest); + httpClient.parse(response, parsedResponseInstance); + return response; + } catch (FirebaseProjectManagementException e) { + String message = String.format( + "%s \"%s\": %s", requestIdentifierDescription, requestIdentifier, e.getMessage()); + throw new FirebaseProjectManagementException(e, message); + } + } + + private static class ProjectManagementErrorHandler + extends AbstractPlatformErrorHandler { + + ProjectManagementErrorHandler(JsonFactory jsonFactory) { + super(jsonFactory); + } + + @Override + protected FirebaseProjectManagementException createException(FirebaseException base) { + return new FirebaseProjectManagementException(base); + } + } +} diff --git a/src/main/java/com/google/firebase/projectmanagement/IosApp.java b/src/main/java/com/google/firebase/projectmanagement/IosApp.java new file mode 100644 index 000000000..16ec77b11 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/IosApp.java @@ -0,0 +1,103 @@ +/* + * Copyright 2018 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.projectmanagement; + +import com.google.api.core.ApiFuture; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; + +/** + * An instance of this class is a reference to an iOS App within a Firebase Project; it can be used + * to query detailed information about the App, modify the display name of the App, or download the + * configuration file for the App. + * + *

Note: the methods in this class make RPCs. + */ +public class IosApp { + private final String appId; + private final IosAppService iosAppService; + + IosApp(String appId, IosAppService iosAppService) { + Preconditions.checkArgument(!Strings.isNullOrEmpty(appId), "app ID cannot be null or empty"); + this.appId = appId; + this.iosAppService = iosAppService; + } + + String getAppId() { + return appId; + } + + /** + * Retrieves detailed information about this iOS App. + * + * @return an {@link IosAppMetadata} instance describing this App + * @throws FirebaseProjectManagementException if there was an error during the RPC + */ + public IosAppMetadata getMetadata() throws FirebaseProjectManagementException { + return iosAppService.getIosApp(appId); + } + + /** + * Asynchronously retrieves information about this iOS App. + * + * @return an {@code ApiFuture} containing an {@link IosAppMetadata} instance describing this App + */ + public ApiFuture getMetadataAsync() { + return iosAppService.getIosAppAsync(appId); + } + + /** + * Updates the Display Name attribute of this iOS App to the one given. + * + * @throws FirebaseProjectManagementException if there was an error during the RPC + */ + public void setDisplayName(String newDisplayName) throws FirebaseProjectManagementException { + iosAppService.setIosDisplayName(appId, newDisplayName); + } + + /** + * Asynchronously updates the Display Name attribute of this iOS App to the one given. + */ + public ApiFuture setDisplayNameAsync(String newDisplayName) { + return iosAppService.setIosDisplayNameAsync(appId, newDisplayName); + } + + /** + * Retrieves the configuration artifact associated with this iOS App. + * + * @return a modified UTF-8 encoded {@code String} containing the contents of the artifact + * @throws FirebaseProjectManagementException if there was an error during the RPC + */ + public String getConfig() throws FirebaseProjectManagementException { + return iosAppService.getIosConfig(appId); + } + + /** + * Asynchronously retrieves the configuration artifact associated with this iOS App. + * + * @return an {@code ApiFuture} of a UTF-8 encoded {@code String} containing the contents of the + * artifact + */ + public ApiFuture getConfigAsync() { + return iosAppService.getIosConfigAsync(appId); + } + + @Override + public String toString() { + return String.format("iOS App %s", getAppId()); + } +} diff --git a/src/main/java/com/google/firebase/projectmanagement/IosAppMetadata.java b/src/main/java/com/google/firebase/projectmanagement/IosAppMetadata.java new file mode 100644 index 000000000..b1a67c076 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/IosAppMetadata.java @@ -0,0 +1,112 @@ +/* Copyright 2018 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.projectmanagement; + +import com.google.api.client.util.Preconditions; +import com.google.common.base.MoreObjects; +import com.google.common.base.Objects; +import com.google.firebase.internal.Nullable; + +/** + * Contains detailed information about an iOS App. Instances of this class are immutable. + */ +public class IosAppMetadata { + private final String name; + private final String appId; + private final String displayName; + private final String projectId; + private final String bundleId; + + IosAppMetadata( + String name, String appId, String displayName, String projectId, String bundleId) { + this.name = Preconditions.checkNotNull(name, "Null name"); + this.appId = Preconditions.checkNotNull(appId, "Null appId"); + this.displayName = displayName; + this.projectId = Preconditions.checkNotNull(projectId, "Null projectId"); + this.bundleId = Preconditions.checkNotNull(bundleId, "Null bundleId"); + } + + /** + * Returns the fully qualified resource name of this iOS App. + */ + String getName() { + return name; + } + + /** + * Returns the globally unique, Firebase-assigned identifier of this iOS App. This ID is unique + * even across Apps of different platforms, such as Android Apps. + */ + public String getAppId() { + return appId; + } + + /** + * Returns the user-assigned display name of this iOS App. Returns {@code null} if it has never + * been set. + */ + @Nullable + public String getDisplayName() { + return displayName; + } + + /** + * Returns the permanent, globally unique, user-assigned ID of the parent Project for this iOS + * App. + */ + public String getProjectId() { + return projectId; + } + + /** + * Returns the canonical bundle ID of this iOS App as it would appear in the iOS AppStore. + */ + public String getBundleId() { + return bundleId; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper("IosAppMetadata") + .add("name", name) + .add("appId", appId) + .add("displayName", displayName) + .add("projectId", projectId) + .add("bundleId", bundleId) + .toString(); + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o instanceof IosAppMetadata) { + IosAppMetadata that = (IosAppMetadata) o; + return Objects.equal(this.name, that.name) + && Objects.equal(this.appId, that.appId) + && Objects.equal(this.displayName, that.displayName) + && Objects.equal(this.projectId, that.projectId) + && Objects.equal(this.bundleId, that.bundleId); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(name, appId, displayName, projectId, bundleId); + } +} diff --git a/src/main/java/com/google/firebase/projectmanagement/IosAppService.java b/src/main/java/com/google/firebase/projectmanagement/IosAppService.java new file mode 100644 index 000000000..b6d3e684d --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/IosAppService.java @@ -0,0 +1,115 @@ +/* Copyright 2018 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.projectmanagement; + +import com.google.api.core.ApiFuture; +import java.util.List; + +/** + * An interface to interact with the iOS-specific functionalities in the Firebase Project Management + * Service. + * + *

Note: Implementations of methods in this service may make RPCs. + */ +interface IosAppService { + /** + * Creates a new iOS App in the given project with the given display name. + * + * @param projectId the Project ID of the project in which to create the App + * @param bundleId the bundle ID of the iOS App to create + * @param displayName a nickname for this iOS App + * @return an {@link IosApp} reference + */ + IosApp createIosApp(String projectId, String bundleId, String displayName) + throws FirebaseProjectManagementException; + + /** + * Asynchronously creates a new iOS App in the given project with the given display name. + * + * @param projectId the Project ID of the project in which to create the App + * @param bundleId the bundle ID of the iOS App to create + * @param displayName a nickname for this iOS App + * @return an {@link ApiFuture} of an {@link IosApp} reference + */ + ApiFuture createIosAppAsync(String projectId, String bundleId, String displayName); + + /** + * Retrieve information about an existing iOS App, identified by its App ID. + * + * @param appId the App ID of the iOS App + * @return an {@link IosAppMetadata} instance describing the iOS App + */ + IosAppMetadata getIosApp(String appId) throws FirebaseProjectManagementException; + + /** + * Asynchronously retrieves information about an existing iOS App, identified by its App ID. + * + * @param appId the App ID of the iOS App + * @return an {@link IosAppMetadata} instance describing the iOS App + */ + ApiFuture getIosAppAsync(String appId); + + /** + * Lists all the iOS Apps belonging to the given project. The returned list cannot be modified. + * + * @param projectId the Project ID of the project + * @return a read-only list of {@link IosApp} references + */ + List listIosApps(String projectId) throws FirebaseProjectManagementException; + + /** + * Asynchronously lists all the iOS Apps belonging to the given project. The returned list cannot + * be modified. + * + * @param projectId the project ID of the project + * @return an {@link ApiFuture} of a read-only list of {@link IosApp} references + */ + ApiFuture> listIosAppsAsync(String projectId); + + /** + * Updates the Display Name of the given iOS App. + * + * @param appId the App ID of the iOS App + * @param newDisplayName the new Display Name + */ + void setIosDisplayName(String appId, String newDisplayName) + throws FirebaseProjectManagementException; + + /** + * Asynchronously updates the Display Name of the given iOS App. + * + * @param appId the App ID of the iOS App + * @param newDisplayName the new Display Name + */ + ApiFuture setIosDisplayNameAsync(String appId, String newDisplayName); + + /** + * Retrieves the configuration artifact associated with the specified iOS App. + * + * @param appId the App ID of the iOS App + * @return a modified UTF-8 encoded {@code String} containing the contents of the artifact + */ + String getIosConfig(String appId) throws FirebaseProjectManagementException; + + /** + * Asynchronously retrieves the configuration artifact associated with the specified iOS App. + * + * @param appId the App ID of the iOS App + * @return an {@link ApiFuture} of a modified UTF-8 encoded {@code String} containing the contents + * of the artifact + */ + ApiFuture getIosConfigAsync(String appId); +} diff --git a/src/main/java/com/google/firebase/projectmanagement/Scheduler.java b/src/main/java/com/google/firebase/projectmanagement/Scheduler.java new file mode 100644 index 000000000..bac493e3e --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/Scheduler.java @@ -0,0 +1,25 @@ +/* + * Copyright 2019 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.projectmanagement; + +/** + * Schedules a task to be executed after a specified delay. + */ +interface Scheduler { + + void schedule(Runnable runnable, long delayMillis); +} diff --git a/src/main/java/com/google/firebase/projectmanagement/ShaCertificate.java b/src/main/java/com/google/firebase/projectmanagement/ShaCertificate.java new file mode 100644 index 000000000..13b298c95 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/ShaCertificate.java @@ -0,0 +1,125 @@ +/* Copyright 2018 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.projectmanagement; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Ascii; +import com.google.common.base.MoreObjects; +import com.google.common.base.Objects; +import com.google.common.base.Preconditions; +import java.util.regex.Pattern; + +/** + * Information about an SHA certificate associated with an Android app. + */ +public class ShaCertificate { + + private static final Pattern SHA1_PATTERN = Pattern.compile("[0-9a-fA-F]{40}"); + private static final Pattern SHA256_PATTERN = Pattern.compile("[0-9a-fA-F]{64}"); + + private final String name; + private final String shaHash; + private final ShaCertificateType certType; + + private ShaCertificate(String name, String shaHash, ShaCertificateType certType) { + this.name = Preconditions.checkNotNull(name, "Null name"); + this.shaHash = Preconditions.checkNotNull(shaHash, "Null shaHash"); + this.certType = Preconditions.checkNotNull(certType, "Null certType"); + } + + /** + * Creates an {@link ShaCertificate} from the given certificate hash. + * + *

The fully qualified resource name of this certificate will be set to the empty string since + * it has not been generated yet. + * + * @param shaHash SHA hash of the certificate + * @return a new {@link ShaCertificate} instance + */ + public static ShaCertificate create(String shaHash) { + return new ShaCertificate("", shaHash, getTypeFromHash(shaHash)); + } + + static ShaCertificate create(String name, String shaHash) { + return new ShaCertificate(name, shaHash, getTypeFromHash(shaHash)); + } + + /** + * Returns the type of the certificate based on its hash. + * + * @throws IllegalArgumentException if the SHA hash is neither SHA-1 nor SHA-256 + */ + @VisibleForTesting + static ShaCertificateType getTypeFromHash(String shaHash) { + Preconditions.checkNotNull(shaHash, "Null shaHash"); + shaHash = Ascii.toLowerCase(shaHash); + if (SHA1_PATTERN.matcher(shaHash).matches()) { + return ShaCertificateType.SHA_1; + } else if (SHA256_PATTERN.matcher(shaHash).matches()) { + return ShaCertificateType.SHA_256; + } + throw new IllegalArgumentException("Invalid SHA hash; it is neither SHA-1 nor SHA-256."); + } + + /** + * Returns the fully qualified resource name of this SHA certificate. + */ + public String getName() { + return name; + } + + /** + * Returns the hash of this SHA certificate. + */ + public String getShaHash() { + return shaHash; + } + + /** + * Returns the type of this SHA certificate. + */ + public ShaCertificateType getCertType() { + return certType; + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o instanceof ShaCertificate) { + ShaCertificate that = (ShaCertificate) o; + return (this.name.equals(that.getName())) + && (this.shaHash.equals(that.getShaHash())) + && (this.certType.equals(that.getCertType())); + } + return false; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper("ShaCertificate") + .add("name", name) + .add("shaHash", shaHash) + .add("certType", certType) + .toString(); + } + + @Override + public int hashCode() { + return Objects.hashCode(name, shaHash, certType); + } +} diff --git a/src/main/java/com/google/firebase/projectmanagement/ShaCertificateType.java b/src/main/java/com/google/firebase/projectmanagement/ShaCertificateType.java new file mode 100644 index 000000000..e62c8ca69 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/ShaCertificateType.java @@ -0,0 +1,26 @@ +/* Copyright 2018 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.projectmanagement; + +/** + * Enum denoting types of SHA certificates currently supported by Firebase. + */ +public enum ShaCertificateType { + /** Certificate generated by SHA-1 hashing algorithm. */ + SHA_1, + /** Certificate generated by SHA-256 hashing algorithm. */ + SHA_256 +} diff --git a/src/main/java/com/google/firebase/remoteconfig/AndCondition.java b/src/main/java/com/google/firebase/remoteconfig/AndCondition.java new file mode 100644 index 000000000..ec118cef2 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/AndCondition.java @@ -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.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.collect.ImmutableList; +import com.google.firebase.internal.NonNull; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.AndConditionResponse; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.OneOfConditionResponse; + +import java.util.List; +import java.util.stream.Collectors; + +final class AndCondition { + private final ImmutableList conditions; + + AndCondition(@NonNull List conditions) { + checkNotNull(conditions, "List of conditions for AND operation must not be null."); + checkArgument(!conditions.isEmpty(), + "List of conditions for AND operation must not be empty."); + this.conditions = ImmutableList.copyOf(conditions); + } + + AndCondition(AndConditionResponse andConditionResponse) { + List conditionList = andConditionResponse.getConditions(); + checkNotNull(conditionList, "List of conditions for AND operation must not be null."); + checkArgument(!conditionList.isEmpty(), + "List of conditions for AND operation must not be empty"); + this.conditions = conditionList.stream() + .map(OneOfCondition::new) + .collect(ImmutableList.toImmutableList()); + } + + @NonNull + ImmutableList getConditions() { + return conditions; + } + + AndConditionResponse toAndConditionResponse() { + return new AndConditionResponse() + .setConditions(this.conditions.stream() + .map(OneOfCondition::toOneOfConditionResponse) + .collect(Collectors.toList())); + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/Condition.java b/src/main/java/com/google/firebase/remoteconfig/Condition.java new file mode 100644 index 000000000..6ccde59d9 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/Condition.java @@ -0,0 +1,174 @@ +/* + * Copyright 2020 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.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Strings; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import com.google.firebase.remoteconfig.internal.TemplateResponse.ConditionResponse; + +import java.util.Objects; + +/** + * Represents a Remote Config condition that can be included in a {@link Template}. + * A condition targets a specific group of users. A list of these conditions make up + * part of a Remote Config template. + */ +public final class Condition { + + private String name; + private String expression; + private TagColor tagColor; + + /** + * Creates a new {@link Condition}. + * + * @param name A non-null, non-empty, and unique name of this condition. + * @param expression A non-null and non-empty expression of this condition. + */ + public Condition(@NonNull String name, @NonNull String expression) { + this(name, expression, null); + } + + /** + * Creates a new {@link Condition}. + * + * @param name A non-null, non-empty, and unique name of this condition. + * @param expression A non-null and non-empty expression of this condition. + * @param tagColor A color associated with this condition for display purposes in the + * Firebase Console. Not specifying this value results in the console picking an + * arbitrary color to associate with the condition. + */ + public Condition(@NonNull String name, @NonNull String expression, @Nullable TagColor tagColor) { + checkArgument(!Strings.isNullOrEmpty(name), "condition name must not be null or empty"); + checkArgument(!Strings.isNullOrEmpty(expression), + "condition expression must not be null or empty"); + this.name = name; + this.expression = expression; + this.tagColor = tagColor; + } + + Condition(@NonNull ConditionResponse conditionResponse) { + checkNotNull(conditionResponse); + this.name = conditionResponse.getName(); + this.expression = conditionResponse.getExpression(); + if (!Strings.isNullOrEmpty(conditionResponse.getTagColor())) { + this.tagColor = TagColor.valueOf(conditionResponse.getTagColor()); + } + } + + /** + * Gets the name of the condition. + * + * @return The name of the condition. + */ + @NonNull + public String getName() { + return name; + } + + /** + * Gets the expression of the condition. + * + * @return The expression of the condition. + */ + @NonNull + public String getExpression() { + return expression; + } + + /** + * Gets the tag color of the condition used for display purposes in the Firebase Console. + * + * @return The tag color of the condition. + */ + @NonNull + public TagColor getTagColor() { + return tagColor; + } + + /** + * Sets the name of the condition. + * + * @param name A non-empty and unique name of this condition. + * @return This {@link Condition}. + */ + public Condition setName(@NonNull String name) { + checkArgument(!Strings.isNullOrEmpty(name), "condition name must not be null or empty"); + this.name = name; + return this; + } + + /** + * Sets the expression of the condition. + * + *

See + * condition expressions for the expected syntax of this field. + * + * @param expression The logic of this condition. + * @return This {@link Condition}. + */ + public Condition setExpression(@NonNull String expression) { + checkArgument(!Strings.isNullOrEmpty(expression), + "condition expression must not be null or empty"); + this.expression = expression; + return this; + } + + /** + * Sets the tag color of the condition. + * + *

The color associated with this condition for display purposes in the Firebase Console. + * Not specifying this value results in the console picking an arbitrary color to associate + * with the condition. + * + * @param tagColor The tag color of this condition. + * @return This {@link Condition}. + */ + public Condition setTagColor(@Nullable TagColor tagColor) { + this.tagColor = tagColor; + return this; + } + + ConditionResponse toConditionResponse() { + return new ConditionResponse() + .setName(this.name) + .setExpression(this.expression) + .setTagColor(this.tagColor == null ? null : this.tagColor.getColor()); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Condition condition = (Condition) o; + return Objects.equals(name, condition.name) + && Objects.equals(expression, condition.expression) && tagColor == condition.tagColor; + } + + @Override + public int hashCode() { + return Objects.hash(name, expression, tagColor); + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/ConditionEvaluator.java b/src/main/java/com/google/firebase/remoteconfig/ConditionEvaluator.java new file mode 100644 index 000000000..63718b32b --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/ConditionEvaluator.java @@ -0,0 +1,346 @@ +/* + * 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.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.collect.ImmutableMap.toImmutableMap; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.BiPredicate; +import java.util.function.IntPredicate; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +final class ConditionEvaluator { + private static final int MAX_CONDITION_RECURSION_DEPTH = 10; + private static final Logger logger = LoggerFactory.getLogger(ConditionEvaluator.class); + private static final BigInteger MICRO_PERCENT_MODULO = BigInteger.valueOf(100_000_000L); + private static final Pattern SEMVER_PATTERN = Pattern.compile("^[0-9]+(?:\\.[0-9]+){0,4}$"); + + /** + * Evaluates server conditions and assigns a boolean value to each condition. + * + * @param conditions List of conditions which are to be evaluated. + * @param context A map with additional metadata used during evaluation. + * @return A map of condition to evaluated value. + */ + @NonNull + Map evaluateConditions( + @NonNull List conditions, @Nullable KeysAndValues context) { + checkNotNull(conditions, "List of conditions must not be null."); + checkArgument(!conditions.isEmpty(), "List of conditions must not be empty."); + if (context == null || conditions.isEmpty()) { + return ImmutableMap.of(); + } + KeysAndValues evaluationContext = + context != null ? context : new KeysAndValues.Builder().build(); + + Map evaluatedConditions = + conditions.stream() + .collect( + toImmutableMap( + ServerCondition::getName, + condition -> + evaluateCondition( + condition.getCondition(), evaluationContext, /* nestingLevel= */ 0))); + + return evaluatedConditions; + } + + private boolean evaluateCondition( + OneOfCondition condition, KeysAndValues context, int nestingLevel) { + if (nestingLevel > MAX_CONDITION_RECURSION_DEPTH) { + logger.warn("Maximum condition recursion depth exceeded."); + return false; + } + + if (condition.getOrCondition() != null) { + return evaluateOrCondition(condition.getOrCondition(), context, nestingLevel + 1); + } else if (condition.getAndCondition() != null) { + return evaluateAndCondition(condition.getAndCondition(), context, nestingLevel + 1); + } else if (condition.isTrue() != null) { + return true; + } else if (condition.isFalse() != null) { + return false; + } else if (condition.getCustomSignal() != null) { + return evaluateCustomSignalCondition(condition.getCustomSignal(), context); + } else if (condition.getPercent() != null) { + return evaluatePercentCondition(condition.getPercent(), context); + } + logger.atWarn().log("Received invalid condition for evaluation."); + return false; + } + + private boolean evaluateOrCondition( + OrCondition condition, KeysAndValues context, int nestingLevel) { + return condition.getConditions().stream() + .anyMatch(subCondition -> evaluateCondition(subCondition, context, nestingLevel + 1)); + } + + private boolean evaluateAndCondition( + AndCondition condition, KeysAndValues context, int nestingLevel) { + return condition.getConditions().stream() + .allMatch(subCondition -> evaluateCondition(subCondition, context, nestingLevel + 1)); + } + + private boolean evaluateCustomSignalCondition( + CustomSignalCondition condition, KeysAndValues context) { + CustomSignalOperator customSignalOperator = condition.getCustomSignalOperator(); + String customSignalKey = condition.getCustomSignalKey(); + ImmutableList targetCustomSignalValues = + ImmutableList.copyOf(condition.getTargetCustomSignalValues()); + + if (targetCustomSignalValues.isEmpty()) { + logger.warn( + String.format( + "Values must be assigned to all custom signal fields. Operator:%s, Key:%s, Values:%s", + customSignalOperator, customSignalKey, targetCustomSignalValues)); + return false; + } + + String customSignalValue = context.get(customSignalKey); + if (customSignalValue == null) { + return false; + } + + switch (customSignalOperator) { + // String operations. + case STRING_CONTAINS: + return compareStrings( + targetCustomSignalValues, + customSignalValue, + (customSignal, targetSignal) -> customSignal.contains(targetSignal)); + case STRING_DOES_NOT_CONTAIN: + return !compareStrings( + targetCustomSignalValues, + customSignalValue, + (customSignal, targetSignal) -> customSignal.contains(targetSignal)); + case STRING_EXACTLY_MATCHES: + return compareStrings( + targetCustomSignalValues, + customSignalValue, + (customSignal, targetSignal) -> customSignal.equals(targetSignal)); + case STRING_CONTAINS_REGEX: + return compareStrings( + targetCustomSignalValues, + customSignalValue, + (customSignal, targetSignal) -> compareStringRegex(customSignal, targetSignal)); + + // Numeric operations. + case NUMERIC_LESS_THAN: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result < 0); + case NUMERIC_LESS_EQUAL: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result <= 0); + case NUMERIC_EQUAL: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result == 0); + case NUMERIC_NOT_EQUAL: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result != 0); + case NUMERIC_GREATER_THAN: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result > 0); + case NUMERIC_GREATER_EQUAL: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result >= 0); + + // Semantic operations. + case SEMANTIC_VERSION_EQUAL: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result == 0); + case SEMANTIC_VERSION_GREATER_EQUAL: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result >= 0); + case SEMANTIC_VERSION_GREATER_THAN: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result > 0); + case SEMANTIC_VERSION_LESS_EQUAL: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result <= 0); + case SEMANTIC_VERSION_LESS_THAN: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result < 0); + case SEMANTIC_VERSION_NOT_EQUAL: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result != 0); + default: + return false; + } + } + + private boolean evaluatePercentCondition(PercentCondition condition, KeysAndValues context) { + if (!context.containsKey("randomizationId")) { + logger.warn("Percentage operation must not be performed without randomizationId"); + return false; + } + + PercentConditionOperator operator = condition.getPercentConditionOperator(); + + // The micro-percent interval to be used with the BETWEEN operator. + MicroPercentRange microPercentRange = condition.getMicroPercentRange(); + int microPercentUpperBound = + microPercentRange != null ? microPercentRange.getMicroPercentUpperBound() : 0; + int microPercentLowerBound = + microPercentRange != null ? microPercentRange.getMicroPercentLowerBound() : 0; + // The limit of percentiles to target in micro-percents when using the + // LESS_OR_EQUAL and GREATER_THAN operators. The value must be in the range [0 + // and 100000000]. + int microPercent = condition.getMicroPercent(); + BigInteger microPercentile = + getMicroPercentile(condition.getSeed(), context.get("randomizationId")); + switch (operator) { + case LESS_OR_EQUAL: + return microPercentile.compareTo(BigInteger.valueOf(microPercent)) <= 0; + case GREATER_THAN: + return microPercentile.compareTo(BigInteger.valueOf(microPercent)) > 0; + case BETWEEN: + return microPercentile.compareTo(BigInteger.valueOf(microPercentLowerBound)) > 0 + && microPercentile.compareTo(BigInteger.valueOf(microPercentUpperBound)) <= 0; + case UNSPECIFIED: + default: + return false; + } + } + + private BigInteger getMicroPercentile(String seed, String randomizationId) { + String seedPrefix = seed != null && !seed.isEmpty() ? seed + "." : ""; + String stringToHash = seedPrefix + randomizationId; + BigInteger hash = hashSeededRandomizationId(stringToHash); + BigInteger microPercentile = hash.mod(MICRO_PERCENT_MODULO); + + return microPercentile; + } + + private BigInteger hashSeededRandomizationId(String seededRandomizationId) { + try { + // Create a SHA-256 hash. + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(seededRandomizationId.getBytes(StandardCharsets.UTF_8)); + + // Convert the hash bytes to a BigInteger + return new BigInteger(1, hashBytes); + } catch (NoSuchAlgorithmException e) { + logger.error("SHA-256 algorithm not found", e); + throw new RuntimeException("SHA-256 algorithm not found", e); + } + } + + private boolean compareStrings( + ImmutableList targetValues, + String customSignal, + BiPredicate compareFunction) { + return targetValues.stream() + .anyMatch(targetValue -> compareFunction.test(customSignal, targetValue)); + } + + private boolean compareStringRegex(String customSignal, String targetSignal) { + try { + return Pattern.compile(targetSignal).matcher(customSignal).matches(); + } catch (PatternSyntaxException e) { + return false; + } + } + + private boolean compareNumbers( + ImmutableList targetValues, String customSignal, IntPredicate compareFunction) { + if (targetValues.size() != 1) { + logger.warn( + String.format( + "Target values must contain 1 element for numeric operations. Target Value: %s", + targetValues)); + return false; + } + + try { + double customSignalDouble = Double.parseDouble(customSignal); + double targetValue = Double.parseDouble(targetValues.get(0)); + int comparisonResult = Double.compare(customSignalDouble, targetValue); + return compareFunction.test(comparisonResult); + } catch (NumberFormatException e) { + logger.warn( + "Error parsing numeric values: customSignal=%s, targetValue=%s", + customSignal, targetValues.get(0), e); + return false; + } + } + + private boolean compareSemanticVersions( + ImmutableList targetValues, String customSignal, IntPredicate compareFunction) { + if (targetValues.size() != 1) { + logger.warn(String.format("Target values must contain 1 element for semantic operation.")); + return false; + } + + String targetValueString = targetValues.get(0); + if (!validateSemanticVersion(targetValueString) || !validateSemanticVersion(customSignal)) { + return false; + } + + List targetVersion = parseSemanticVersion(targetValueString); + List customSignalVersion = parseSemanticVersion(customSignal); + + int maxLength = 5; + if (targetVersion.size() > maxLength || customSignalVersion.size() > maxLength) { + logger.warn( + "Semantic version max length(%s) exceeded. Target: %s, Custom Signal: %s", + maxLength, targetValueString, customSignal); + return false; + } + + int comparison = compareSemanticVersions(customSignalVersion, targetVersion); + return compareFunction.test(comparison); + } + + private int compareSemanticVersions(List version1, List version2) { + int maxLength = Math.max(version1.size(), version2.size()); + int version1Size = version1.size(); + int version2Size = version2.size(); + + for (int i = 0; i < maxLength; i++) { + // Default to 0 if segment is missing + int v1 = i < version1Size ? version1.get(i) : 0; + int v2 = i < version2Size ? version2.get(i) : 0; + + int comparison = Integer.compare(v1, v2); + if (comparison != 0) { + return comparison; + } + } + // Versions are equal + return 0; + } + + private List parseSemanticVersion(String versionString) { + return Arrays.stream(versionString.split("\\.")) + .map(Integer::parseInt) + .collect(Collectors.toList()); + } + + private boolean validateSemanticVersion(String version) { + return SEMVER_PATTERN.matcher(version).matches(); + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/CustomSignalCondition.java b/src/main/java/com/google/firebase/remoteconfig/CustomSignalCondition.java new file mode 100644 index 000000000..a8d96efdf --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/CustomSignalCondition.java @@ -0,0 +1,140 @@ +/* + * 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.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.firebase.internal.NonNull; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.CustomSignalConditionResponse; + +import java.util.ArrayList; +import java.util.List; + +final class CustomSignalCondition { + private final String customSignalKey; + private final CustomSignalOperator customSignalOperator; + private final ImmutableList targetCustomSignalValues; + + public CustomSignalCondition( + @NonNull String customSignalKey, + @NonNull CustomSignalOperator customSignalOperator, + @NonNull List targetCustomSignalValues) { + checkArgument( + !Strings.isNullOrEmpty(customSignalKey), "Custom signal key must not be null or empty."); + checkNotNull(customSignalOperator); + checkNotNull(targetCustomSignalValues); + checkArgument( + !targetCustomSignalValues.isEmpty(), "Target custom signal values must not be empty."); + this.customSignalKey = customSignalKey.trim(); + this.customSignalOperator = customSignalOperator; + this.targetCustomSignalValues = ImmutableList.copyOf(targetCustomSignalValues); + } + + CustomSignalCondition(CustomSignalConditionResponse customSignalCondition) { + checkArgument( + !Strings.isNullOrEmpty(customSignalCondition.getKey()), + "Custom signal key must not be null or empty."); + checkArgument( + !customSignalCondition.getTargetValues().isEmpty(), + "Target custom signal values must not be empty."); + this.customSignalKey = customSignalCondition.getKey().trim(); + List targetCustomSignalValuesList = customSignalCondition.getTargetValues(); + this.targetCustomSignalValues = ImmutableList.copyOf(targetCustomSignalValuesList); + switch (customSignalCondition.getOperator()) { + case "NUMERIC_EQUAL": + this.customSignalOperator = CustomSignalOperator.NUMERIC_EQUAL; + break; + case "NUMERIC_GREATER_EQUAL": + this.customSignalOperator = CustomSignalOperator.NUMERIC_GREATER_EQUAL; + break; + case "NUMERIC_GREATER_THAN": + this.customSignalOperator = CustomSignalOperator.NUMERIC_GREATER_THAN; + break; + case "NUMERIC_LESS_EQUAL": + this.customSignalOperator = CustomSignalOperator.NUMERIC_LESS_EQUAL; + break; + case "NUMERIC_LESS_THAN": + this.customSignalOperator = CustomSignalOperator.NUMERIC_LESS_THAN; + break; + case "NUMERIC_NOT_EQUAL": + this.customSignalOperator = CustomSignalOperator.NUMERIC_NOT_EQUAL; + break; + case "SEMANTIC_VERSION_EQUAL": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_EQUAL; + break; + case "SEMANTIC_VERSION_GREATER_EQUAL": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL; + break; + case "SEMANTIC_VERSION_GREATER_THAN": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN; + break; + case "SEMANTIC_VERSION_LESS_EQUAL": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL; + break; + case "SEMANTIC_VERSION_LESS_THAN": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN; + break; + case "SEMANTIC_VERSION_NOT_EQUAL": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL; + break; + case "STRING_CONTAINS": + this.customSignalOperator = CustomSignalOperator.STRING_CONTAINS; + break; + case "STRING_CONTAINS_REGEX": + this.customSignalOperator = CustomSignalOperator.STRING_CONTAINS_REGEX; + break; + case "STRING_DOES_NOT_CONTAIN": + this.customSignalOperator = CustomSignalOperator.STRING_DOES_NOT_CONTAIN; + break; + case "STRING_EXACTLY_MATCHES": + this.customSignalOperator = CustomSignalOperator.STRING_EXACTLY_MATCHES; + break; + default: + this.customSignalOperator = CustomSignalOperator.UNSPECIFIED; + } + checkArgument( + this.customSignalOperator != CustomSignalOperator.UNSPECIFIED, + "Custom signal operator passed is invalid"); + } + + @NonNull + String getCustomSignalKey() { + return customSignalKey; + } + + @NonNull + CustomSignalOperator getCustomSignalOperator() { + return customSignalOperator; + } + + @NonNull + List getTargetCustomSignalValues() { + return new ArrayList<>(targetCustomSignalValues); + } + + CustomSignalConditionResponse toCustomConditonResponse() { + CustomSignalConditionResponse customSignalConditionResponse = + new CustomSignalConditionResponse(); + customSignalConditionResponse.setKey(this.customSignalKey); + customSignalConditionResponse.setOperator(this.customSignalOperator.getOperator()); + customSignalConditionResponse.setTargetValues(this.targetCustomSignalValues); + return customSignalConditionResponse; + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/CustomSignalOperator.java b/src/main/java/com/google/firebase/remoteconfig/CustomSignalOperator.java new file mode 100644 index 000000000..0c0924d62 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/CustomSignalOperator.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Strings; +import com.google.firebase.internal.NonNull; + +enum CustomSignalOperator { + NUMERIC_EQUAL("NUMERIC_EQUAL"), + NUMERIC_GREATER_EQUAL("NUMERIC_GREATER_EQUAL"), + NUMERIC_GREATER_THAN("NUMERIC_GREATER_THAN"), + NUMERIC_LESS_EQUAL("NUMERIC_LESS_EQUAL"), + NUMERIC_LESS_THAN("NUMERIC_LESS_THAN"), + NUMERIC_NOT_EQUAL("NUMERIC_NOT_EQUAL"), + SEMANTIC_VERSION_EQUAL("SEMANTIC_VERSION_EQUAL"), + SEMANTIC_VERSION_GREATER_EQUAL("SEMANTIC_VERSION_GREATER_EQUAL"), + SEMANTIC_VERSION_GREATER_THAN("SEMANTIC_VERSION_GREATER_THAN"), + SEMANTIC_VERSION_LESS_EQUAL("SEMANTIC_VERSION_LESS_EQUAL"), + SEMANTIC_VERSION_LESS_THAN("SEMANTIC_VERSION_LESS_THAN"), + SEMANTIC_VERSION_NOT_EQUAL("SEMANTIC_VERSION_NOT_EQUAL"), + STRING_CONTAINS("STRING_CONTAINS"), + STRING_CONTAINS_REGEX("STRING_CONTAINS_REGEX"), + STRING_DOES_NOT_CONTAIN("STRING_DOES_NOT_CONTAIN"), + STRING_EXACTLY_MATCHES("STRING_EXACTLY_MATCHES"), + UNSPECIFIED("CUSTOM_SIGNAL_OPERATOR_UNSPECIFIED"); + + private final String operator; + + CustomSignalOperator(@NonNull String operator) { + checkArgument(!Strings.isNullOrEmpty(operator), "Operator must not be null or empty."); + this.operator = operator; + } + + @NonNull + String getOperator() { + return operator; + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java new file mode 100644 index 000000000..e3edce4e4 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java @@ -0,0 +1,483 @@ +/* + * Copyright 2020 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.remoteconfig; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.core.ApiFuture; +import com.google.common.annotations.VisibleForTesting; +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.internal.CallableOperation; +import com.google.firebase.internal.FirebaseService; +import com.google.firebase.internal.NonNull; + +/** + * This class is the entry point for all server-side Firebase Remote Config actions. + * + *

You can get an instance of {@link FirebaseRemoteConfig} via {@link #getInstance(FirebaseApp)}, + * and then use it to manage Remote Config templates. + */ +public final class FirebaseRemoteConfig { + + private static final String SERVICE_ID = FirebaseRemoteConfig.class.getName(); + private final FirebaseApp app; + private final FirebaseRemoteConfigClient remoteConfigClient; + + @VisibleForTesting + FirebaseRemoteConfig(FirebaseApp app, FirebaseRemoteConfigClient client) { + this.app = checkNotNull(app); + this.remoteConfigClient = checkNotNull(client); + } + + private FirebaseRemoteConfig(FirebaseApp app) { + this(app, FirebaseRemoteConfigClientImpl.fromApp(app)); + } + + /** + * Gets the {@link FirebaseRemoteConfig} instance for the default {@link FirebaseApp}. + * + * @return The {@link FirebaseRemoteConfig} instance for the default {@link FirebaseApp}. + */ + public static FirebaseRemoteConfig getInstance() { + return getInstance(FirebaseApp.getInstance()); + } + + /** + * Gets the {@link FirebaseRemoteConfig} instance for the specified {@link FirebaseApp}. + * + * @return The {@link FirebaseRemoteConfig} instance for the specified {@link FirebaseApp}. + */ + public static synchronized FirebaseRemoteConfig getInstance(FirebaseApp app) { + FirebaseRemoteConfigService service = ImplFirebaseTrampolines.getService(app, SERVICE_ID, + FirebaseRemoteConfigService.class); + if (service == null) { + service = ImplFirebaseTrampolines.addService(app, new FirebaseRemoteConfigService(app)); + } + return service.getInstance(); + } + + /** + * Gets the current active version of the Remote Config template. + * + * @return A {@link Template}. + * @throws FirebaseRemoteConfigException If an error occurs while getting the template. + */ + public Template getTemplate() throws FirebaseRemoteConfigException { + return getTemplateOp().call(); + } + + /** + * Similar to {@link #getTemplate()} but performs the operation asynchronously. + * + * @return An {@code ApiFuture} that completes with a {@link Template} when + * the template is available. + */ + public ApiFuture