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..feb690edb 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..7740dccd8 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..da1d3057d --- /dev/null +++ b/.github/resources/settings.xml @@ -0,0 +1,29 @@ + + + + false + + + + central + ${env.CENTRAL_USERNAME} + ${env.CENTRAL_TOKEN} + + + + + + release + + true + + + gpg + 0A05D8FAD4287A36C53BE07714D6B82AEB1DD39C + ${env.GPG_PASSPHRASE} + + + + diff --git a/.github/scripts/generate_changelog.sh b/.github/scripts/generate_changelog.sh new file mode 100755 index 000000000..e393f40e4 --- /dev/null +++ b/.github/scripts/generate_changelog.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +# Copyright 2020 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. + +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..30d998c75 --- /dev/null +++ b/.github/scripts/package_artifacts.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +# Copyright 2020 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. + +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..8e20af88f --- /dev/null +++ b/.github/scripts/publish_artifacts.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# Copyright 2020 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. + +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..7c8e384e2 --- /dev/null +++ b/.github/scripts/publish_preflight_check.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +# Copyright 2020 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. + + +###################################### 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 master --prune --unshallow >---" +git fetch origin master --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..39948e9e9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +# Copyright 2020 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. +name: Continuous Integration + +on: pull_request + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + java-version: [8, 11, 17] + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + 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..7bda5ba58 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,84 @@ +# Copyright 2021 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. + +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] + +jobs: + nightly: + + runs-on: ubuntu-latest + + steps: + - name: Checkout source for staging + uses: actions/checkout@v4 + with: + ref: ${{ github.event.client_payload.ref || github.ref }} + + - name: Set up JDK 8 + uses: actions/setup-java@v4 + 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@v4 + with: + name: dist + path: dist + + - name: Send email on failure + if: failure() + uses: firebase/firebase-admin-node/.github/actions/send-email@master + 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@master + 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..86774e50c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,127 @@ +# Copyright 2020 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. + +name: Release Candidate + +on: + # Only run the workflow when a PR is updated or when a developer explicitly requests + # a build by sending a 'firebase_build' event. + pull_request: + types: [opened, synchronize, closed] + + repository_dispatch: + types: + - firebase_build + +jobs: + stage_release: + # To publish a release, merge the release PR with the label 'release:publish'. + # 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.pull_request.merged && + contains(github.event.pull_request.labels.*.name, 'release:publish')) + + runs-on: ubuntu-latest + + # When manually triggering the build, the requester can specify a target branch or a tag + # via the 'ref' client parameter. + steps: + - name: Checkout source for staging + uses: actions/checkout@v4 + with: + ref: ${{ github.event.client_payload.ref || github.ref }} + + - name: Set up JDK 8 + uses: actions/setup-java@v4 + 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@v4 + with: + name: dist + path: dist + + publish_release: + needs: stage_release + + # Check whether the release should be published. We publish only when the trigger PR is + # 1. merged + # 2. to the master branch + # 3. with the label 'release:publish', and + # 4. the title prefix '[chore] Release '. + if: github.event.pull_request.merged && + github.ref == 'refs/heads/master' && + contains(github.event.pull_request.labels.*.name, 'release:publish') && + startsWith(github.event.pull_request.title, '[chore] Release ') + + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout source for publish + uses: actions/checkout@v4 + + - name: Set up JDK 8 + uses: actions/setup-java@v4 + 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 }} + 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 }} + run: gh release create ${{ steps.preflight.outputs.version }} + --title "Firebase Admin Java SDK ${{ steps.preflight.outputs.version }}" + --notes '${{ steps.preflight.outputs.changelog }}' + + # Post to Twitter if explicitly opted-in by adding the label 'release:tweet'. + - name: Post to Twitter + if: success() && + contains(github.event.pull_request.labels.*.name, 'release:tweet') + uses: firebase/firebase-admin-node/.github/actions/send-tweet@master + with: + status: > + ${{ steps.preflight.outputs.version }} of @Firebase Admin Java SDK is available. + https://github.com/firebase/firebase-admin-java/releases/tag/${{ steps.preflight.outputs.version }} + consumer-key: ${{ secrets.TWITTER_CONSUMER_KEY }} + consumer-secret: ${{ secrets.TWITTER_CONSUMER_SECRET }} + access-token: ${{ secrets.TWITTER_ACCESS_TOKEN }} + access-token-secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} + continue-on-error: true 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 new file mode 100644 index 000000000..56484ef37 --- /dev/null +++ b/.opensource/project.json @@ -0,0 +1,15 @@ +{ + "name": "Firebase Admin SDK - Java", + "platforms": [ + "Java", + "Admin" + ], + "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/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 689833459..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,394 +0,0 @@ -# Unreleased - -- [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..2e6914c5f 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 @@ -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..b6c5d02b2 --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,5 @@ +Firebase Admin Java SDK +Copyright 2019 Google Inc. + +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..77e2dba54 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -42,15 +42,15 @@ - + - - + + @@ -229,6 +229,9 @@ + + + diff --git a/pom.xml b/pom.xml index f1d07a8c7..275a7830d 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,7 @@ com.google.firebase firebase-admin - 5.9.1-SNAPSHOT + 9.7.0 jar firebase-admin @@ -36,8 +36,8 @@ - Commercial - https://firebase.google.com/terms/ + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt @@ -59,7 +59,7 @@ UTF-8 UTF-8 ${skipTests} - 4.1.17.Final + 4.2.7.Final @@ -69,24 +69,7 @@ HEAD - - - ossrh - https://oss.sonatype.org/content/repositories/snapshots - - - - - - disable-java8-doclint - - [1.8,) - - - -Xdoclint:none - - devsite-apidocs @@ -99,6 +82,7 @@ maven-javadoc-plugin + 3.12.0 site @@ -122,24 +106,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.1.0 site @@ -161,59 +146,11 @@ 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 +158,12 @@ sign + + + --pinentry-mode + loopback + + @@ -239,7 +182,7 @@ org.jacoco jacoco-maven-plugin - 0.7.9 + 0.8.13 pre-unit-test @@ -265,7 +208,7 @@ org.codehaus.mojo exec-maven-plugin - 1.6.0 + 3.6.2 test @@ -295,6 +238,7 @@ + maven-checkstyle-plugin 2.17 @@ -315,120 +259,172 @@ + + maven-compiler-plugin - 3.6.1 + 3.14.0 - 1.7 - 1.7 + 1.8 + 1.8 + + maven-surefire-plugin - 2.19.1 + 3.5.4 ${skipUTs} + + - maven-failsafe-plugin - 2.19.1 + maven-source-plugin + 3.3.1 + 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 + + + + maven-failsafe-plugin + 3.5.3 + + + + integration-test + verify + + + + + + - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.7 + org.sonatype.central + central-publishing-maven-plugin + 0.8.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.71.0 + pom + import + + + + + + com.google.auth + google-auth-library-oauth2-http + com.google.api-client google-api-client - 1.23.0 - - - com.google.guava - guava-jdk5 - - com.google.api-client google-api-client-gson - 1.23.0 com.google.http-client google-http-client - 1.23.0 com.google.api api-common - 1.2.0 - - - com.google.auth - google-auth-library-oauth2-http - 0.8.0 com.google.cloud google-cloud-storage - 1.15.0 com.google.cloud google-cloud-firestore - 0.33.0-beta com.google.guava guava - 20.0 - - - org.json - json - 20160810 org.slf4j slf4j-api - 1.7.25 + 2.0.17 io.netty @@ -445,12 +441,17 @@ netty-transport ${netty.version} + + org.apache.httpcomponents.client5 + httpclient5 + 5.3.1 + org.mockito mockito-core - 2.7.21 + 4.11.0 test @@ -460,15 +461,22 @@ test - com.cedarsoftware - java-util - 1.26.0 + junit + junit + 4.13.2 + test + + + + javax.ws.rs + javax.ws.rs-api + 2.1.1 test - junit - junit - 4.12 + org.hamcrest + hamcrest + 3.0 test diff --git a/prepare_release.sh b/prepare_release.sh index 47a7f4ed3..b531557ef 100755 --- a/prepare_release.sh +++ b/prepare_release.sh @@ -75,20 +75,6 @@ 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 "1 s/# Unreleased//" "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} - ################################# # RUN MAVEN PREPARATION STEPS # 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..0eaa9cec2 --- /dev/null +++ b/src/main/java/com/google/firebase/ErrorCode.java @@ -0,0 +1,109 @@ +/* + * Copyright 2020 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; + +/** + * 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 52cffb15c..27727c366 100644 --- a/src/main/java/com/google/firebase/FirebaseApp.java +++ b/src/main/java/com/google/firebase/FirebaseApp.java @@ -19,11 +19,11 @@ 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 java.nio.charset.StandardCharsets.UTF_8; +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; import com.google.auth.oauth2.AccessToken; import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.OAuth2Credentials; @@ -34,27 +34,21 @@ import com.google.common.base.MoreObjects; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; -import com.google.common.io.BaseEncoding; -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.GaeThreadFactory; +import com.google.firebase.internal.ListenableFuture2ApiFuture; import com.google.firebase.internal.NonNull; import com.google.firebase.internal.Nullable; -import com.google.firebase.internal.RevivingScheduledExecutor; -import com.google.firebase.tasks.Task; -import com.google.firebase.tasks.Tasks; - -import java.io.FileNotFoundException; import java.io.FileReader; import java.io.IOException; 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; @@ -124,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()); } @@ -135,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); } @@ -176,6 +168,9 @@ public static FirebaseApp getInstance(@NonNull String name) { * by looking up the {@code FIREBASE_CONFIG} environment variable. If the value of * the variable starts with '{', it is parsed as a JSON object. Otherwise it is * treated as a file name and the JSON content is read from the corresponding file. + * + * @throws IllegalStateException if the default app has already been initialized. + * @throws IllegalArgumentException if an error occurs while loading options from the environment. */ public static FirebaseApp initializeApp() { return initializeApp(DEFAULT_APP_NAME); @@ -185,13 +180,23 @@ public static FirebaseApp initializeApp() { * Initializes a named {@link FirebaseApp} instance using Google Application Default Credentials. * Loads additional {@link FirebaseOptions} from the environment in the same way as the * {@link #initializeApp()} method. + * + * @throws IllegalStateException if an app with the same name has already been initialized. + * @throws IllegalArgumentException if an error occurs while loading options from the environment. */ public static FirebaseApp initializeApp(String name) { - return initializeApp(getOptionsFromEnvironment(), name); + try { + return initializeApp(getOptionsFromEnvironment(), name); + } catch (IOException e) { + throw new IllegalArgumentException( + "Failed to load settings from the system's environment variables", e); + } } /** * Initializes the default {@link FirebaseApp} instance using the given options. + * + * @throws IllegalStateException if the default app has already been initialized. */ public static FirebaseApp initializeApp(FirebaseOptions options) { return initializeApp(options, DEFAULT_APP_NAME); @@ -212,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 @@ -241,33 +241,14 @@ static void clearInstancesForTest() { } } - /** - * Returns persistence key. Exists to support getting {@link FirebaseApp} persistence key after - * the app has been deleted. - */ - static String getPersistenceKey(String name, FirebaseOptions options) { - return BaseEncoding.base64Url().omitPadding().encode(name.getBytes(UTF_8)); - } - - /** Use this key to store data per FirebaseApp. */ - String getPersistenceKey() { - return FirebaseApp.getPersistenceKey(getName(), getOptions()); - } - 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. */ @@ -281,8 +262,8 @@ public String getName() { return name; } - /** - * Returns the specified {@link FirebaseOptions}. + /** + * Returns the specified {@link FirebaseOptions}. */ @NonNull public FirebaseOptions getOptions() { @@ -297,6 +278,8 @@ public FirebaseOptions getOptions() { */ @Nullable String getProjectId() { + checkNotDeleted(); + // Try to get project ID from user-specified options. String projectId = options.getProjectId(); @@ -310,17 +293,17 @@ String getProjectId() { // Try to get project ID from the environment. if (Strings.isNullOrEmpty(projectId)) { - projectId = System.getenv("GCLOUD_PROJECT"); + projectId = FirebaseProcessEnvironment.getenv("GOOGLE_CLOUD_PROJECT"); + } + if (Strings.isNullOrEmpty(projectId)) { + projectId = FirebaseProcessEnvironment.getenv("GCLOUD_PROJECT"); } return projectId; } @Override public boolean equals(Object o) { - if (!(o instanceof FirebaseApp)) { - return false; - } - return name.equals(((FirebaseApp) o).getName()); + return o instanceof FirebaseApp && name.equals(((FirebaseApp) o).getName()); } @Override @@ -334,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. */ @@ -363,11 +348,6 @@ public void delete() { synchronized (appsLock) { instances.remove(name); } - - FirebaseAppStore appStore = FirebaseAppStore.getInstance(); - if (appStore != null) { - appStore.removeApp(name); - } } private void checkNotDeleted() { @@ -383,8 +363,8 @@ private ScheduledExecutorService ensureScheduledExecutorService() { synchronized (lock) { checkNotDeleted(); if (scheduledExecutor == null) { - scheduledExecutor = new RevivingScheduledExecutor(threadManager.getThreadFactory(), - "firebase-scheduled-worker", GaeThreadFactory.isAvailable()); + scheduledExecutor = new FirebaseScheduledExecutor(getThreadFactory(), + "firebase-scheduled-worker"); } } } @@ -395,10 +375,13 @@ ThreadFactory getThreadFactory() { return threadManager.getThreadFactory(); } - // TODO: Return an ApiFuture once Task API is fully removed. - Task submit(Callable command) { + ScheduledExecutorService getScheduledExecutorService() { + return ensureScheduledExecutorService(); + } + + ApiFuture submit(Callable command) { checkNotNull(command); - return Tasks.call(executors.getListeningExecutor(), command); + return new ListenableFuture2ApiFuture<>(executors.getListeningExecutor().submit(command)); } ScheduledFuture schedule(Callable command, long delayMillis) { @@ -411,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(); @@ -462,7 +456,7 @@ static class TokenRefresher implements CredentialsChangedListener { } @Override - public final synchronized void onChanged(OAuth2Credentials credentials) throws IOException { + public final synchronized void onChanged(OAuth2Credentials credentials) { if (state.get() != State.STARTED) { return; } @@ -569,33 +563,24 @@ enum State { } } - private static FirebaseOptions getOptionsFromEnvironment() { - String defaultConfig = System.getenv(FIREBASE_CONFIG_ENV_VAR); - try { - if (Strings.isNullOrEmpty(defaultConfig)) { - return new FirebaseOptions.Builder() - .setCredentials(GoogleCredentials.getApplicationDefault()) + private static FirebaseOptions getOptionsFromEnvironment() throws IOException { + String defaultConfig = FirebaseProcessEnvironment.getenv(FIREBASE_CONFIG_ENV_VAR); + if (Strings.isNullOrEmpty(defaultConfig)) { + return FirebaseOptions.builder() + .setCredentials(APPLICATION_DEFAULT_CREDENTIALS) .build(); - } - JsonFactory jsonFactory = Utils.getDefaultJsonFactory(); - FirebaseOptions.Builder builder = new FirebaseOptions.Builder(); - JsonParser parser; - if (defaultConfig.startsWith("{")) { - parser = jsonFactory.createJsonParser(defaultConfig); - } else { - FileReader reader; - reader = new FileReader(defaultConfig); - parser = jsonFactory.createJsonParser(reader); - } - parser.parseAndClose(builder); - builder.setCredentials(GoogleCredentials.getApplicationDefault()); - - return builder.build(); - - } catch (FileNotFoundException e) { - throw new IllegalStateException(e); - } catch (IOException e) { - throw new IllegalStateException(e); } + JsonFactory jsonFactory = ApiClientUtils.getDefaultJsonFactory(); + FirebaseOptions.Builder builder = FirebaseOptions.builder(); + JsonParser parser; + if (defaultConfig.startsWith("{")) { + parser = jsonFactory.createJsonParser(defaultConfig); + } else { + FileReader reader = new FileReader(defaultConfig); + parser = jsonFactory.createJsonParser(reader); + } + parser.parseAndClose(builder); + 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..6493edb60 100644 --- a/src/main/java/com/google/firebase/FirebaseAppLifecycleListener.java +++ b/src/main/java/com/google/firebase/FirebaseAppLifecycleListener.java @@ -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..a5bb80424 100644 --- a/src/main/java/com/google/firebase/FirebaseException.java +++ b/src/main/java/com/google/firebase/FirebaseException.java @@ -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 fc4df2658..03f1b34a4 100644 --- a/src/main/java/com/google/firebase/FirebaseOptions.java +++ b/src/main/java/com/google/firebase/FirebaseOptions.java @@ -19,55 +19,103 @@ 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.firebase.auth.FirebaseCredential; -import com.google.firebase.auth.FirebaseCredentials; -import com.google.firebase.auth.internal.BaseCredential; -import com.google.firebase.auth.internal.FirebaseCredentialsAdapter; +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; /** Configurable Firebase options. */ public final class FirebaseOptions { - // TODO: deprecate and remove it once we can fetch these from Remote Config. + private static final List FIREBASE_SCOPES = + ImmutableList.of( + // Enables access to Firebase Realtime Database. + "https://www.googleapis.com/auth/firebase.database", + + // Enables access to the email address associated with a project. + "https://www.googleapis.com/auth/userinfo.email", + + // Enables access to Google Identity Toolkit (for user management APIs). + "https://www.googleapis.com/auth/identitytoolkit", + + // Enables access to Google Cloud Storage. + "https://www.googleapis.com/auth/devstorage.full_control", + + // Enables access to Google Cloud Firestore + "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(BaseCredential.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)) { checkArgument(!builder.storageBucket.startsWith("gs://"), "StorageBucket must not include 'gs://' prefix."); } + if (!Strings.isNullOrEmpty(builder.serviceAccountId)) { + this.serviceAccountId = builder.serviceAccountId; + } else { + 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; } /** @@ -89,7 +137,7 @@ public String getStorageBucket() { } GoogleCredentials getCredentials() { - return credentials; + return credentialsSupplier.get(); } /** @@ -111,6 +159,16 @@ public String getProjectId() { return projectId; } + /** + * Returns the client email address of the service account. + * + * @return The client email of the service account set via + * {@link Builder#setServiceAccountId(String)} + */ + public String getServiceAccountId() { + return serviceAccountId; + } + /** * Returns the HttpTransport used to call remote HTTP endpoints. This transport is * used by all services of the SDK, except for FirebaseDatabase. @@ -132,33 +190,95 @@ public JsonFactory getJsonFactory() { return jsonFactory; } + /** + * Returns the connect timeout in milliseconds, which is applied to outgoing REST calls + * made by the SDK. + * + * @return Connect timeout in milliseconds. 0 indicates an infinite timeout. + */ + public int getConnectTimeout() { + return connectTimeout; + } + + /** + * Returns the read timeout applied to outgoing REST calls in milliseconds. + * + * @return Read timeout in milliseconds. 0 indicates an infinite timeout. + */ + 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; } + FirestoreOptions getFirestoreOptions() { + return firestoreOptions; + } + + /** + * Creates an empty builder. + * + * @return A new builder instance. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * 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}. + * 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; - - private GoogleCredentials credentials; - private HttpTransport httpTransport = Utils.getDefaultTransport(); - private JsonFactory jsonFactory = Utils.getDefaultJsonFactory(); - private ThreadManager threadManager = FirebaseThreadManagers.DEFAULT_THREAD_MANAGER; - /** Constructs an empty builder. */ + @Key("serviceAccountId") + private String serviceAccountId; + private Supplier credentialsSupplier; + private FirestoreOptions firestoreOptions; + private HttpTransport httpTransport; + private JsonFactory jsonFactory; + private ThreadManager threadManager; + private int connectTimeout; + private int readTimeout; + private int writeTimeout; + + /** + * Constructs an empty builder. + * + * @deprecated Use {@link FirebaseOptions#builder()} instead. + */ + @Deprecated public Builder() {} /** @@ -166,16 +286,23 @@ 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; jsonFactory = options.jsonFactory; threadManager = options.threadManager; + connectTimeout = options.connectTimeout; + readTimeout = options.readTimeout; + writeTimeout = options.writeTimeout; + firestoreOptions = options.firestoreOptions; } /** @@ -215,36 +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 FirebaseCredential to use to authenticate the SDK. + * Sets the Supplier of GoogleCredentials to use to authenticate the + * SDK. This is NOT intended for public use outside the SDK. * - * @param credential A FirebaseCredential used to authenticate the SDK. See {@link - * FirebaseCredentials} for default implementations. + * @param credentialsSupplier Supplier instance that wraps GoogleCredentials. * @return This Builder instance is returned so subsequent calls can be chained. - * @deprecated Use {@link FirebaseOptions.Builder#setCredentials(GoogleCredentials)}. */ - public Builder setCredential(@NonNull FirebaseCredential credential) { - checkNotNull(credential); - if (credential instanceof BaseCredential) { - this.credentials = ((BaseCredential) credential).getGoogleCredentials(); - } else { - this.credentials = new FirebaseCredentialsAdapter(credential); - } + Builder setCredentials(Supplier credentialsSupplier) { + this.credentialsSupplier = checkNotNull(credentialsSupplier); return this; } @@ -284,6 +407,24 @@ public Builder setProjectId(@NonNull String projectId) { return this; } + /** + * Sets the client email address of the service account that should be associated with an app. + * + *

This is used to + * create custom auth tokens when service account credentials are not available. The client + * email address of a service account can be found in the {@code client_email} field of the + * service account JSON. + * + * @param serviceAccountId A service account email address string. + * @return This Builder instance is returned so subsequent calls can be chained. + */ + public Builder setServiceAccountId(@NonNull String serviceAccountId) { + checkArgument(!Strings.isNullOrEmpty(serviceAccountId), + "Service account ID must not be null or empty"); + this.serviceAccountId = serviceAccountId; + return this; + } + /** * Sets the HttpTransport used to make remote HTTP calls. A reasonable default * is used if not explicitly set. The transport specified by calling this method is @@ -293,7 +434,8 @@ public Builder setProjectId(@NonNull String projectId) { * @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; } @@ -305,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; } @@ -317,7 +460,64 @@ 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; + } + + /** + * Sets the FirestoreOptions used to initialize Firestore in the + * {@link com.google.firebase.cloud.FirestoreClient} API. This can be used to customize + * low-level transport (GRPC) parameters, and timestamp handling behavior. + * + *

If credentials or a project ID is set in FirestoreOptions, they will get + * overwritten by the corresponding parameters in FirebaseOptions. + * + * @param firestoreOptions A FirestoreOptions instance. + * @return This Builder instance is returned so subsequent calls can be chained. + */ + public Builder setFirestoreOptions(FirestoreOptions firestoreOptions) { + this.firestoreOptions = firestoreOptions; + return this; + } + + /** + * Sets the connect timeout for outgoing HTTP (REST) connections made by the SDK. This is used + * when opening a communication link to a remote HTTP endpoint. This setting does not + * affect the {@link com.google.firebase.database.FirebaseDatabase} and + * {@link com.google.firebase.cloud.FirestoreClient} APIs. + * + * @param connectTimeout Connect timeout in milliseconds. Must not be negative. + * @return This Builder instance is returned so subsequent calls can be chained. + */ + public Builder setConnectTimeout(int connectTimeout) { + this.connectTimeout = connectTimeout; + return this; + } + + /** + * Sets the read 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 readTimeout Read timeout in milliseconds. Must not be negative. + * @return This Builder instance is returned so subsequent calls can be chained. + */ + public Builder setReadTimeout(int readTimeout) { + this.readTimeout = 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; } diff --git a/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java b/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java index 7ffac6d11..0d3008b18 100644 --- a/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java +++ b/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java @@ -16,12 +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 com.google.firebase.tasks.Task; import java.util.concurrent.Callable; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadFactory; /** @@ -36,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(); } @@ -43,16 +53,12 @@ public static String getProjectId(@NonNull FirebaseApp app) { return app.getProjectId(); } - public static boolean isDefaultApp(@NonNull FirebaseApp app) { - return app.isDefaultApp(); + public static FirestoreOptions getFirestoreOptions(@NonNull FirebaseApp app) { + return app.getOptions().getFirestoreOptions(); } - public static String getPersistenceKey(@NonNull FirebaseApp app) { - return app.getPersistenceKey(); - } - - public static String getPersistenceKey(String name, FirebaseOptions options) { - return FirebaseApp.getPersistenceKey(name, options); + public static boolean isDefaultApp(@NonNull FirebaseApp app) { + return app.isDefaultApp(); } public static T getService( @@ -70,7 +76,25 @@ public static ThreadFactory getThreadFactory(@NonNull FirebaseApp app) { return app.getThreadFactory(); } - public static Task submitCallable(@NonNull FirebaseApp app, @NonNull Callable command) { + 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..cfeac5e70 --- /dev/null +++ b/src/main/java/com/google/firebase/IncomingHttpResponse.java @@ -0,0 +1,114 @@ +/* + * Copyright 2020 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; + +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..44af4bff0 --- /dev/null +++ b/src/main/java/com/google/firebase/OutgoingHttpRequest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2020 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; + +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 4f3c5f29d..8903101f6 100644 --- a/src/main/java/com/google/firebase/ThreadManager.java +++ b/src/main/java/com/google/firebase/ThreadManager.java @@ -57,7 +57,7 @@ final void releaseFirebaseExecutors( * {@link #getThreadFactory()} method. * * @param app A {@link FirebaseApp} instance. - * @return A non-null {@link ExecutorService} instance. + * @return A non-null ExecutorService instance. */ @NonNull protected abstract ExecutorService getExecutor(@NonNull FirebaseApp app); 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..8ffccd542 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/ActionCodeSettings.java @@ -0,0 +1,221 @@ +/* + * Copyright 2018 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; + +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..9f7ecebf1 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/AuthErrorCode.java @@ -0,0 +1,121 @@ +/* + * Copyright 2020 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; + +/** + * 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..e8ca7dba5 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/DeleteUsersResult.java @@ -0,0 +1,74 @@ +/* + * Copyright 2020 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; + +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..8e729c220 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/EmailIdentifier.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020 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; + +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 new file mode 100644 index 000000000..7f7a5e346 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/ErrorInfo.java @@ -0,0 +1,52 @@ +/* + * Copyright 2018 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; + +import java.util.List; + +/** + * Represents an error encountered while importing an {@link ImportUserRecord}. + */ +public final class ErrorInfo { + + private final int index; + private final String reason; + + ErrorInfo(int index, String reason) { + this.index = index; + this.reason = reason; + } + + /** + * The index of the failed user in the list passed to the + * {@link FirebaseAuth#importUsersAsync(List, UserImportOptions)} method. + * + * @return an integer index. + */ + public int getIndex() { + return index; + } + + /** + * A string describing the error. + * + * @return A string error message. + */ + public String getReason() { + return reason; + } +} diff --git a/src/main/java/com/google/firebase/auth/ExportedUserRecord.java b/src/main/java/com/google/firebase/auth/ExportedUserRecord.java index e367f55ae..9fdfc9c5e 100644 --- a/src/main/java/com/google/firebase/auth/ExportedUserRecord.java +++ b/src/main/java/com/google/firebase/auth/ExportedUserRecord.java @@ -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 bbb9feb2b..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,33 +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.googleapis.auth.oauth2.GooglePublicKeysManager; -import com.google.api.client.json.JsonFactory; -import com.google.api.client.util.Clock; -import com.google.api.core.ApiFuture; -import com.google.auth.oauth2.GoogleCredentials; -import com.google.auth.oauth2.ServiceAccountCredentials; -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.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.multitenancy.TenantManager; import com.google.firebase.internal.FirebaseService; -import com.google.firebase.internal.Nullable; -import com.google.firebase.internal.TaskToApiFuture; -import com.google.firebase.tasks.Task; -import java.util.Map; -import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicBoolean; /** * This class is the entry point for all server-side Firebase Authentication actions. @@ -52,43 +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 { +public final class FirebaseAuth extends AbstractFirebaseAuth { - private final GooglePublicKeysManager googlePublicKeysManager; - private final Clock clock; + private static final String SERVICE_ID = FirebaseAuth.class.getName(); - private final FirebaseApp firebaseApp; - private final GoogleCredentials credentials; - private final String projectId; - private final JsonFactory jsonFactory; - private final FirebaseUserManager userManager; - private final AtomicBoolean destroyed; - private final Object lock; + private final Supplier tenantManager; - private FirebaseAuth(FirebaseApp firebaseApp) { - this(firebaseApp, - FirebaseTokenVerifier.buildGooglePublicKeysManager( - firebaseApp.getOptions().getHttpTransport()), - 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, GooglePublicKeysManager googlePublicKeysManager, Clock clock) { - this.firebaseApp = checkNotNull(firebaseApp); - this.googlePublicKeysManager = checkNotNull(googlePublicKeysManager); - this.clock = checkNotNull(clock); - this.credentials = ImplFirebaseTrampolines.getCredentials(firebaseApp); - this.projectId = ImplFirebaseTrampolines.getProjectId(firebaseApp); - this.jsonFactory = firebaseApp.getOptions().getJsonFactory(); - this.userManager = new FirebaseUserManager(jsonFactory, - firebaseApp.getOptions().getHttpTransport(), this.credentials); - this.destroyed = new AtomicBoolean(false); - this.lock = new Object(); + public TenantManager getTenantManager() { + return tenantManager.get(); } /** @@ -107,540 +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(); } - /** - * Similar to {@link #createCustomTokenAsync(String)}, but returns a {@link Task}. - * - * @param uid The UID to store in the token. This identifies the user to other Firebase services - * (Firebase Database, Firebase Auth, etc.) - * @return A {@link Task} which will complete successfully with the created Firebase Custom Token, - * or unsuccessfully with the failure Exception. - * @deprecated Use {@link #createCustomTokenAsync(String)} - */ - public Task createCustomToken(String uid) { - return createCustomToken(uid, null); - } - - /** - * Similar to {@link #createCustomTokenAsync(String, Map)}, but returns a {@link Task}. - * - * @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 A {@link Task} which will complete successfully with the created Firebase Custom Token, - * or unsuccessfully with the failure Exception. - * @deprecated Use {@link #createCustomTokenAsync(String, Map)} - */ - public Task createCustomToken( - final String uid, final Map developerClaims) { - checkNotDestroyed(); - checkState(credentials instanceof ServiceAccountCredentials, - "Must initialize FirebaseApp with a service account credential to call " - + "createCustomToken()"); - - final ServiceAccountCredentials serviceAccount = (ServiceAccountCredentials) credentials; - return call(new Callable() { - @Override - public String call() throws Exception { - FirebaseTokenFactory tokenFactory = FirebaseTokenFactory.getInstance(); - return tokenFactory.createSignedCustomAuthTokenForUser( - uid, - developerClaims, - serviceAccount.getClientEmail(), - serviceAccount.getPrivateKey()); - } - }); - } - - /** - * Creates a Firebase Custom Token associated with the given UID. This token can then be provided - * back to a client application for use with the - * signInWithCustomToken - * authentication API. - * - * @param uid The UID to store in the token. This identifies the user to other Firebase services - * (Firebase Realtime Database, Firebase Auth, etc.) - * @return An {@code ApiFuture} which will complete successfully with the created Firebase Custom - * Token, or unsuccessfully with the failure Exception. - */ - public ApiFuture createCustomTokenAsync(String uid) { - return new TaskToApiFuture<>(createCustomToken(uid)); - } - - /** - * Creates a Firebase Custom Token associated with the given UID and additionally containing the - * specified developerClaims. This token can then be provided back to a client application for use - * with the signInWithCustomToken authentication API. - * - * @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. - */ - public ApiFuture createCustomTokenAsync( - final String uid, final Map developerClaims) { - return new TaskToApiFuture<>(createCustomToken(uid, developerClaims)); - } - - /** - * Similar to {@link #verifyIdTokenAsync(String)}, but returns a {@link Task}. - * - * @param token A Firebase ID Token to verify and parse. - * @return A {@link Task} which will complete successfully with the parsed token, or - * unsuccessfully with the failure Exception. - * @deprecated Use {@link #verifyIdTokenAsync(String)} - */ - public Task verifyIdToken(final String token) { - return verifyIdToken(token, false); - } - - private Task verifyIdToken(final String token, final boolean checkRevoked) { - checkNotDestroyed(); - checkState(!Strings.isNullOrEmpty(projectId), - "Must initialize FirebaseApp with a project ID to call verifyIdToken()"); - return call(new Callable() { - @Override - public FirebaseToken call() throws Exception { - - FirebaseTokenVerifier firebaseTokenVerifier = - new FirebaseTokenVerifier.Builder() - .setProjectId(projectId) - .setPublicKeysManager(googlePublicKeysManager) - .setClock(clock) - .build(); - - FirebaseToken firebaseToken = FirebaseToken.parse(jsonFactory, token); - - // This will throw a FirebaseAuthException with details on how the token is invalid. - firebaseTokenVerifier.verifyTokenAndSignature(firebaseToken.getToken()); - - if (checkRevoked) { - String uid = firebaseToken.getUid(); - UserRecord user = userManager.getUserById(uid); - long issuedAt = (long) firebaseToken.getClaims().get("iat"); - if (user.getTokensValidAfterTimestamp() > issuedAt * 1000) { - throw new FirebaseAuthException(FirebaseUserManager.ID_TOKEN_REVOKED_ERROR, - "Firebase auth token revoked"); - } - } - return firebaseToken; - } - }); - } - - private Task revokeRefreshTokens(String uid) { - checkNotDestroyed(); - int currentTimeSeconds = (int) (System.currentTimeMillis() / 1000); - final UpdateRequest request = new UpdateRequest(uid).setValidSince(currentTimeSeconds); - return call(new Callable() { - @Override - public Void call() throws Exception { - userManager.updateUser(request, jsonFactory); - return null; - } - }); - } - - /** - * 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. - * @return An {@code ApiFuture} which will complete successfully or if updating the user fails, - * unsuccessfully with the failure Exception. - */ - public ApiFuture revokeRefreshTokensAsync(String uid) { - return new TaskToApiFuture<>(revokeRefreshTokens(uid)); - } - - /** - * 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 getToken API in the Firebase Authentication client) with - * its request. - * - *

The backend server can then use the verifyIdToken() method to verify the token is valid, - * meaning: the token is properly signed, has not expired, and it was issued for the project - * associated with this FirebaseAuth instance (which by default is extracted from your service - * account) - * - *

If the token is valid, the returned Future will complete successfully and provide a - * parsed version of the token from which the UID and other claims in the token can be inspected. - * If the token is invalid, the future throws an exception indicating the failure. - * - *

This does not check whether a token has been revoked. - * See {@link #verifyIdTokenAsync(String, boolean)} below. - * - * @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 the failure Exception. - */ - public ApiFuture verifyIdTokenAsync(final String token) { - return verifyIdTokenAsync(token, false); - } - - /** - * Parses and verifies a Firebase ID Token and if requested, checks whether it was revoked. - * - *

A Firebase application can identify itself to a trusted backend server by sending its - * Firebase ID Token (accessible via the getToken API in the Firebase Authentication client) with - * its request. - * - *

The backend server can then use the verifyIdToken() method to verify the token is valid, - * meaning: the token is properly signed, has not expired, and it was issued for the project - * associated with this FirebaseAuth instance (which by default is extracted from your service - * account) - * - *

If {@code checkRevoked} is true, additionally checks if the token has been revoked. - * - *

If the token is valid, and not revoked, the returned Future will complete successfully and - * provide a parsed version of the token from which the UID and other claims in the token can be - * inspected. - * If the token is invalid or has been revoked, the future throws an exception indicating the - * failure. - * - * @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 the failure Exception. - */ - public ApiFuture verifyIdTokenAsync(final String token, - final boolean checkRevoked) { - return new TaskToApiFuture<>(verifyIdToken(token, checkRevoked)); - } - - /** - * Similar to {@link #getUserAsync(String)}, but returns a {@link Task}. - * - * @param uid A user ID string. - * @return A {@link Task} 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 task fails with a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the user ID string is null or empty. - * @deprecated Use {@link #getUserAsync(String)} - */ - public Task getUser(final String uid) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); - return call(new Callable() { - @Override - public UserRecord call() throws Exception { - return userManager.getUserById(uid); - } - }); - } - - /** - * Gets the user data corresponding to the specified user ID. - * - * @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(final String uid) { - return new TaskToApiFuture<>(getUser(uid)); - } - - /** - * Similar to {@link #getUserByEmailAsync(String)}, but returns a {@link Task}. - * - * @param email A user email address string. - * @return A {@link Task} 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 task fails with a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the email is null or empty. - * @deprecated Use {@link #getUserByEmailAsync(String)} - */ - public Task getUserByEmail(final String email) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(email), "email must not be null or empty"); - return call(new Callable() { - @Override - public UserRecord call() throws Exception { - return userManager.getUserByEmail(email); - } - }); - } - - /** - * Gets the user data corresponding to the specified user email. - * - * @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(final String email) { - return new TaskToApiFuture<>(getUserByEmail(email)); - } - - /** - * Similar to {@link #getUserByPhoneNumberAsync(String)}, but returns a {@link Task}. - * - * @param phoneNumber A user phone number string. - * @return A {@link Task} 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 task fails with a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the phone number is null or empty. - * @deprecated Use {@link #getUserByPhoneNumberAsync(String)} - */ - public Task getUserByPhoneNumber(final String phoneNumber) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(phoneNumber), "phone number must not be null or empty"); - return call(new Callable() { - @Override - public UserRecord call() throws Exception { - return userManager.getUserByPhoneNumber(phoneNumber); - } - }); - } - - /** - * 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(final String phoneNumber) { - return new TaskToApiFuture<>(getUserByPhoneNumber(phoneNumber)); - } - - private Task listUsers(@Nullable String pageToken, int maxResults) { - checkNotDestroyed(); - final PageFactory factory = new PageFactory( - new DefaultUserSource(userManager, jsonFactory), maxResults, pageToken); - return call(new Callable() { - @Override - public ListUsersPage call() throws Exception { - return factory.create(); - } - }); - } - - /** - * 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 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); - } - - /** - * 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 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 new TaskToApiFuture<>(listUsers(pageToken, maxResults)); - } - - /** - * Similar to {@link #createUserAsync(CreateRequest)}, but returns a {@link Task}. - * - * @param request A non-null {@link CreateRequest} instance. - * @return A {@link Task} 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 task fails with a {@link FirebaseAuthException}. - * @throws NullPointerException if the provided request is null. - * @deprecated Use {@link #createUserAsync(CreateRequest)} - */ - public Task createUser(final CreateRequest request) { - checkNotDestroyed(); - checkNotNull(request, "create request must not be null"); - return call(new Callable() { - @Override - public UserRecord call() throws Exception { - String uid = userManager.createUser(request); - return userManager.getUserById(uid); - } - }); - } - - /** - * Creates a new user account with the attributes contained in the specified - * {@link CreateRequest}. - * - * @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(final CreateRequest request) { - return new TaskToApiFuture<>(createUser(request)); + private static FirebaseAuth fromApp(final FirebaseApp app) { + return populateBuilderFromApp(builder(), app, null) + .setTenantManager(new Supplier() { + @Override + public TenantManager get() { + return new TenantManager(app); + } + }) + .build(); } - /** - * Similar to {@link #updateUserAsync(UpdateRequest)}, but returns a {@link Task}. - * - * @param request A non-null {@link UpdateRequest} instance. - * @return A {@link Task} 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 task fails with a {@link FirebaseAuthException}. - * @throws NullPointerException if the provided update request is null. - * @deprecated Use {@link #updateUserAsync(UpdateRequest)} - */ - public Task updateUser(final UpdateRequest request) { - checkNotDestroyed(); - checkNotNull(request, "update request must not be null"); - return call(new Callable() { - @Override - public UserRecord call() throws Exception { - userManager.updateUser(request, jsonFactory); - return userManager.getUserById(request.getUid()); - } - }); - } - - /** - * Updates an existing user account with the attributes contained in the specified - * {@link UpdateRequest}. - * - * @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}. - * @throws NullPointerException if the provided update request is null. - */ - public ApiFuture updateUserAsync(final UpdateRequest request) { - return new TaskToApiFuture<>(updateUser(request)); - } - - private Task setCustomClaims(String uid, Map claims) { - checkNotDestroyed(); - final UpdateRequest request = new UpdateRequest(uid).setCustomClaims(claims); - return call(new Callable() { - @Override - public Void call() throws Exception { - userManager.updateUser(request, jsonFactory); - return null; - } - }); - } - - /** - * 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. - * @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, or the claims - * payload is invalid or too large. - */ - public ApiFuture setCustomUserClaimsAsync(String uid, Map claims) { - return new TaskToApiFuture<>(setCustomClaims(uid, claims)); - } + private static class FirebaseAuthService extends FirebaseService { - /** - * Similar to {@link #deleteUserAsync(String)}, but returns a {@link Task}. - * - * @param uid A user ID string. - * @return A {@link Task} which will complete successfully when the specified user account has - * been deleted. If an error occurs while deleting the user account, the task fails with a - * {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the user ID string is null or empty. - * @deprecated Use {@link #deleteUserAsync(String)} - */ - public Task deleteUser(final String uid) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); - return call(new Callable() { - @Override - public Void call() throws Exception { - userManager.deleteUser(uid); - return null; - } - }); + FirebaseAuthService(FirebaseApp app) { + super(SERVICE_ID, FirebaseAuth.fromApp(app)); + } } - /** - * Deletes the user identified by the specified user ID. - * - * @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 new TaskToApiFuture<>(deleteUser(uid)); + static Builder builder() { + return new Builder(); } - private Task call(Callable command) { - return ImplFirebaseTrampolines.submitCallable(firebaseApp, command); - } + static class Builder extends AbstractFirebaseAuth.Builder { - @VisibleForTesting - FirebaseUserManager getUserManager() { - return this.userManager; - } + private Supplier tenantManager; - private void checkNotDestroyed() { - synchronized (lock) { - checkState(!destroyed.get(), "FirebaseAuth instance is no longer alive. This happens when " - + "the parent FirebaseApp instance has been deleted."); - } - } + private Builder() { } - private void destroy() { - synchronized (lock) { - destroyed.set(true); + @Override + protected Builder getThis() { + 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)); + public Builder setTenantManager(Supplier tenantManager) { + this.tenantManager = tenantManager; + return this; } - @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..53c980668 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseAuthException.java +++ b/src/main/java/com/google/firebase/auth/FirebaseAuthException.java @@ -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/FirebaseCredential.java b/src/main/java/com/google/firebase/auth/FirebaseCredential.java deleted file mode 100644 index b238d299c..000000000 --- a/src/main/java/com/google/firebase/auth/FirebaseCredential.java +++ /dev/null @@ -1,39 +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; - -import com.google.firebase.tasks.Task; - -/** - * Provides Google OAuth2 access tokens used to authenticate with Firebase services. In most cases, - * you will not need to implement this yourself and can instead use the default implementations - * provided by {@link FirebaseCredentials}. - * - * @deprecated Use {@code GoogleCredentials}. - */ -public interface FirebaseCredential { - - /** - * Returns a Google OAuth2 access token which can be used to authenticate with Firebase services. - * This method does not cache tokens, and therefore each invocation will fetch a fresh token. - * The caller is expected to implement caching by referencing the token expiry details - * available in the returned GoogleOAuthAccessToken instance. - * - * @return A {@link Task} providing a Google OAuth access token. - */ - Task getAccessToken(); -} diff --git a/src/main/java/com/google/firebase/auth/FirebaseCredentials.java b/src/main/java/com/google/firebase/auth/FirebaseCredentials.java deleted file mode 100644 index 4ae1d62da..000000000 --- a/src/main/java/com/google/firebase/auth/FirebaseCredentials.java +++ /dev/null @@ -1,223 +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; - -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.auth.http.HttpTransportFactory; -import com.google.auth.oauth2.GoogleCredentials; -import com.google.auth.oauth2.ServiceAccountCredentials; -import com.google.auth.oauth2.UserCredentials; -import com.google.common.base.Strings; -import com.google.firebase.auth.internal.BaseCredential; -import com.google.firebase.internal.NonNull; - -import java.io.IOException; -import java.io.InputStream; - -/** - * Standard {@link FirebaseCredential} implementations for use with {@link - * com.google.firebase.FirebaseOptions}. - * - * @deprecated Use {@code GoogleCredentials}. - */ -public class FirebaseCredentials { - - private FirebaseCredentials() { - } - - /** - * Returns a {@link FirebaseCredential} based on Google Application Default Credentials which can - * be used to authenticate the SDK. - * - *

See Google - * Application Default Credentials for details on Google Application Deafult Credentials. - * - *

See Initialize the SDK for code samples - * and detailed documentation. - * - * @return A {@link FirebaseCredential} based on Google Application Default Credentials which can - * be used to authenticate the SDK. - */ - @NonNull - public static FirebaseCredential applicationDefault() { - return DefaultCredentialsHolder.INSTANCE; - } - - /** - * Returns a {@link FirebaseCredential} based on Google Application Default Credentials which can - * be used to authenticate the SDK. Allows specifying the HttpTransport and the - * JsonFactory to be used when communicating with the remote authentication server. - * - *

See Google - * Application Default Credentials for details on Google Application Deafult Credentials. - * - *

See Initialize the SDK for code samples - * and detailed documentation. - * - * @param transport HttpTransport used to communicate with the remote - * authentication server. - * @param jsonFactory JsonFactory used to parse JSON responses from the remote - * authentication server. - * @return A {@link FirebaseCredential} based on Google Application Default Credentials which can - * be used to authenticate the SDK. - */ - @NonNull - public static FirebaseCredential applicationDefault( - HttpTransport transport, JsonFactory jsonFactory) { - try { - return new ApplicationDefaultCredential(transport); - } catch (IOException e) { - // To prevent a breaking API change, we throw an unchecked exception. - throw new RuntimeException(e); - } - } - - /** - * Returns a {@link FirebaseCredential} generated from the provided service account certificate - * which can be used to authenticate the SDK. - * - *

See Initialize the SDK for code samples - * and detailed documentation. - * - * @param serviceAccount An InputStream containing the JSON representation of a - * service account certificate. - * @return A {@link FirebaseCredential} generated from the provided service account certificate - * which can be used to authenticate the SDK. - * @throws IOException If an error occurs while parsing the service account certificate. - */ - @NonNull - public static FirebaseCredential fromCertificate(InputStream serviceAccount) throws IOException { - return fromCertificate(serviceAccount, Utils.getDefaultTransport(), - Utils.getDefaultJsonFactory()); - } - - /** - * Returns a {@link FirebaseCredential} generated from the provided service account certificate - * which can be used to authenticate the SDK. Allows specifying the HttpTransport - * and the JsonFactory to be used when communicating with the remote authentication - * server. - * - *

See Initialize the SDK for code samples - * and detailed documentation. - * - * @param serviceAccount An InputStream containing the JSON representation of a - * service account certificate. - * @param transport HttpTransport used to communicate with the remote - * authentication server. - * @param jsonFactory JsonFactory used to parse JSON responses from the remote - * authentication server. - * @return A {@link FirebaseCredential} generated from the provided service account certificate - * which can be used to authenticate the SDK. - * @throws IOException If an error occurs while parsing the service account certificate. - */ - @NonNull - public static FirebaseCredential fromCertificate(InputStream serviceAccount, - HttpTransport transport, JsonFactory jsonFactory) throws IOException { - ServiceAccountCredentials credentials = ServiceAccountCredentials.fromStream( - serviceAccount, wrap(transport)); - checkArgument(!Strings.isNullOrEmpty(credentials.getProjectId()), - "Failed to parse service account: 'project_id' must be set"); - return new CertCredential(credentials); - } - - /** - * Returns a {@link FirebaseCredential} generated from the provided refresh token which can be - * used to authenticate the SDK. - * - *

See Initialize the SDK for code samples - * and detailed documentation. - * - * @param refreshToken An InputStream containing the JSON representation of a refresh - * token. - * @return A {@link FirebaseCredential} generated from the provided service account credential - * which can be used to authenticate the SDK. - * @throws IOException If an error occurs while parsing the refresh token. - */ - @NonNull - public static FirebaseCredential fromRefreshToken(InputStream refreshToken) throws IOException { - return fromRefreshToken( - refreshToken, Utils.getDefaultTransport(), Utils.getDefaultJsonFactory()); - } - - /** - * Returns a {@link FirebaseCredential} generated from the provided refresh token which can be - * used to authenticate the SDK. Allows specifying the HttpTransport and the - * JsonFactory to be used when communicating with the remote authentication server. - * - *

See Initialize the SDK for code samples - * and detailed documentation. - * - * @param refreshToken An InputStream containing the JSON representation of a refresh - * token. - * @param transport HttpTransport used to communicate with the remote - * authentication server. - * @param jsonFactory JsonFactory used to parse JSON responses from the remote - * authentication server. - * @return A {@link FirebaseCredential} generated from the provided service account credential - * which can be used to authenticate the SDK. - * @throws IOException If an error occurs while parsing the refresh token. - */ - @NonNull - public static FirebaseCredential fromRefreshToken(final InputStream refreshToken, - HttpTransport transport, JsonFactory jsonFactory) throws IOException { - return new RefreshTokenCredential(refreshToken, transport); - } - - static class CertCredential extends BaseCredential { - - CertCredential(ServiceAccountCredentials credentials) throws IOException { - super(credentials); - } - } - - static class ApplicationDefaultCredential extends BaseCredential { - - ApplicationDefaultCredential(HttpTransport transport) throws IOException { - super(GoogleCredentials.getApplicationDefault(wrap(transport))); - } - } - - static class RefreshTokenCredential extends BaseCredential { - - RefreshTokenCredential(InputStream inputStream, HttpTransport transport) throws IOException { - super(UserCredentials.fromStream(inputStream, wrap(transport))); - } - } - - private static class DefaultCredentialsHolder { - - static final FirebaseCredential INSTANCE = - applicationDefault(Utils.getDefaultTransport(), Utils.getDefaultJsonFactory()); - } - - private static HttpTransportFactory wrap(final HttpTransport transport) { - checkNotNull(transport, "HttpTransport must not be null"); - return new HttpTransportFactory() { - @Override - public HttpTransport create() { - return transport; - } - }; - } -} diff --git a/src/main/java/com/google/firebase/auth/FirebaseToken.java b/src/main/java/com/google/firebase/auth/FirebaseToken.java index 1726250a4..6950bf16f 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseToken.java +++ b/src/main/java/com/google/firebase/auth/FirebaseToken.java @@ -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..873dbe7ac --- /dev/null +++ b/src/main/java/com/google/firebase/auth/FirebaseTokenUtils.java @@ -0,0 +1,146 @@ +/* + * Copyright 2019 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; + +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..e73f82381 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/FirebaseTokenVerifier.java @@ -0,0 +1,34 @@ +/* + * Copyright 2019 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; + +/** + * 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..9a3e1ae15 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/FirebaseTokenVerifierImpl.java @@ -0,0 +1,408 @@ +/* + * Copyright 2019 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; + +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 504476400..195527841 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java +++ b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java @@ -19,33 +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.auth.http.HttpCredentialsAdapter; -import com.google.auth.oauth2.GoogleCredentials; +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.auth.UserRecord.CreateRequest; -import com.google.firebase.auth.UserRecord.UpdateRequest; +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseApp; +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.internal.SdkUtils; -import java.io.IOException; +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.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.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 @@ -54,127 +59,133 @@ * @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"; - - // 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; static final List RESERVED_CLAIMS = ImmutableList.of( "amr", "at_hash", "aud", "auth_time", "azp", "cnf", "c_hash", "exp", "iat", "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 jsonFactory JsonFactory instance used to transform Java objects into JSON and back. - * @param transport HttpTransport used to make REST API calls. - */ - FirebaseUserManager(JsonFactory jsonFactory, HttpTransport transport, - GoogleCredentials credentials) { - this.jsonFactory = checkNotNull(jsonFactory, "jsonFactory must not be null"); - this.requestFactory = transport.createRequestFactory(new HttpCredentialsAdapter(credentials)); + 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 { @@ -185,60 +196,245 @@ 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."); + 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( + "/accounts:batchCreate", request, UploadAccountResponse.class); + return new UserImportResult(request.getUsersCount(), response); + } + + 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); + 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()); + } + + GenericJson response = post("/accounts:sendOobCode", payload.build(), GenericJson.class); + return (String) response.get("oobLink"); + } + + 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); + } + + 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); } - return response; + + 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"); - 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 + 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 { + + @Key("users") + private final List> users; + + UserImportRequest(List users, UserImportOptions options, + JsonFactory jsonFactory) { + checkArgument(users != null && !users.isEmpty(), "users must not be null or empty"); + checkArgument(users.size() <= FirebaseUserManager.MAX_IMPORT_USERS, + "users list must not contain more than %s items", FirebaseUserManager.MAX_IMPORT_USERS); + + boolean hasPassword = false; + ImmutableList.Builder> usersBuilder = ImmutableList.builder(); + for (ImportUserRecord user : users) { + if (user.hasPassword()) { + hasPassword = true; } + usersBuilder.add(user.getProperties(jsonFactory)); + } + this.users = usersBuilder.build(); + + if (hasPassword) { + checkArgument(options != null && options.getHash() != null, + "UserImportHash option is required when at least one user has a password. Provide " + + "a UserImportHash via UserImportOptions.withHash()."); + this.putAll(options.getProperties()); } } + + int getUsersCount() { + return users.size(); + } } - 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 + 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); } - String msg = String.format( - "Unexpected HTTP response with status: %d; body: %s", e.getStatusCode(), e.getContent()); - throw new FirebaseAuthException(INTERNAL_ERROR, msg, e); } } 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..3ceec01cb --- /dev/null +++ b/src/main/java/com/google/firebase/auth/GetUsersResult.java @@ -0,0 +1,52 @@ +/* + * Copyright 2020 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; + +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/GoogleOAuthAccessToken.java b/src/main/java/com/google/firebase/auth/GoogleOAuthAccessToken.java deleted file mode 100644 index 85e6021e3..000000000 --- a/src/main/java/com/google/firebase/auth/GoogleOAuthAccessToken.java +++ /dev/null @@ -1,62 +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; - -import static com.google.common.base.Preconditions.checkArgument; - -import com.google.common.base.Strings; - -/** - * Represents an OAuth access token, which can be used to access Firebase and other qualified - * Google APIs. Encapsulates both the token string, and its expiration time. - * - * @deprecated Use GoogleCredentials and associated classes. - */ -public class GoogleOAuthAccessToken { - - - private final String accessToken; - private final long expiryTime; - - /** - * Create a new GoogleOAuthAccessToken instance - * - * @param accessToken JWT access token string - * @param expiryTime Time at which the token will expire (milliseconds since epoch) - * @throws IllegalArgumentException If the token is null or empty - */ - public GoogleOAuthAccessToken(String accessToken, long expiryTime) { - checkArgument(!Strings.isNullOrEmpty(accessToken), "Access token must not be null"); - this.accessToken = accessToken; - this.expiryTime = expiryTime; - } - - /** - * Returns the JWT access token. - */ - public String getAccessToken() { - return accessToken; - } - - /** - * Returns the expiration time as a milliseconds since epoch timestamp. - */ - public long getExpiryTime() { - return expiryTime; - } - -} diff --git a/src/main/java/com/google/firebase/auth/ImportUserRecord.java b/src/main/java/com/google/firebase/auth/ImportUserRecord.java new file mode 100644 index 000000000..5ce57fcc9 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/ImportUserRecord.java @@ -0,0 +1,303 @@ +/* + * 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; + +import com.google.api.client.json.JsonFactory; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.BaseEncoding; +import com.google.firebase.internal.NonNull; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Represents a user account to be imported to Firebase Auth via the + * {@link FirebaseAuth#importUsers(List, UserImportOptions)} API. Must contain at least a + * uid string. + */ +public final class ImportUserRecord { + + private final Map properties; + + private ImportUserRecord(Map properties) { + this.properties = ImmutableMap.copyOf(properties); + } + + Map getProperties(JsonFactory jsonFactory) { + Map copy = new HashMap<>(properties); + // serialize custom claims + if (copy.containsKey(UserRecord.CUSTOM_ATTRIBUTES)) { + Map customClaims = (Map) copy.remove(UserRecord.CUSTOM_ATTRIBUTES); + copy.put(UserRecord.CUSTOM_ATTRIBUTES, UserRecord.serializeCustomClaims( + customClaims, jsonFactory)); + } + return ImmutableMap.copyOf(copy); + } + + boolean hasPassword() { + return this.properties.containsKey("passwordHash"); + } + + /** + * Creates a new {@link ImportUserRecord.Builder}. + * + * @return A {@link ImportUserRecord.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String uid; + private String email; + private Boolean emailVerified; + private String displayName; + private String phoneNumber; + private String photoUrl; + private Boolean disabled; + private UserMetadata userMetadata; + private byte[] passwordHash; + private byte[] passwordSalt; + + private final List userProviders = new ArrayList<>(); + private final Map customClaims = new HashMap<>(); + + private Builder() {} + + /** + * Sets a user ID for the user. + * + * @param uid a non-null, non-empty user ID that uniquely identifies the user. The user ID + * must not be longer than 128 characters. + * @return This builder. + */ + public Builder setUid(String uid) { + this.uid = uid; + return this; + } + + /** + * Sets an email address for the user. + * + * @param email a non-null, non-empty email address string. + * @return This builder. + */ + public Builder setEmail(String email) { + this.email = email; + return this; + } + + /** + * Sets whether the user email address has been verified or not. + * + * @param emailVerified a boolean indicating the email verification status. + * @return This builder. + */ + public Builder setEmailVerified(boolean emailVerified) { + this.emailVerified = emailVerified; + return this; + } + + /** + * Sets the display name for the user. + * + * @param displayName a non-null, non-empty display name string. + * @return This builder. + */ + public Builder setDisplayName(String displayName) { + this.displayName = displayName; + return this; + } + + /** + * Sets the phone number associated with this user. + * + * @param phoneNumber a valid phone number string. + * @return This builder. + */ + public Builder setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + return this; + } + + /** + * Sets the photo URL for the user. + * + * @param photoUrl a non-null, non-empty URL string. + * @return This builder. + */ + public Builder setPhotoUrl(String photoUrl) { + this.photoUrl = photoUrl; + return this; + } + + /** + * Sets whether the user account should be disabled by default or not. + * + * @param disabled a boolean indicating whether the account should be disabled. + * @return This builder. + */ + public Builder setDisabled(boolean disabled) { + this.disabled = disabled; + return this; + } + + /** + * Sets additional metadata about the user. + * + * @param userMetadata A {@link UserMetadata} instance. + * @return This builder. + */ + public Builder setUserMetadata(UserMetadata userMetadata) { + this.userMetadata = userMetadata; + return this; + } + + /** + * Sets a byte array representing the user's hashed password. If at least one user account + * carries a password hash, a {@link UserImportHash} must be specified when calling the + * {@link FirebaseAuth#importUsersAsync(List, UserImportOptions)} method. See + * {@link UserImportOptions.Builder#setHash(UserImportHash)}. + * + * @param passwordHash A byte array. + * @return This builder. + */ + public Builder setPasswordHash(byte[] passwordHash) { + this.passwordHash = passwordHash; + return this; + } + + /** + * Sets a byte array representing the user's password salt. + * + * @param passwordSalt A byte array. + * @return This builder. + */ + public Builder setPasswordSalt(byte[] passwordSalt) { + this.passwordSalt = passwordSalt; + return this; + } + + /** + * Adds a user provider to be associated with this user. + * + *

A {@link UserProvider} represents the identity of the user as specified by an + * identity provider that is linked to this user account. The identity provider can specify + * its own values for common user attributes like email, display name and photo URL. + * + * @param provider A non-null {@link UserProvider}. + * @return This builder. + */ + public Builder addUserProvider(@NonNull UserProvider provider) { + this.userProviders.add(provider); + return this; + } + + /** + * Associates all user provider's in the given list with this user. + * + * @param providers A list of {@link UserProvider} instances. + * @return This builder. + */ + public Builder addAllUserProviders(List providers) { + this.userProviders.addAll(providers); + return this; + } + + /** + * Sets the specified custom claim on this user account. + * + * @param key Name of the claim. + * @param value Value of the claim. + * @return This builder. + */ + public Builder putCustomClaim(String key, Object value) { + this.customClaims.put(key, value); + return this; + } + + /** + * Sets the custom claims associated with this user. + * + * @param customClaims a Map of custom claims + */ + public Builder putAllCustomClaims(Map customClaims) { + this.customClaims.putAll(customClaims); + return this; + } + + /** + * Builds a new {@link ImportUserRecord}. + * + * @return A non-null {@link ImportUserRecord}. + */ + public ImportUserRecord build() { + Map properties = new HashMap<>(); + UserRecord.checkUid(uid); + properties.put("localId", uid); + + if (!Strings.isNullOrEmpty(email)) { + UserRecord.checkEmail(email); + properties.put("email", email); + } + if (!Strings.isNullOrEmpty(photoUrl)) { + UserRecord.checkUrl(photoUrl); + properties.put("photoUrl", photoUrl); + } + if (!Strings.isNullOrEmpty(phoneNumber)) { + UserRecord.checkPhoneNumber(phoneNumber); + properties.put("phoneNumber", phoneNumber); + } + if (!Strings.isNullOrEmpty(displayName)) { + properties.put("displayName", displayName); + } + if (userMetadata != null) { + if (userMetadata.getCreationTimestamp() > 0) { + properties.put("createdAt", userMetadata.getCreationTimestamp()); + } + if (userMetadata.getLastSignInTimestamp() > 0) { + properties.put("lastLoginAt", userMetadata.getLastSignInTimestamp()); + } + } + if (passwordHash != null) { + properties.put("passwordHash", BaseEncoding.base64Url().encode(passwordHash)); + } + if (passwordSalt != null) { + properties.put("salt", BaseEncoding.base64Url().encode(passwordSalt)); + } + if (userProviders.size() > 0) { + properties.put("providerUserInfo", ImmutableList.copyOf(userProviders)); + } + if (customClaims.size() > 0) { + ImmutableMap mergedClaims = ImmutableMap.copyOf(customClaims); + UserRecord.checkCustomClaims(mergedClaims); + properties.put(UserRecord.CUSTOM_ATTRIBUTES, mergedClaims); + } + if (emailVerified != null) { + properties.put("emailVerified", emailVerified); + } + if (disabled != null) { + properties.put("disabled", disabled); + } + return new ImportUserRecord(properties); + } + } +} 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..ba727af5a 100644 --- a/src/main/java/com/google/firebase/auth/ListUsersPage.java +++ b/src/main/java/com/google/firebase/auth/ListUsersPage.java @@ -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..bdc84fe92 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/PhoneIdentifier.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020 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; + +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..25e00026d --- /dev/null +++ b/src/main/java/com/google/firebase/auth/ProviderIdentifier.java @@ -0,0 +1,56 @@ +/* + * Copyright 2020 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; + +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/RevocationCheckDecorator.java b/src/main/java/com/google/firebase/auth/RevocationCheckDecorator.java new file mode 100644 index 000000000..9a99fd5df --- /dev/null +++ b/src/main/java/com/google/firebase/auth/RevocationCheckDecorator.java @@ -0,0 +1,89 @@ +/* + * Copyright 2019 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; + +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 new file mode 100644 index 000000000..d630780e8 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/SessionCookieOptions.java @@ -0,0 +1,76 @@ +/* + * 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; + +import static com.google.common.base.Preconditions.checkArgument; + +import java.util.concurrent.TimeUnit; + +/** + * A set of additional options that can be passed to + * {@link FirebaseAuth#createSessionCookieAsync(String, SessionCookieOptions)}. + */ +public class SessionCookieOptions { + + private final long expiresIn; + + private SessionCookieOptions(Builder builder) { + checkArgument(builder.expiresIn > TimeUnit.MINUTES.toMillis(5), + "expiresIn duration must be at least 5 minutes"); + checkArgument(builder.expiresIn < TimeUnit.DAYS.toMillis(14), + "expiresIn duration must be at most 14 days"); + this.expiresIn = builder.expiresIn; + } + + long getExpiresInSeconds() { + return TimeUnit.MILLISECONDS.toSeconds(expiresIn); + } + + /** + * Creates a new {@link Builder}. + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private long expiresIn; + + private Builder() {} + + /** + * Sets the duration until the cookie is expired in milliseconds. Must be between 5 minutes + * and 14 days. + * + * @param expiresInMillis Time duration in milliseconds. + * @return This builder. + */ + public Builder setExpiresIn(long expiresInMillis) { + this.expiresIn = expiresInMillis; + return this; + } + + /** + * Creates a new {@link SessionCookieOptions} instance. + */ + public SessionCookieOptions build() { + return new SessionCookieOptions(this); + } + } + +} 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..a4f7069d9 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/UidIdentifier.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020 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; + +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/tasks/OnCompleteListener.java b/src/main/java/com/google/firebase/auth/UserIdentifier.java similarity index 61% rename from src/main/java/com/google/firebase/tasks/OnCompleteListener.java rename to src/main/java/com/google/firebase/auth/UserIdentifier.java index b92fb8eb0..7ec9699e6 100644 --- a/src/main/java/com/google/firebase/tasks/OnCompleteListener.java +++ b/src/main/java/com/google/firebase/auth/UserIdentifier.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2020 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,22 +14,18 @@ * limitations under the License. */ -package com.google.firebase.tasks; +package com.google.firebase.auth; +import com.google.firebase.auth.internal.GetAccountInfoRequest; import com.google.firebase.internal.NonNull; /** - * Listener called when a {@link Task} completes. - * - * @param the Task's result type - * @see Task#addOnCompleteListener(OnCompleteListener) + * Identifies a user to be looked up. */ -public interface OnCompleteListener { +public abstract class UserIdentifier { + public abstract String toString(); + + abstract void populate(@NonNull GetAccountInfoRequest payload); - /** - * Called when the Task completes. - * - * @param task the completed Task. Never null - */ - void onComplete(@NonNull Task task); + 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 new file mode 100644 index 000000000..443c6f858 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/UserImportHash.java @@ -0,0 +1,52 @@ +/* + * 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; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import java.util.Map; + +/** + * Represents a hash algorithm and the related configuration parameters used to hash user + * passwords. An instance of this class must be specified if importing any users with password + * hashes (see {@link UserImportOptions.Builder#setHash(UserImportHash)}. + * + *

This is not expected to be extended in user code. Applications should use one of the provided + * concrete implementations in the {@link com.google.firebase.auth.hash} package. See + * documentation for more + * details on available options. + */ +public abstract class UserImportHash { + + private final String name; + + protected UserImportHash(String name) { + checkArgument(!Strings.isNullOrEmpty(name)); + this.name = name; + } + + final Map getProperties() { + return ImmutableMap.builder() + .put("hashAlgorithm", name) + .putAll(getOptions()) + .build(); + } + + protected abstract Map getOptions(); +} diff --git a/src/main/java/com/google/firebase/auth/UserImportOptions.java b/src/main/java/com/google/firebase/auth/UserImportOptions.java new file mode 100644 index 000000000..86accf91b --- /dev/null +++ b/src/main/java/com/google/firebase/auth/UserImportOptions.java @@ -0,0 +1,95 @@ +/* + * Copyright 2018 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; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.collect.ImmutableMap; +import com.google.firebase.internal.NonNull; +import java.util.List; +import java.util.Map; + +/** + * A collection of options that can be passed to the + * {@link FirebaseAuth#importUsersAsync(List, UserImportOptions)} API. + */ +public final class UserImportOptions { + + private final UserImportHash hash; + + private UserImportOptions(Builder builder) { + this.hash = checkNotNull(builder.hash); + } + + /** + * Creates a new {@link UserImportOptions} containing the provided hash algorithm. + * + * @param hash A non-null {@link UserImportHash}. + * @return A new {@link UserImportOptions}. + */ + public static UserImportOptions withHash(@NonNull UserImportHash hash) { + return builder().setHash(checkNotNull(hash)).build(); + } + + /** + * Creates a new {@link UserImportOptions.Builder}. + * + * @return A {@link UserImportOptions.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + Map getProperties() { + return ImmutableMap.builder() + .putAll(hash.getProperties()) + .build(); + } + + UserImportHash getHash() { + return hash; + } + + public static class Builder { + + private UserImportHash hash; + + private Builder() {} + + /** + * Sets the algorithm used to hash user passwords. This is required + * when at least one of the {@link ImportUserRecord} instances being imported has a password + * hash. See {@link ImportUserRecord.Builder#setPasswordHash(byte[])}. + * + * @param hash A {@link UserImportHash}. + * @return This builder. + */ + public Builder setHash(@NonNull UserImportHash hash) { + this.hash = hash; + return this; + } + + /** + * Builds a new {@link UserImportOptions}. + * + * @return A non-null {@link UserImportOptions}. + */ + public UserImportOptions build() { + return new UserImportOptions(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/UserImportResult.java b/src/main/java/com/google/firebase/auth/UserImportResult.java new file mode 100644 index 000000000..143b0618b --- /dev/null +++ b/src/main/java/com/google/firebase/auth/UserImportResult.java @@ -0,0 +1,74 @@ +/* + * Copyright 2018 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; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.collect.ImmutableList; +import com.google.firebase.auth.internal.UploadAccountResponse; +import com.google.firebase.internal.NonNull; +import java.util.List; + +/** + * Represents the result of the {@link FirebaseAuth#importUsersAsync(List, UserImportOptions)} API. + */ +public final class UserImportResult { + + private final int users; + private final ImmutableList errors; + + UserImportResult(int users, UploadAccountResponse response) { + ImmutableList.Builder errorsBuilder = ImmutableList.builder(); + List errors = response.getErrors(); + if (errors != null) { + checkArgument(users >= errors.size()); + for (UploadAccountResponse.ErrorInfo error : errors) { + errorsBuilder.add(new ErrorInfo(error.getIndex(), error.getMessage())); + } + } + this.users = users; + this.errors = errorsBuilder.build(); + } + + /** + * Returns the number of users that were imported successfully. + * + * @return number of users successfully imported (possibly zero). + */ + public int getSuccessCount() { + return users - errors.size(); + } + + /** + * Returns the number of users that failed to be imported. + * + * @return number of users that resulted in import failures (possibly zero). + */ + public int getFailureCount() { + return errors.size(); + } + + /** + * A list of {@link ErrorInfo} instances describing the errors that were encountered during + * the import. 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/UserMetadata.java b/src/main/java/com/google/firebase/auth/UserMetadata.java index eb4da93c8..85a24a0fd 100644 --- a/src/main/java/com/google/firebase/auth/UserMetadata.java +++ b/src/main/java/com/google/firebase/auth/UserMetadata.java @@ -23,10 +23,16 @@ public class UserMetadata { private final long creationTimestamp; private final long lastSignInTimestamp; + private final long lastRefreshTimestamp; - UserMetadata(long creationTimestamp, long lastSignInTimestamp) { + public UserMetadata(long creationTimestamp) { + this(creationTimestamp, 0L, 0L); + } + + public UserMetadata(long creationTimestamp, long lastSignInTimestamp, long lastRefreshTimestamp) { this.creationTimestamp = creationTimestamp; this.lastSignInTimestamp = lastSignInTimestamp; + this.lastRefreshTimestamp = lastRefreshTimestamp; } /** @@ -46,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 new file mode 100644 index 000000000..a13e0ecc1 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/UserProvider.java @@ -0,0 +1,139 @@ +/* + * 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; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.client.util.Key; +import com.google.common.base.Strings; + +/** + * Represents a user identity provider that can be associated with a Firebase user. + */ +public final class UserProvider { + + @Key("rawId") + private final String uid; + + @Key("displayName") + private final String displayName; + + @Key("email") + private final String email; + + @Key("photoUrl") + private final String photoUrl; + + @Key("providerId") + private final String providerId; + + private UserProvider(Builder builder) { + checkArgument(!Strings.isNullOrEmpty(builder.uid), "Uid must not be null or empty"); + checkArgument(!Strings.isNullOrEmpty(builder.providerId), + "ProviderId must not be null or empty"); + this.uid = builder.uid; + this.displayName = builder.displayName; + this.email = builder.email; + this.photoUrl = builder.photoUrl; + this.providerId = builder.providerId; + } + + /** + * Creates a new {@link UserProvider.Builder}. + * + * @return A {@link UserProvider.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String uid; + private String displayName; + private String email; + private String photoUrl; + private String providerId; + + private Builder() {} + + /** + * Sets the user's unique ID assigned by the identity provider. This field is required. + * + * @param uid a user ID string. + * @return This builder. + */ + public Builder setUid(String uid) { + this.uid = uid; + return this; + } + + /** + * Sets the user's display name. + * + * @param displayName display name of the user. + * @return This builder. + */ + public Builder setDisplayName(String displayName) { + this.displayName = displayName; + return this; + } + + /** + * Sets the user's email address. + * + * @param email an email address string. + * @return This builder. + */ + public Builder setEmail(String email) { + this.email = email; + return this; + } + + /** + * Sets the photo URl of the user. + * + * @param photoUrl a photo URL string. + * @return This builder. + */ + public Builder setPhotoUrl(String photoUrl) { + this.photoUrl = photoUrl; + return this; + } + + /** + * Sets the ID of the identity provider. This can be a short domain name (e.g. google.com) or + * the identifier of an OpenID identity provider. This field is required. + * + * @param providerId an ID string that uniquely identifies the identity provider. + * @return This builder. + */ + public Builder setProviderId(String providerId) { + this.providerId = providerId; + return this; + } + + /** + * Builds a new {@link UserProvider}. + * + * @return A non-null {@link UserProvider}. + */ + public UserProvider build() { + return new UserProvider(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/UserRecord.java b/src/main/java/com/google/firebase/auth/UserRecord.java index cc27bd197..33c5fbe74 100644 --- a/src/main/java/com/google/firebase/auth/UserRecord.java +++ b/src/main/java/com/google/firebase/auth/UserRecord.java @@ -18,10 +18,9 @@ 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.common.annotations.VisibleForTesting; +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; @@ -33,7 +32,6 @@ import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; -import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -48,10 +46,11 @@ public class UserRecord implements UserInfo { private static final Map REMOVABLE_FIELDS = ImmutableMap.of( "displayName", "DISPLAY_NAME", "photoUrl", "PHOTO_URL"); - private static final String CUSTOM_ATTRIBUTES = "customAttributes"; + static final String CUSTOM_ATTRIBUTES = "customAttributes"; 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; @@ -68,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(); @@ -83,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); } @@ -110,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. * @@ -193,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. */ @@ -232,29 +250,49 @@ public UpdateRequest updateRequest() { return new UpdateRequest(uid); } - private static void checkEmail(String email) { + static void checkUid(String uid) { + checkArgument(!Strings.isNullOrEmpty(uid), "uid cannot be null or empty"); + checkArgument(uid.length() <= 128, "UID cannot be longer than 128 characters"); + } + + static void checkEmail(String email) { checkArgument(!Strings.isNullOrEmpty(email), "email cannot be null or empty"); checkArgument(email.matches("^[^@]+@[^@]+$")); } - private static void checkPhoneNumber(String phoneNumber) { + static void checkPhoneNumber(String phoneNumber) { // Phone number verification is very lax here. Backend will enforce E.164 spec compliance, and // normalize accordingly. checkArgument(!Strings.isNullOrEmpty(phoneNumber), "phone number cannot be null or empty"); - checkState(phoneNumber.startsWith("+"), + checkArgument(phoneNumber.startsWith("+"), "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 { + new URL(photoUrl); + } catch (MalformedURLException e) { + throw new IllegalArgumentException("malformed url string", e); + } + } + private static void checkPassword(String password) { checkArgument(!Strings.isNullOrEmpty(password), "password cannot be null or empty"); checkArgument(password.length() >= 6, "password must be at least 6 characters long"); } - private static void checkCustomClaims(Map customClaims) { + static void checkCustomClaims(Map customClaims) { if (customClaims == null) { return; } for (String key : customClaims.keySet()) { + checkArgument(!Strings.isNullOrEmpty(key), "Claim names must not be null or empty"); checkArgument(!FirebaseUserManager.RESERVED_CLAIMS.contains(key), "Claim '" + key + "' is reserved and cannot be set"); } @@ -265,7 +303,7 @@ private static void checkValidSince(long epochSeconds) { + Long.toString(epochSeconds)); } - private static String serializeCustomClaims(Map customClaims, JsonFactory jsonFactory) { + static String serializeCustomClaims(Map customClaims, JsonFactory jsonFactory) { checkNotNull(jsonFactory, "JsonFactory must not be null"); if (customClaims == null || customClaims.isEmpty()) { return "{}"; @@ -305,8 +343,7 @@ public CreateRequest() { * must not be longer than 128 characters. */ public CreateRequest setUid(String uid) { - checkArgument(!Strings.isNullOrEmpty(uid), "uid cannot be null or empty"); - checkArgument(uid.length() <= 128, "UID cannot be longer than 128 characters"); + checkUid(uid); properties.put("localId", uid); return this; } @@ -346,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; } @@ -360,12 +397,7 @@ public CreateRequest setDisplayName(String displayName) { * @param photoUrl a non-null, non-empty URL string. */ public CreateRequest setPhotoUrl(String photoUrl) { - checkArgument(!Strings.isNullOrEmpty(photoUrl), "photoUrl cannot be null or empty"); - try { - new URL(photoUrl); - } catch (MalformedURLException e) { - throw new IllegalArgumentException("malformed photoUrl string", e); - } + checkUrl(photoUrl); properties.put("photoUrl", photoUrl); return this; } @@ -443,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; } @@ -475,12 +530,9 @@ public UpdateRequest setDisplayName(@Nullable String displayName) { * @param photoUrl a valid URL string or null */ public UpdateRequest setPhotoUrl(@Nullable String photoUrl) { + // This is allowed to be null if (photoUrl != null) { - try { - new URL(photoUrl); - } catch (MalformedURLException e) { - throw new IllegalArgumentException("malformed photoUrl string", e); - } + checkUrl(photoUrl); } properties.put("photoUrl", photoUrl); return this; @@ -519,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); @@ -540,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..8edcf11b7 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/Argon2.java @@ -0,0 +1,217 @@ +/* + * Copyright 2022 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.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 new file mode 100644 index 000000000..9c55d8d56 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/Bcrypt.java @@ -0,0 +1,41 @@ +/* + * Copyright 2018 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.hash; + +import com.google.common.collect.ImmutableMap; +import com.google.firebase.auth.UserImportHash; +import java.util.Map; + +/** + * Represents the Bcrypt password hashing algorithm. Can be used as an instance of + * {@link com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class Bcrypt extends UserImportHash { + + private Bcrypt() { + super("BCRYPT"); + } + + public static Bcrypt getInstance() { + return new Bcrypt(); + } + + @Override + protected Map getOptions() { + return ImmutableMap.of(); + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/Hmac.java b/src/main/java/com/google/firebase/auth/hash/Hmac.java new file mode 100644 index 000000000..d55879389 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/Hmac.java @@ -0,0 +1,60 @@ +/* + * Copyright 2018 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.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; + +abstract class Hmac extends UserImportHash { + + private final String key; + + Hmac(String name, Builder builder) { + super(name); + checkArgument(builder.key != null && builder.key.length > 0, + "A non-empty key is required for HMAC algorithms"); + this.key = BaseEncoding.base64().encode(builder.key); + } + + @Override + protected final Map getOptions() { + return ImmutableMap.of("signerKey", key); + } + + abstract static class Builder { + private byte[] key; + + protected abstract T getInstance(); + + /** + * Sets the signer key for the HMAC hash algorithm. Required field. + * + * @param key Signer key as a byte array. + * @return This builder. + */ + public T setKey(byte[] key) { + this.key = key; + return getInstance(); + } + + public abstract U build(); + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/HmacMd5.java b/src/main/java/com/google/firebase/auth/hash/HmacMd5.java new file mode 100644 index 000000000..b2ffdb852 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/HmacMd5.java @@ -0,0 +1,46 @@ +/* + * Copyright 2018 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.hash; + +/** + * Represents the HMAC MD5 password hashing algorithm. Can be used as an instance of + * {@link com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class HmacMd5 extends Hmac { + + private HmacMd5(Builder builder) { + super("HMAC_MD5", builder); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends Hmac.Builder { + + private Builder() {} + + @Override + protected Builder getInstance() { + return this; + } + + public HmacMd5 build() { + return new HmacMd5(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/HmacSha1.java b/src/main/java/com/google/firebase/auth/hash/HmacSha1.java new file mode 100644 index 000000000..964e5e60d --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/HmacSha1.java @@ -0,0 +1,46 @@ +/* + * Copyright 2018 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.hash; + +/** + * Represents the HMAC SHA1 password hashing algorithm. Can be used as an instance of + * {@link com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class HmacSha1 extends Hmac { + + private HmacSha1(Builder builder) { + super("HMAC_SHA1", builder); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends Hmac.Builder { + + private Builder() {} + + @Override + protected Builder getInstance() { + return this; + } + + public HmacSha1 build() { + return new HmacSha1(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/HmacSha256.java b/src/main/java/com/google/firebase/auth/hash/HmacSha256.java new file mode 100644 index 000000000..92917e6f3 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/HmacSha256.java @@ -0,0 +1,46 @@ +/* + * Copyright 2018 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.hash; + +/** + * Represents the HMAC SHA256 password hashing algorithm. Can be used as an instance of + * {@link com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class HmacSha256 extends Hmac { + + private HmacSha256(Builder builder) { + super("HMAC_SHA256", builder); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends Hmac.Builder { + + private Builder() {} + + @Override + protected Builder getInstance() { + return this; + } + + public HmacSha256 build() { + return new HmacSha256(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/HmacSha512.java b/src/main/java/com/google/firebase/auth/hash/HmacSha512.java new file mode 100644 index 000000000..b5a0e09ec --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/HmacSha512.java @@ -0,0 +1,46 @@ +/* + * 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.hash; + +/** + * Represents the HMAC SHA512 password hashing algorithm. Can be used as an instance of + * {@link com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class HmacSha512 extends Hmac { + + private HmacSha512(Builder builder) { + super("HMAC_SHA512", builder); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends Hmac.Builder { + + private Builder() {} + + @Override + protected Builder getInstance() { + return this; + } + + public HmacSha512 build() { + return new HmacSha512(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/Md5.java b/src/main/java/com/google/firebase/auth/hash/Md5.java new file mode 100644 index 000000000..353b07f01 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/Md5.java @@ -0,0 +1,46 @@ +/* + * Copyright 2018 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.hash; + +/** + * Represents the MD5 password hashing algorithm. Can be used as an instance of + * {@link com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class Md5 extends RepeatableHash { + + private Md5(Builder builder) { + super("MD5", 0, 8192, builder); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends RepeatableHash.Builder { + + private Builder() {} + + @Override + protected Builder getInstance() { + return this; + } + + public Md5 build() { + return new Md5(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/Pbkdf2Sha256.java b/src/main/java/com/google/firebase/auth/hash/Pbkdf2Sha256.java new file mode 100644 index 000000000..6c3ffeff2 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/Pbkdf2Sha256.java @@ -0,0 +1,46 @@ +/* + * Copyright 2018 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.hash; + +/** + * Represents the PBKDF2 SHA256 password hashing algorithm. Can be used as an instance of + * {@link com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class Pbkdf2Sha256 extends RepeatableHash { + + private Pbkdf2Sha256(Builder builder) { + super("PBKDF2_SHA256", 0, 120000, builder); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends RepeatableHash.Builder { + + private Builder() {} + + @Override + protected Builder getInstance() { + return this; + } + + public Pbkdf2Sha256 build() { + return new Pbkdf2Sha256(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/PbkdfSha1.java b/src/main/java/com/google/firebase/auth/hash/PbkdfSha1.java new file mode 100644 index 000000000..647a365b3 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/PbkdfSha1.java @@ -0,0 +1,46 @@ +/* + * Copyright 2018 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.hash; + +/** + * Represents the PBKDF SHA1 password hashing algorithm. Can be used as an instance of + * {@link com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class PbkdfSha1 extends RepeatableHash { + + private PbkdfSha1(Builder builder) { + super("PBKDF_SHA1", 0, 120000, builder); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends RepeatableHash.Builder { + + private Builder() {} + + @Override + protected Builder getInstance() { + return this; + } + + public PbkdfSha1 build() { + return new PbkdfSha1(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/RepeatableHash.java b/src/main/java/com/google/firebase/auth/hash/RepeatableHash.java new file mode 100644 index 000000000..bcf1a9524 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/RepeatableHash.java @@ -0,0 +1,63 @@ +/* + * Copyright 2018 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.hash; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.collect.ImmutableMap; +import com.google.firebase.auth.UserImportHash; +import java.util.Map; + +/** + * An abstract {@link UserImportHash} implementation that accepts a {@code rounds} parameter in + * a given range. + */ +abstract class RepeatableHash extends UserImportHash { + + private final int rounds; + + RepeatableHash(String name, int min, int max, Builder builder) { + super(name); + checkArgument(builder.rounds >= min && builder.rounds <= max, + "Rounds value must be between %s and %s (inclusive).", min, max); + this.rounds = builder.rounds; + } + + @Override + protected Map getOptions() { + return ImmutableMap.of("rounds", rounds); + } + + abstract static class Builder { + private int rounds; + + protected abstract T getInstance(); + + /** + * Sets the number of rounds for the hash algorithm. + * + * @param rounds an integer between 0 and 120000 (inclusive). + * @return this builder. + */ + public T setRounds(int rounds) { + this.rounds = rounds; + return getInstance(); + } + + public abstract U build(); + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/Scrypt.java b/src/main/java/com/google/firebase/auth/hash/Scrypt.java new file mode 100644 index 000000000..1c9612285 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/Scrypt.java @@ -0,0 +1,116 @@ +/* + * Copyright 2018 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.hash; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.collect.ImmutableMap; +import com.google.common.io.BaseEncoding; +import java.util.Map; + +/** + * Represents the Scrypt password hashing algorithm. This is the + * modified Scrypt algorithm used by + * Firebase Auth. See {@link StandardScrypt} for the standard Scrypt algorithm. Can be used as an + * instance of {@link com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class Scrypt extends RepeatableHash { + + private final String key; + private final String saltSeparator; + private final int memoryCost; + + private Scrypt(Builder builder) { + super("SCRYPT",0, 8, builder); + checkArgument(builder.key != null && builder.key.length > 0, + "A non-empty key is required for Scrypt"); + checkArgument(builder.memoryCost > 0 && builder.memoryCost <= 14, + "memoryCost must be between 1 and 14"); + this.key = BaseEncoding.base64Url().encode(builder.key); + if (builder.saltSeparator != null) { + this.saltSeparator = BaseEncoding.base64Url().encode(builder.saltSeparator); + } else { + this.saltSeparator = BaseEncoding.base64Url().encode(new byte[0]); + } + this.memoryCost = builder.memoryCost; + } + + @Override + protected Map getOptions() { + return ImmutableMap.builder() + .putAll(super.getOptions()) + .put("signerKey", key) + .put("memoryCost", memoryCost) + .put("saltSeparator", saltSeparator) + .build(); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends RepeatableHash.Builder { + + private byte[] key; + private byte[] saltSeparator; + private int memoryCost; + + private Builder() {} + + /** + * Sets the signer key. Required field. + * + * @param key Signer key as a byte array. + * @return This builder. + */ + public Builder setKey(byte[] key) { + this.key = key; + return this; + } + + /** + * Sets the salt separator. + * + * @param saltSeparator Salt separator as a byte array. + * @return This builder. + */ + public Builder setSaltSeparator(byte[] saltSeparator) { + this.saltSeparator = saltSeparator; + return this; + } + + /** + * Sets the memory cost. Required field. + * + * @param memoryCost an integer between 1 and 14 (inclusive). + * @return this builder. + */ + public Builder setMemoryCost(int memoryCost) { + this.memoryCost = memoryCost; + return this; + } + + @Override + protected Builder getInstance() { + return this; + } + + public Scrypt build() { + return new Scrypt(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/Sha1.java b/src/main/java/com/google/firebase/auth/hash/Sha1.java new file mode 100644 index 000000000..9de01b0b8 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/Sha1.java @@ -0,0 +1,46 @@ +/* + * Copyright 2018 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.hash; + +/** + * Represents the SHA1 password hashing algorithm. Can be used as an instance of + * {@link com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class Sha1 extends RepeatableHash { + + private Sha1(Builder builder) { + super("SHA1", 1, 8192, builder); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends RepeatableHash.Builder { + + private Builder() {} + + @Override + protected Builder getInstance() { + return this; + } + + public Sha1 build() { + return new Sha1(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/Sha256.java b/src/main/java/com/google/firebase/auth/hash/Sha256.java new file mode 100644 index 000000000..d0185195e --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/Sha256.java @@ -0,0 +1,46 @@ +/* + * Copyright 2018 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.hash; + +/** + * Represents the SHA256 password hashing algorithm. Can be used as an instance of + * {@link com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class Sha256 extends RepeatableHash { + + private Sha256(Builder builder) { + super("SHA256", 1, 8192, builder); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends RepeatableHash.Builder { + + private Builder() {} + + @Override + protected Builder getInstance() { + return this; + } + + public Sha256 build() { + return new Sha256(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/Sha512.java b/src/main/java/com/google/firebase/auth/hash/Sha512.java new file mode 100644 index 000000000..f468abe1c --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/Sha512.java @@ -0,0 +1,46 @@ +/* + * Copyright 2018 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.hash; + +/** + * Represents the SHA512 password hashing algorithm. Can be used as an instance of + * {@link com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class Sha512 extends RepeatableHash { + + private Sha512(Builder builder) { + super("SHA512", 1, 8192, builder); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends RepeatableHash.Builder { + + private Builder() {} + + @Override + protected Builder getInstance() { + return this; + } + + public Sha512 build() { + return new Sha512(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/StandardScrypt.java b/src/main/java/com/google/firebase/auth/hash/StandardScrypt.java new file mode 100644 index 000000000..139fd114f --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/StandardScrypt.java @@ -0,0 +1,89 @@ +/* + * Copyright 2018 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.hash; + +import com.google.common.collect.ImmutableMap; +import com.google.firebase.auth.UserImportHash; +import java.util.Map; + +/** + * Represents the Standard Scrypt password hashing algorithm. Can be used as an instance of + * {@link com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class StandardScrypt extends UserImportHash { + + private final int derivedKeyLength; + private final int blockSize; + private final int parallelization; + private final int memoryCost; + + private StandardScrypt(Builder builder) { + super("STANDARD_SCRYPT"); + this.derivedKeyLength = builder.derivedKeyLength; + this.blockSize = builder.blockSize; + this.parallelization = builder.parallelization; + this.memoryCost = builder.memoryCost; + } + + @Override + protected Map getOptions() { + return ImmutableMap.of( + "dkLen", derivedKeyLength, + "blockSize", blockSize, + "parallelization", parallelization, + "memoryCost", memoryCost + ); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private int derivedKeyLength; + private int blockSize; + private int parallelization; + private int memoryCost; + + private Builder() {} + + public Builder setDerivedKeyLength(int derivedKeyLength) { + this.derivedKeyLength = derivedKeyLength; + return this; + } + + public Builder setBlockSize(int blockSize) { + this.blockSize = blockSize; + return this; + } + + public Builder setParallelization(int parallelization) { + this.parallelization = parallelization; + return this; + } + + public Builder setMemoryCost(int memoryCost) { + this.memoryCost = memoryCost; + return this; + } + + public StandardScrypt build() { + return new StandardScrypt(this); + } + } +} 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..cd570c64d --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/AuthErrorHandler.java @@ -0,0 +1,235 @@ +/* + * Copyright 2020 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.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..e8413f13f --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/AuthHttpClient.java @@ -0,0 +1,81 @@ +/* + * Copyright 2020 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 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/BaseCredential.java b/src/main/java/com/google/firebase/auth/internal/BaseCredential.java deleted file mode 100644 index d15832a55..000000000 --- a/src/main/java/com/google/firebase/auth/internal/BaseCredential.java +++ /dev/null @@ -1,77 +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.auth.oauth2.AccessToken; -import com.google.auth.oauth2.GoogleCredentials; -import com.google.common.collect.ImmutableList; -import com.google.firebase.auth.FirebaseCredential; -import com.google.firebase.auth.GoogleOAuthAccessToken; -import com.google.firebase.tasks.Task; -import com.google.firebase.tasks.Tasks; - -import java.io.IOException; -import java.util.List; - -/** - * Internal base class for built-in FirebaseCredential implementations. - */ -public abstract class BaseCredential implements FirebaseCredential { - - public static final List FIREBASE_SCOPES = - ImmutableList.of( - // Enables access to Firebase Realtime Database. - "https://www.googleapis.com/auth/firebase.database", - - // Enables access to the email address associated with a project. - "https://www.googleapis.com/auth/userinfo.email", - - // Enables access to Google Identity Toolkit (for user management APIs). - "https://www.googleapis.com/auth/identitytoolkit", - - // Enables access to Google Cloud Storage. - "https://www.googleapis.com/auth/devstorage.full_control", - - // Enables access to Google Cloud Firestore - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/datastore"); - - private final GoogleCredentials googleCredentials; - - public BaseCredential(GoogleCredentials googleCredentials) { - this.googleCredentials = checkNotNull(googleCredentials).createScoped(FIREBASE_SCOPES); - } - - public final GoogleCredentials getGoogleCredentials() { - return googleCredentials; - } - - @Override - public Task getAccessToken() { - try { - AccessToken accessToken = googleCredentials.refreshAccessToken(); - GoogleOAuthAccessToken googleToken = new GoogleOAuthAccessToken(accessToken.getTokenValue(), - accessToken.getExpirationTime().getTime()); - return Tasks.forResult(googleToken); - } catch (Exception e) { - return Tasks.forException(e); - } - } - -} diff --git a/src/main/java/com/google/firebase/auth/internal/BatchDeleteResponse.java b/src/main/java/com/google/firebase/auth/internal/BatchDeleteResponse.java new file mode 100644 index 000000000..728cf6358 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/BatchDeleteResponse.java @@ -0,0 +1,51 @@ +/* + * Copyright 2020 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 com.google.api.client.util.Key; +import java.util.List; + +/** + * Represents the response from Google identity Toolkit for a batch delete request. + */ +public class BatchDeleteResponse { + + @Key("errors") + private List errors; + + public List getErrors() { + return errors; + } + + public static class ErrorInfo { + @Key("index") + private int index; + + @Key("message") + private String message; + + // A 'localId' field also exists here, but is not currently exposed in the Admin SDK. + + 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 new file mode 100644 index 000000000..2ff30a20c --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java @@ -0,0 +1,48 @@ +/* + * Copyright 2018 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 com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.internal.NonNull; +import java.io.IOException; + +/** + * Represents an object that can be used to cryptographically sign data. Mainly used for signing + * custom JWT tokens issued to Firebase users. + * + *

See {@link com.google.firebase.auth.FirebaseAuth#createCustomToken(String)}. + */ +interface CryptoSigner { + + /** + * Signs the given payload. + * + * @param payload Data to be signed + * @return Signature as a byte array + * @throws FirebaseAuthException If an error occurs during signing + */ + @NonNull + byte[] sign(@NonNull byte[] payload) throws FirebaseAuthException; + + /** + * Returns the client email of the service account used to sign payloads. + * + * @return A service account client email + */ + @NonNull + String getAccount(); +} diff --git a/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java b/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java new file mode 100644 index 000000000..543f955c3 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java @@ -0,0 +1,196 @@ +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.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.HttpResponseInterceptor; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.util.StringUtils; +import com.google.auth.ServiceAccountSigner; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.BaseEncoding; +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.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; + +/** + * A set of {@link CryptoSigner} implementations and utilities for interacting with them. + */ +public class CryptoSigners { + + private static final String METADATA_SERVICE_URL = + "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email"; + + private CryptoSigners() { } + + /** + * A {@link CryptoSigner} implementation that uses service account credentials or equivalent + * crypto-capable credentials for signing data. + */ + static class ServiceAccountCryptoSigner implements CryptoSigner { + + private final ServiceAccountSigner signer; + + ServiceAccountCryptoSigner(@NonNull ServiceAccountSigner signer) { + this.signer = checkNotNull(signer); + } + + @Override + public byte[] sign(byte[] payload) { + return signer.sign(payload); + } + + @Override + public String getAccount() { + return signer.getAccount(); + } + } + + /** + * @ {@link CryptoSigner} implementation that uses the + * + * Google IAMCredentials service to sign data. + */ + static class IAMCryptoSigner implements CryptoSigner { + + private static final String IAM_SIGN_BLOB_URL = + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:signBlob"; + + private final String serviceAccount; + private final ErrorHandlingHttpClient httpClient; + + IAMCryptoSigner( + @NonNull HttpRequestFactory requestFactory, + @NonNull JsonFactory jsonFactory, + @NonNull String serviceAccount) { + checkArgument(!Strings.isNullOrEmpty(serviceAccount)); + this.serviceAccount = serviceAccount; + this.httpClient = new ErrorHandlingHttpClient<>( + requestFactory, + jsonFactory, + new IAMErrorHandler(jsonFactory)); + } + + void setInterceptor(HttpResponseInterceptor interceptor) { + httpClient.setInterceptor(interceptor); + } + + @Override + public byte[] sign(byte[] payload) throws FirebaseAuthException { + String encodedPayload = BaseEncoding.base64().encode(payload); + 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 + public String getAccount() { + return serviceAccount; + } + } + + /** + * 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. + */ + 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. + if (credentials instanceof ServiceAccountCredentials) { + return new ServiceAccountCryptoSigner((ServiceAccountCredentials) credentials); + } + + 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 = firebaseApp.getOptions().getServiceAccountId(); + if (!Strings.isNullOrEmpty(serviceAccountId)) { + return new IAMCryptoSigner(requestFactory, jsonFactory, serviceAccountId); + } + + // If the SDK was initialized with some other credential type that supports signing + // (e.g. GAE credentials), use it to sign bytes. + if (credentials instanceof ServiceAccountSigner) { + return new ServiceAccountCryptoSigner((ServiceAccountSigner) credentials); + } + + // Attempt to discover a service account email from the local Metadata service. Use it + // with the IAM service to sign bytes. + 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()); + return StringUtils.newStringUtf8(output).trim(); + } finally { + ApiClientUtils.disconnectQuietly(response); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/FirebaseCredentialsAdapter.java b/src/main/java/com/google/firebase/auth/internal/FirebaseCredentialsAdapter.java deleted file mode 100644 index e1b069eda..000000000 --- a/src/main/java/com/google/firebase/auth/internal/FirebaseCredentialsAdapter.java +++ /dev/null @@ -1,51 +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.auth.oauth2.AccessToken; -import com.google.auth.oauth2.GoogleCredentials; -import com.google.firebase.auth.FirebaseCredential; -import com.google.firebase.auth.GoogleOAuthAccessToken; -import com.google.firebase.tasks.Tasks; -import java.io.IOException; -import java.util.Date; -import java.util.concurrent.ExecutionException; - -/** - * An adapter for converting custom {@link FirebaseCredential} implementations into - * GoogleCredentials. - */ -public final class FirebaseCredentialsAdapter extends GoogleCredentials { - - private final FirebaseCredential credential; - - public FirebaseCredentialsAdapter(FirebaseCredential credential) { - this.credential = checkNotNull(credential); - } - - @Override - public AccessToken refreshAccessToken() throws IOException { - try { - GoogleOAuthAccessToken token = Tasks.await(credential.getAccessToken()); - return new AccessToken(token.getAccessToken(), new Date(token.getExpiryTime())); - } catch (ExecutionException | InterruptedException e) { - throw new IOException("Error while obtaining OAuth2 token", e); - } - } -} 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..2fe0b1859 100644 --- a/src/main/java/com/google/firebase/auth/internal/FirebaseCustomAuthToken.java +++ b/src/main/java/com/google/firebase/auth/internal/FirebaseCustomAuthToken.java @@ -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 1309318e0..b5aa1e31a 100644 --- a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java +++ b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java @@ -16,81 +16,108 @@ 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.json.GenericJson; import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.gson.GsonFactory; import com.google.api.client.json.webtoken.JsonWebSignature; +import com.google.api.client.util.Base64; import com.google.api.client.util.Clock; -import com.google.common.base.Preconditions; +import com.google.api.client.util.StringUtils; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.internal.Nullable; import java.io.IOException; -import java.security.GeneralSecurityException; -import java.security.PrivateKey; import java.util.Collection; import java.util.Map; /** - * Provides helper methods to simplify the creation of FirebaseCustomAuthTokens. + * Provides helper methods to simplify the creation of Firebase custom auth tokens. * *

This class is designed to hide underlying implementation details from a Firebase developer. */ public class FirebaseTokenFactory { - private static FirebaseTokenFactory instance; - - private JsonFactory factory; - private Clock clock; + private final JsonFactory jsonFactory; + private final Clock clock; + private final CryptoSigner signer; + private final String tenantId; - public FirebaseTokenFactory(JsonFactory factory, Clock clock) { - this.factory = factory; - this.clock = clock; + 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; } - public static FirebaseTokenFactory getInstance() { - if (null == instance) { - instance = new FirebaseTokenFactory(new GsonFactory(), Clock.SYSTEM); - } - - return instance; + @VisibleForTesting + FirebaseTokenFactory(JsonFactory jsonFactory, Clock clock, CryptoSigner signer) { + this(jsonFactory, clock, signer, null); } - public String createSignedCustomAuthTokenForUser(String uid, String issuer, PrivateKey privateKey) - throws GeneralSecurityException, IOException { - return createSignedCustomAuthTokenForUser(uid, null, issuer, privateKey); + String createSignedCustomAuthTokenForUser(String uid) throws FirebaseAuthException { + return createSignedCustomAuthTokenForUser(uid, null); } public String createSignedCustomAuthTokenForUser( - String uid, Map developerClaims, String issuer, PrivateKey privateKey) - throws GeneralSecurityException, IOException { - Preconditions.checkState(uid != null, "Uid must be provided."); - Preconditions.checkState(issuer != null && !"".equals(issuer), "Must provide an issuer."); - Preconditions.checkState(uid.length() <= 128, "Uid must be shorter than 128 characters."); + 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."); JsonWebSignature.Header header = new JsonWebSignature.Header().setAlgorithm("RS256"); - long issuedAt = clock.currentTimeMillis() / 1000; + final long issuedAt = clock.currentTimeMillis() / 1000; FirebaseCustomAuthToken.Payload payload = new FirebaseCustomAuthToken.Payload() .setUid(uid) - .setIssuer(issuer) - .setSubject(issuer) + .setIssuer(signer.getAccount()) + .setSubject(signer.getAccount()) .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(); for (String key : developerClaims.keySet()) { if (reservedNames.contains(key)) { throw new IllegalArgumentException( - String.format("developer_claims can not contain a reserved key: %s", key)); + String.format("developerClaims must not contain a reserved key: %s", key)); } } + GenericJson jsonObject = new GenericJson(); jsonObject.putAll(developerClaims); payload.setDeveloperClaims(jsonObject); } - return JsonWebSignature.signUsingRsaSha256(privateKey, factory, header, payload); + return signPayload(header, payload); + } + + 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; + } + + 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 db89a7ec5..000000000 --- a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenVerifier.java +++ /dev/null @@ -1,236 +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 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.http.HttpTransport; -import com.google.api.client.http.javanet.NetHttpTransport; -import com.google.api.client.json.gson.GsonFactory; -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.annotations.VisibleForTesting; -import com.google.common.base.Preconditions; -import com.google.firebase.auth.FirebaseAuthException; - -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 { - - @VisibleForTesting - static final String CLIENT_CERT_URL = - "https://www.googleapis.com/robot/v1/metadata/x509/" - + "securetoken@system.gserviceaccount.com"; - /** The default public keys manager for verifying projects use the correct public key. */ - public static final GooglePublicKeysManager DEFAULT_KEY_MANAGER = - new GooglePublicKeysManager.Builder(new NetHttpTransport.Builder().build(), new GsonFactory()) - .setClock(Clock.SYSTEM) - .setPublicCertsEncodedUrl(CLIENT_CERT_URL) - .build(); - - private static final String ISSUER_PREFIX = "https://securetoken.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 ID token comes from the same Firebase project as the service account used to " - + "authenticate this SDK."; - private static final String VERIFY_ID_TOKEN_DOCS_MESSAGE = - " See https://firebase.google.com/docs/auth/admin/verify-id-tokens for details on how to " - + "retrieve an ID token."; - private static final String ALGORITHM = "RS256"; - private String projectId; - private GooglePublicKeysManager publicKeysManager; - - protected FirebaseTokenVerifier(Builder builder) { - super(builder); - Preconditions.checkArgument(builder.projectId != null, "projectId must be set"); - - this.projectId = builder.projectId; - this.publicKeysManager = builder.publicKeysManager; - } - - /** - * 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 boolean 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 = "verifyIdToken() expects an ID token, but was given a custom token."; - } else if (isLegacyCustomToken) { - errorMessage = "verifyIdToken() expects an ID token, but was given a legacy custom token."; - } else { - errorMessage = "Firebase ID token has no \"kid\" claim."; - } - } else if (header.getAlgorithm() == null || !header.getAlgorithm().equals(ALGORITHM)) { - errorMessage = - String.format( - "Firebase ID token has incorrect algorithm. Expected \"%s\" but got \"%s\".", - ALGORITHM, header.getAlgorithm()); - } else if (!token.verifyAudience(getAudience())) { - errorMessage = - String.format( - "Firebase ID token has incorrect \"aud\" (audience) claim. Expected \"%s\" but got " - + "\"%s\".", - concat(getAudience()), concat(token.getPayload().getAudienceAsList())); - errorMessage += PROJECT_ID_MATCH_MESSAGE; - } else if (!token.verifyIssuer(getIssuers())) { - errorMessage = - String.format( - "Firebase ID token has incorrect \"iss\" (issuer) claim. " - + "Expected \"%s\" but got \"%s\".", - concat(getIssuers()), token.getPayload().getIssuer()); - errorMessage += PROJECT_ID_MATCH_MESSAGE; - } else if (payload.getSubject() == null) { - errorMessage = "Firebase ID token has no \"sub\" (subject) claim."; - } else if (payload.getSubject().isEmpty()) { - errorMessage = "Firebase ID token has an empty string \"sub\" (subject) claim."; - } else if (payload.getSubject().length() > 128) { - errorMessage = "Firebase ID token has \"sub\" (subject) claim longer than 128 characters."; - } else if (!token.verifyTime(getClock().currentTimeMillis(), getAcceptableTimeSkewSeconds())) { - errorMessage = - "Firebase ID token has expired or is not yet valid. Get a fresh token from your client " - + "app and try again."; - } - - if (errorMessage != null) { - errorMessage += VERIFY_ID_TOKEN_DOCS_MESSAGE; - throw new FirebaseAuthException(ERROR_INVALID_CREDENTIAL, errorMessage); - } - - try { - if (!verifySignature(token)) { - throw new FirebaseAuthException( - ERROR_INVALID_CREDENTIAL, - "Firebase ID token isn't signed by a valid public key." + VERIFY_ID_TOKEN_DOCS_MESSAGE); - } - } catch (IOException | GeneralSecurityException e) { - throw new FirebaseAuthException( - ERROR_RUNTIME_EXCEPTION, "Error while verifying token signature.", e); - } - - return true; - } - - 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. - * - *

TODO: Wrap these blocking steps in a Task. - */ - 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; - } - - public static GooglePublicKeysManager buildGooglePublicKeysManager(HttpTransport transport) { - return new GooglePublicKeysManager.Builder(transport, new GsonFactory()) - .setClock(Clock.SYSTEM) - .setPublicCertsEncodedUrl(FirebaseTokenVerifier.CLIENT_CERT_URL) - .build(); - } - - /** - * Builder for {@link FirebaseTokenVerifier}. - */ - public static class Builder extends IdTokenVerifier.Builder { - - String projectId; - - GooglePublicKeysManager publicKeysManager = DEFAULT_KEY_MANAGER; - - public String getProjectId() { - return projectId; - } - - public Builder setProjectId(String projectId) { - this.projectId = projectId; - - this.setIssuer(ISSUER_PREFIX + projectId); - this.setAudience(Collections.singleton(projectId)); - - return this; - } - - @Override - public Builder setClock(Clock clock) { - return (Builder) super.setClock(clock); - } - - public GooglePublicKeysManager getPublicKeyManager() { - return publicKeysManager; - } - - /** Override the GooglePublicKeysManager from the default. */ - public Builder setPublicKeysManager(GooglePublicKeysManager publicKeysManager) { - this.publicKeysManager = publicKeysManager; - return this; - } - - @Override - public FirebaseTokenVerifier build() { - return new FirebaseTokenVerifier(this); - } - } -} 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..67c4d0ee7 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoRequest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2020 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 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..7bde3eb39 100644 --- a/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java +++ b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java @@ -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/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/HttpErrorResponse.java b/src/main/java/com/google/firebase/auth/internal/UploadAccountResponse.java similarity index 59% rename from src/main/java/com/google/firebase/auth/internal/HttpErrorResponse.java rename to src/main/java/com/google/firebase/auth/internal/UploadAccountResponse.java index d4be4b4a6..abf28539d 100644 --- a/src/main/java/com/google/firebase/auth/internal/HttpErrorResponse.java +++ b/src/main/java/com/google/firebase/auth/internal/UploadAccountResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2018 Google Inc. * * 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,33 @@ 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 identity toolkit for a user import request. */ -public class HttpErrorResponse { +public class UploadAccountResponse { @Key("error") - private Error error; + 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; - 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/Utils.java b/src/main/java/com/google/firebase/auth/internal/Utils.java new file mode 100644 index 000000000..f4d082917 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/Utils.java @@ -0,0 +1,35 @@ +/* + * Copyright 2021 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 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/multitenancy/FirebaseTenantClient.java b/src/main/java/com/google/firebase/auth/multitenancy/FirebaseTenantClient.java new file mode 100644 index 000000000..56ef04eb7 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/multitenancy/FirebaseTenantClient.java @@ -0,0 +1,124 @@ +/* + * Copyright 2020 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.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 ace91b40c..682c39f07 100644 --- a/src/main/java/com/google/firebase/cloud/FirestoreClient.java +++ b/src/main/java/com/google/firebase/cloud/FirestoreClient.java @@ -3,6 +3,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; +import com.google.api.gax.core.FixedCredentialsProvider; import com.google.cloud.firestore.Firestore; import com.google.cloud.firestore.FirestoreOptions; import com.google.common.base.Strings; @@ -10,41 +11,56 @@ 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 * project ID from the {@link com.google.firebase.FirebaseOptions} used to initialize the underlying * {@link FirebaseApp}. If that is not available, it examines the credentials used to initialize - * the app. Finally it attempts to get the project ID by looking up the GCLOUD_PROJECT environment - * variable. If a project ID cannot be determined by any of these methods, this API will throw - * a runtime exception. + * the app. Finally it attempts to get the project ID by looking up the GOOGLE_CLOUD_PROJECT and + * GCLOUD_PROJECT environment variables. If a project ID cannot be determined by any of these + * methods, this API will throw a runtime exception. */ 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), "Project ID is required for accessing Firestore. Use a service account credential or " + "set the project ID explicitly via FirebaseOptions. Alternatively you can also " - + "set the project ID via the GCLOUD_PROJECT environment variable."); - this.firestore = FirestoreOptions.newBuilder() - .setCredentials(ImplFirebaseTrampolines.getCredentials(app)) + + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable."); + FirestoreOptions userOptions = ImplFirebaseTrampolines.getFirestoreOptions(app); + FirestoreOptions.Builder builder = userOptions != null + ? userOptions.toBuilder() : FirestoreOptions.newBuilder(); + this.firestore = builder + // CredentialsProvider has highest priority in FirestoreOptions, so we set that. + .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 @@ -53,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 96b53c8da..172279990 100644 --- a/src/main/java/com/google/firebase/cloud/StorageClient.java +++ b/src/main/java/com/google/firebase/cloud/StorageClient.java @@ -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 1a1bb24d1..65bcdfdce 100644 --- a/src/main/java/com/google/firebase/database/ChildEventListener.java +++ b/src/main/java/com/google/firebase/database/ChildEventListener.java @@ -53,7 +53,7 @@ public interface ChildEventListener { /** * This method is triggered when a child location's priority changes. See {@link - * DatabaseReference#setPriority(Object)} and Ordered Data for more information on priorities and ordering data. * diff --git a/src/main/java/com/google/firebase/database/DataSnapshot.java b/src/main/java/com/google/firebase/database/DataSnapshot.java index 700fa2a2a..f1f470505 100644 --- a/src/main/java/com/google/firebase/database/DataSnapshot.java +++ b/src/main/java/com/google/firebase/database/DataSnapshot.java @@ -38,7 +38,7 @@ * They are efficiently-generated immutable copies of the data at a Firebase Database location. They * can't be modified and will never change. To modify data at a location, use a {@link * DatabaseReference DatabaseReference} reference (e.g. with {@link - * DatabaseReference#setValue(Object)}). + * DatabaseReference#setValueAsync(Object)}). */ public class DataSnapshot { @@ -114,7 +114,7 @@ public boolean exists() { *

  • List<Object> * * - *

    This list is recursive; the possible types for {@link java.lang.Object} in the above list + *

    This list is recursive; the possible types for Object in the above list * is given by the same list. These types correspond to the types available in JSON. * * @return The data contained in this snapshot as native types or null if there is no data at this @@ -138,7 +138,7 @@ public Object getValue() { *

  • List<Object> * * - *

    This list is recursive; the possible types for {@link java.lang.Object} in the above list is + *

    This list is recursive; the possible types for Object in the above list is * given by the same list. These types correspond to the types available in JSON. * *

    If useExportFormat is set to true, priority information will be included in the output. @@ -206,7 +206,7 @@ public T getValue(Class valueType) { /** * Due to the way that Java implements generics, it takes an extra step to get back a - * properly-typed Collection. So, in the case where you want a {@link java.util.List} of Message + * properly-typed Collection. So, in the case where you want a List of Message * instances, you will need to do something like the following: * *

    
    diff --git a/src/main/java/com/google/firebase/database/DatabaseReference.java b/src/main/java/com/google/firebase/database/DatabaseReference.java
    index 1c1b9db71..10dfe40ba 100644
    --- a/src/main/java/com/google/firebase/database/DatabaseReference.java
    +++ b/src/main/java/com/google/firebase/database/DatabaseReference.java
    @@ -33,8 +33,6 @@
     import com.google.firebase.database.utilities.Utilities;
     import com.google.firebase.database.utilities.Validation;
     import com.google.firebase.database.utilities.encoding.CustomClassMapper;
    -import com.google.firebase.internal.TaskToApiFuture;
    -import com.google.firebase.tasks.Task;
     
     import java.io.UnsupportedEncodingException;
     import java.net.URLEncoder;
    @@ -177,7 +175,7 @@ public DatabaseReference push() {
        * @return The ApiFuture for this operation.
        */
       public ApiFuture setValueAsync(Object value) {
    -    return new TaskToApiFuture<>(setValue(value));
    +    return setValueInternal(value, PriorityUtilities.parsePriority(this.path, null), null);
       }
     
       /**
    @@ -215,29 +213,6 @@ public ApiFuture setValueAsync(Object value) {
        * @return The ApiFuture for this operation.
        */
       public ApiFuture setValueAsync(Object value, Object priority) {
    -    return new TaskToApiFuture<>(setValue(value, priority));
    -  }
    -
    -  /**
    -   * Similar to {@link #setValueAsync(Object)} but returns a Task.
    -   *
    -   * @param value The value to set at this location
    -   * @return The {@link Task} for this operation.
    -   * @deprecated Use {@link #setValueAsync(Object)}
    -   */
    -  public Task setValue(Object value) {
    -    return setValueInternal(value, PriorityUtilities.parsePriority(this.path, null), null);
    -  }
    -
    -  /**
    -   * Similar to {@link #setValueAsync(Object, Object)} but returns a Task.
    -   *
    -   * @param value The value to set at this location
    -   * @param priority The priority to set at this location
    -   * @return The {@link Task} for this operation.
    -   * @deprecated Use {@link #setValueAsync(Object, Object)}
    -   */
    -  public Task setValue(Object value, Object priority) {
         return setValueInternal(value, PriorityUtilities.parsePriority(this.path, priority), null);
       }
     
    @@ -315,13 +290,14 @@ public void setValue(Object value, Object priority, CompletionListener listener)
         setValueInternal(value, PriorityUtilities.parsePriority(this.path, priority), listener);
       }
     
    -  private Task setValueInternal(Object value, Node priority, CompletionListener optListener) {
    +  private ApiFuture setValueInternal(Object value, Node priority, CompletionListener
    +      optListener) {
         Validation.validateWritablePath(getPath());
         ValidationPath.validateWithObject(getPath(), value);
         Object bouncedValue = CustomClassMapper.convertToPlainJavaTypes(value);
         Validation.validateWritableObject(bouncedValue);
         final Node node = NodeUtilities.NodeFromJSON(bouncedValue, priority);
    -    final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener);
    +    final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener);
         repo.scheduleNow(
             new Runnable() {
               @Override
    @@ -364,17 +340,6 @@ public void run() {
        * @return The ApiFuture for this operation.
        */
       public ApiFuture setPriorityAsync(Object priority) {
    -    return new TaskToApiFuture<>(setPriority(priority));
    -  }
    -
    -  /**
    -   * Similar to {@link #setPriorityAsync(Object)} but returns a Task.
    -   *
    -   * @param priority The priority to set at the specified location.
    -   * @return The {@link Task} for this operation.
    -   * @deprecated Use {@link #setPriorityAsync(Object)}
    -   */
    -  public Task setPriority(Object priority) {
         return setPriorityInternal(PriorityUtilities.parsePriority(this.path, priority), null);
       }
     
    @@ -413,10 +378,10 @@ public void setPriority(Object priority, CompletionListener listener) {
     
       // Remove
     
    -  private Task setPriorityInternal(final Node priority, CompletionListener optListener) {
    +  private ApiFuture setPriorityInternal(final Node priority, CompletionListener optListener) {
         Validation.validateWritablePath(getPath());
     
    -    final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener);
    +    final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener);
         repo.scheduleNow(
             new Runnable() {
               @Override
    @@ -438,17 +403,6 @@ public void run() {
        * @return The ApiFuture for this operation.
        */
       public ApiFuture updateChildrenAsync(Map update) {
    -    return new TaskToApiFuture<>(updateChildren(update));
    -  }
    -
    -  /**
    -   * Similar to {@link #updateChildrenAsync(Map)} but returns a Task.
    -   *
    -   * @param update The paths to update and their new values
    -   * @return The {@link Task} for this operation.
    -   * @deprecated Use {@link #updateChildrenAsync(Map)}
    -   */
    -  public Task updateChildren(Map update) {
         return updateChildrenInternal(update, null);
       }
     
    @@ -467,7 +421,7 @@ public void updateChildren(final Map update, final CompletionLis
     
       // Transactions
     
    -  private Task updateChildrenInternal(
    +  private ApiFuture updateChildrenInternal(
           final Map update, final CompletionListener optListener) {
         if (update == null) {
           throw new NullPointerException("Can't pass null for argument 'update' in updateChildren()");
    @@ -477,7 +431,7 @@ private Task updateChildrenInternal(
             Validation.parseAndValidateUpdate(getPath(), bouncedUpdate);
         final CompoundWrite merge = CompoundWrite.fromPathMerge(parsedUpdate);
     
    -    final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener);
    +    final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener);
         repo.scheduleNow(
             new Runnable() {
               @Override
    @@ -494,17 +448,7 @@ public void run() {
        * @return The ApiFuture for this operation.
        */
       public ApiFuture removeValueAsync() {
    -    return new TaskToApiFuture<>(removeValue());
    -  }
    -
    -  /**
    -   * Similar to {@link #removeValueAsync()} but returns a Task.
    -   *
    -   * @return The Task for this operation.
    -   * @deprecated Use {@link #removeValueAsync()}
    -   */
    -  public Task removeValue() {
    -    return setValue(null);
    +    return setValueAsync(null);
       }
     
       // Manual Connection Management
    diff --git a/src/main/java/com/google/firebase/database/FirebaseDatabase.java b/src/main/java/com/google/firebase/database/FirebaseDatabase.java
    index 6a196f913..e59cd38fa 100644
    --- a/src/main/java/com/google/firebase/database/FirebaseDatabase.java
    +++ b/src/main/java/com/google/firebase/database/FirebaseDatabase.java
    @@ -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)) {
    @@ -256,24 +279,6 @@ public void goOffline() {
         RepoManager.interrupt(ensureRepo());
       }
     
    -  /**
    -   * By default, this is set to {@link Logger.Level#INFO INFO}. This includes any internal errors
    -   * ({@link Logger.Level#ERROR ERROR}) and any security debug messages ({@link Logger.Level#INFO
    -   * INFO}) that the client receives. Set to {@link Logger.Level#DEBUG DEBUG} to turn on the
    -   * diagnostic logging, and {@link Logger.Level#NONE NONE} to disable all logging.
    -   *
    -   * @param logLevel The desired minimum log level
    -   * @deprecated This method will be removed in a future release. Use SLF4J-based logging instead.
    -   *     For example, add the slf4j-simple.jar to the classpath to log to STDERR. See
    -   *     SLF4J user manual for more details.
    -   */
    -  public synchronized void setLogLevel(Logger.Level logLevel) {
    -    synchronized (lock) {
    -      assertUnfrozen("setLogLevel");
    -      this.config.setLogLevel(logLevel);
    -    }
    -  }
    -
       /**
        * The Firebase Database client will cache synchronized data and keep track of all writes you've
        * initiated while your application is running. It seamlessly handles intermittent network
    @@ -354,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/MutableData.java b/src/main/java/com/google/firebase/database/MutableData.java
    index 79e87f2b4..7a0e7983b 100644
    --- a/src/main/java/com/google/firebase/database/MutableData.java
    +++ b/src/main/java/com/google/firebase/database/MutableData.java
    @@ -177,7 +177,7 @@ public String getKey() {
        *   
  • List<Object> * * - *

    This list is recursive; the possible types for {@link java.lang.Object} in the above list is + *

    This list is recursive; the possible types for Object in the above list is * given by the same list. These types correspond to the types available in JSON. * * @return The data contained in this instance as native types, or null if there is no data at @@ -190,7 +190,7 @@ public Object getValue() { /** * Due to the way that Java implements generics, it takes an extra step to get back a - * properly-typed Collection. So, in the case where you want a {@link java.util.List} of Message + * properly-typed Collection. So, in the case where you want a List of Message * instances, you will need to do something like the following: * *

    
    diff --git a/src/main/java/com/google/firebase/database/OnDisconnect.java b/src/main/java/com/google/firebase/database/OnDisconnect.java
    index 3b02987db..7b4cc0859 100644
    --- a/src/main/java/com/google/firebase/database/OnDisconnect.java
    +++ b/src/main/java/com/google/firebase/database/OnDisconnect.java
    @@ -28,8 +28,6 @@
     import com.google.firebase.database.utilities.Utilities;
     import com.google.firebase.database.utilities.Validation;
     import com.google.firebase.database.utilities.encoding.CustomClassMapper;
    -import com.google.firebase.internal.TaskToApiFuture;
    -import com.google.firebase.tasks.Task;
     
     import java.util.Map;
     
    @@ -52,41 +50,6 @@ public class OnDisconnect {
         this.path = path;
       }
     
    -  /**
    -   * Similar to {@link #setValueAsync(Object)}, but returns a Task.
    -   *
    -   * @param value The value to be set when a disconnect occurs
    -   * @return The {@link Task} for this operation.
    -   * @deprecated Use {@link #setValueAsync(Object)}
    -   */
    -  public Task setValue(Object value) {
    -    return onDisconnectSetInternal(value, PriorityUtilities.NullPriority(), null);
    -  }
    -
    -  /**
    -   * Similar to {@link #setValueAsync(Object, String)}, but returns a Task.
    -   *
    -   * @param value The value to be set when a disconnect occurs
    -   * @param priority The priority to be set when a disconnect occurs
    -   * @return The {@link Task} for this operation.
    -   * @deprecated Use {@link #setValueAsync(Object, String)}
    -   */
    -  public Task setValue(Object value, String priority) {
    -    return onDisconnectSetInternal(value, PriorityUtilities.parsePriority(path, priority), null);
    -  }
    -
    -  /**
    -   * Similar to {@link #setValueAsync(Object, double)}, but returns a Task.
    -   *
    -   * @param value The value to be set when a disconnect occurs
    -   * @param priority The priority to be set when a disconnect occurs
    -   * @return The {@link Task} for this operation.
    -   * @deprecated Use {@link #setValueAsync(Object, double)}
    -   */
    -  public Task setValue(Object value, double priority) {
    -    return onDisconnectSetInternal(value, PriorityUtilities.parsePriority(path, priority), null);
    -  }
    -
       /**
        * Ensure the data at this location is set to the specified value when the client is disconnected
        * (due to closing the browser, navigating to a new page, or network issues). 
    @@ -157,7 +120,7 @@ public void setValue(Object value, Map priority, CompletionListener listener) { * @return The ApiFuture for this operation. */ public ApiFuture setValueAsync(Object value) { - return new TaskToApiFuture<>(setValue(value)); + return onDisconnectSetInternal(value, PriorityUtilities.NullPriority(), null); } /** @@ -172,7 +135,7 @@ public ApiFuture setValueAsync(Object value) { * @return The ApiFuture for this operation. */ public ApiFuture setValueAsync(Object value, String priority) { - return new TaskToApiFuture<>(setValue(value, priority)); + return onDisconnectSetInternal(value, PriorityUtilities.parsePriority(path, priority), null); } /** @@ -187,17 +150,17 @@ public ApiFuture setValueAsync(Object value, String priority) { * @return The ApiFuture for this operation. */ public ApiFuture setValueAsync(Object value, double priority) { - return new TaskToApiFuture<>(setValue(value, priority)); + return onDisconnectSetInternal(value, PriorityUtilities.parsePriority(path, priority), null); } - private Task onDisconnectSetInternal( + private ApiFuture onDisconnectSetInternal( Object value, Node priority, final CompletionListener optListener) { Validation.validateWritablePath(path); ValidationPath.validateWithObject(path, value); Object bouncedValue = CustomClassMapper.convertToPlainJavaTypes(value); Validation.validateWritableObject(bouncedValue); final Node node = NodeUtilities.NodeFromJSON(bouncedValue, priority); - final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener); + final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener); repo.scheduleNow( new Runnable() { @Override @@ -210,17 +173,6 @@ public void run() { // Update - /** - * Similar to {@link #updateChildrenAsync(Map)}, but returns a Task. - * - * @param update The paths to update, along with their desired values - * @return The {@link Task} for this operation. - * @deprecated Use {@link #updateChildrenAsync(Map)} - */ - public Task updateChildren(Map update) { - return updateChildrenInternal(update, null); - } - /** * Ensure the data has the specified child values updated when the client is disconnected * @@ -238,13 +190,13 @@ public void updateChildren(final Map update, final CompletionLis * @return The ApiFuture for this operation. */ public ApiFuture updateChildrenAsync(Map update) { - return new TaskToApiFuture<>(updateChildren(update)); + return updateChildrenInternal(update, null); } - private Task updateChildrenInternal( + private ApiFuture updateChildrenInternal( final Map update, final CompletionListener optListener) { final Map parsedUpdate = Validation.parseAndValidateUpdate(path, update); - final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener); + final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener); repo.scheduleNow( new Runnable() { @Override @@ -257,16 +209,6 @@ public void run() { // Remove - /** - * Similar to {@link #removeValueAsync()}, but returns a Task. - * - * @return The {@link Task} for this operation. - * @deprecated Use {@link #removeValueAsync()} - */ - public Task removeValue() { - return setValue(null); - } - /** * Remove the value at this location when the client disconnects * @@ -282,21 +224,11 @@ public void removeValue(CompletionListener listener) { * @return The ApiFuture for this operation. */ public ApiFuture removeValueAsync() { - return new TaskToApiFuture<>(removeValue()); + return setValueAsync(null); } // Cancel the operation - /** - * Similar to {@link #cancelAsync()} ()}, but returns a Task. - * - * @return The {@link Task} for this operation. - * @deprecated Use {@link #cancelAsync()}. - */ - public Task cancel() { - return cancelInternal(null); - } - /** * Cancel any disconnect operations that are queued up at this location * @@ -312,11 +244,11 @@ public void cancel(final CompletionListener listener) { * @return The ApiFuture for this operation. */ public ApiFuture cancelAsync() { - return new TaskToApiFuture<>(cancel()); + return cancelInternal(null); } - private Task cancelInternal(final CompletionListener optListener) { - final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener); + private ApiFuture cancelInternal(final CompletionListener optListener) { + final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener); repo.scheduleNow( new Runnable() { @Override 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 4059e46e8..83e377304 100644 --- a/src/main/java/com/google/firebase/database/connection/Connection.java +++ b/src/main/java/com/google/firebase/database/connection/Connection.java @@ -17,10 +17,11 @@ package com.google.firebase.database.connection; import com.google.common.annotations.VisibleForTesting; -import com.google.firebase.database.logging.LogWrapper; import java.util.HashMap; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; class Connection implements WebsocketConnection.Delegate { @@ -39,11 +40,14 @@ class Connection implements WebsocketConnection.Delegate { private static final String SERVER_HELLO_TIMESTAMP = "ts"; private static final String SERVER_HELLO_HOST = "h"; private static final String SERVER_HELLO_SESSION_ID = "s"; + + private static final Logger logger = LoggerFactory.getLogger(Connection.class); + private static long connectionIds = 0; - private final LogWrapper logger; private final HostInfo hostInfo; private final Delegate delegate; + private final String label; private WebsocketConnection conn; private State state; @@ -54,36 +58,31 @@ class Connection implements WebsocketConnection.Delegate { String cachedHost, Delegate delegate, String optLastSessionId) { - this(context, hostInfo, delegate, + this(hostInfo, delegate, new DefaultWebsocketConnectionFactory(context, hostInfo, cachedHost, optLastSessionId)); } @VisibleForTesting Connection( - ConnectionContext context, HostInfo hostInfo, Delegate delegate, WebsocketConnectionFactory connFactory) { long connId = connectionIds++; this.hostInfo = hostInfo; this.delegate = delegate; - this.logger = new LogWrapper(context.getLogger(), Connection.class, "conn_" + connId); + this.label = "[conn_" + connId + "]"; this.state = State.REALTIME_CONNECTING; this.conn = connFactory.newConnection(this); } public void open() { - if (logger.logsDebug()) { - logger.debug("Opening a connection"); - } + logger.debug("{} Opening a connection", label); conn.open(); } public void close(DisconnectReason reason) { if (state != State.REALTIME_DISCONNECTED) { - if (logger.logsDebug()) { - logger.debug("closing realtime connection"); - } + logger.debug("{} Closing realtime connection", label); state = State.REALTIME_DISCONNECTED; if (conn != null) { @@ -123,21 +122,14 @@ public void onMessage(Map message) { Map data = (Map) message.get(SERVER_ENVELOPE_DATA); onControlMessage(data); } else { - if (logger.logsDebug()) { - logger.debug("Ignoring unknown server message type: " + messageType); - } + logger.debug("{} Ignoring unknown server message type: {}", label, messageType); } } else { - if (logger.logsDebug()) { - logger.debug( - "Failed to parse server message: missing message type:" + message.toString()); - } + logger.debug("{} Failed to parse server message: missing message type: {}", label, message); close(); } } catch (ClassCastException e) { - if (logger.logsDebug()) { - logger.debug("Failed to parse server message: " + e.toString()); - } + logger.debug("{} Failed to parse server message", label, e); close(); } } @@ -146,30 +138,22 @@ public void onMessage(Map message) { public void onDisconnect(boolean wasEverConnected) { conn = null; if (!wasEverConnected && state == State.REALTIME_CONNECTING) { - if (logger.logsDebug()) { - logger.debug("Realtime connection failed"); - } + logger.debug("{} Realtime connection failed", label); } else { - if (logger.logsDebug()) { - logger.debug("Realtime connection lost"); - } + logger.debug("{} Realtime connection lost", label); } close(); } private void onDataMessage(Map data) { - if (logger.logsDebug()) { - logger.debug("received data message: " + data.toString()); - } + logger.debug("{} Received data message: {}", label, data); // We don't do anything with data messages, just kick them up a level delegate.onDataMessage(data); } private void onControlMessage(Map data) { - if (logger.logsDebug()) { - logger.debug("Got control message: " + data.toString()); - } + logger.debug("{} Got control message: {}", label, data); try { String messageType = (String) data.get(SERVER_CONTROL_MESSAGE_TYPE); if (messageType != null) { @@ -185,28 +169,20 @@ private void onControlMessage(Map data) { (Map) data.get(SERVER_CONTROL_MESSAGE_DATA); onHandshake(handshakeData); } else { - if (logger.logsDebug()) { - logger.debug("Ignoring unknown control message: " + messageType); - } + logger.debug("{} Ignoring unknown control message: {}", label, messageType); } } else { - if (logger.logsDebug()) { - logger.debug("Got invalid control message: " + data.toString()); - } + logger.debug("{} Got invalid control message: {}", label, data); close(); } } catch (ClassCastException e) { - if (logger.logsDebug()) { - logger.debug("Failed to parse control message: " + e.toString()); - } + logger.debug("{} Failed to parse control message", label, e); close(); } } private void onConnectionShutdown(String reason) { - if (logger.logsDebug()) { - logger.debug("Connection shutdown command received. Shutting down..."); - } + logger.debug("{} Connection shutdown command received. Shutting down...", label); delegate.onKill(reason); close(); } @@ -224,21 +200,15 @@ private void onHandshake(Map handshake) { } private void onConnectionReady(long timestamp, String sessionId) { - if (logger.logsDebug()) { - logger.debug("realtime connection established"); - } + logger.debug("{} Realtime connection established", label); state = State.REALTIME_CONNECTED; delegate.onReady(timestamp, sessionId); } private void onReset(String host) { - if (logger.logsDebug()) { - logger.debug( - "Got a reset; killing connection to " - + this.hostInfo.getHost() - + "; Updating internalHost to " - + host); - } + logger.debug( + "{} Got a reset; killing connection to {}; Updating internalHost to {}", + label, hostInfo.getHost(), host); delegate.onCacheHost(host); // Explicitly close the connection with SERVER_RESET so calling code knows to reconnect @@ -248,12 +218,12 @@ private void onReset(String host) { private void sendData(Map data, boolean isSensitive) { if (state != State.REALTIME_CONNECTED) { - logger.debug("Tried to send on an unconnected connection"); + logger.debug("{} Tried to send on an unconnected connection", label); } else { if (isSensitive) { - logger.debug("Sending data (contents hidden)"); + logger.debug("{} Sending data (contents hidden)", label); } else { - logger.debug("Sending data: %s", data); + logger.debug("{} Sending data: {}", label, data); } conn.send(data); } 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 03b4b45f4..574bc1df9 100644 --- a/src/main/java/com/google/firebase/database/connection/ConnectionContext.java +++ b/src/main/java/com/google/firebase/database/connection/ConnectionContext.java @@ -16,8 +16,6 @@ package com.google.firebase.database.connection; -import com.google.firebase.database.logging.Logger; - import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadFactory; @@ -25,21 +23,18 @@ public class ConnectionContext { private final ScheduledExecutorService executorService; private final ConnectionAuthTokenProvider authTokenProvider; - private final Logger logger; private final boolean persistenceEnabled; private final String clientSdkVersion; private final String userAgent; private final ThreadFactory threadFactory; public ConnectionContext( - Logger logger, ConnectionAuthTokenProvider authTokenProvider, ScheduledExecutorService executorService, boolean persistenceEnabled, String clientSdkVersion, String userAgent, ThreadFactory threadFactory) { - this.logger = logger; this.authTokenProvider = authTokenProvider; this.executorService = executorService; this.persistenceEnabled = persistenceEnabled; @@ -48,10 +43,6 @@ public ConnectionContext( this.threadFactory = threadFactory; } - public Logger getLogger() { - return this.logger; - } - public ConnectionAuthTokenProvider getAuthTokenProvider() { return this.authTokenProvider; } 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 40d172722..403a1319f 100644 --- a/src/main/java/com/google/firebase/database/connection/NettyWebSocketClient.java +++ b/src/main/java/com/google/firebase/database/connection/NettyWebSocketClient.java @@ -5,8 +5,7 @@ import static com.google.common.base.Preconditions.checkState; import com.google.common.base.Strings; -import com.google.firebase.internal.GaeThreadFactory; -import com.google.firebase.internal.RevivingScheduledExecutor; +import com.google.firebase.internal.FirebaseScheduledExecutor; import io.netty.bootstrap.Bootstrap; import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; @@ -36,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; /** @@ -58,17 +60,19 @@ 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 RevivingScheduledExecutor( - threadFactory, "firebase-websocket-worker", GaeThreadFactory.isAvailable()); + this.executorService = new FirebaseScheduledExecutor(threadFactory, + "firebase-websocket-worker"); this.group = new NioEventLoopGroup(1, this.executorService); } @@ -76,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 @@ -104,7 +114,7 @@ protected void initChannel(SocketChannel ch) { channelFuture.addListener( new ChannelFutureListener() { @Override - public void operationComplete(ChannelFuture future) throws Exception { + public void operationComplete(ChannelFuture future) { if (!future.isSuccess()) { eventHandler.onError(future.cause()); } @@ -174,7 +184,7 @@ public void channelInactive(ChannelHandlerContext context) { } @Override - public void channelRead0(ChannelHandlerContext context, Object message) throws Exception { + public void channelRead0(ChannelHandlerContext context, Object message) { Channel channel = context.channel(); if (message instanceof FullHttpResponse) { checkState(!handshaker.isHandshakeComplete()); 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 5f2f71303..fc79b4cfe 100644 --- a/src/main/java/com/google/firebase/database/connection/PersistentConnectionImpl.java +++ b/src/main/java/com/google/firebase/database/connection/PersistentConnectionImpl.java @@ -19,7 +19,6 @@ import static com.google.firebase.database.connection.ConnectionUtils.hardAssert; import com.google.firebase.database.connection.util.RetryHelper; -import com.google.firebase.database.logging.LogWrapper; import com.google.firebase.database.util.GAuthToken; import java.util.ArrayList; @@ -33,6 +32,8 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class PersistentConnectionImpl implements Connection.Delegate, PersistentConnection { @@ -91,6 +92,9 @@ public class PersistentConnectionImpl implements Connection.Delegate, Persistent private static final String SERVER_KILL_INTERRUPT_REASON = "server_kill"; private static final String IDLE_INTERRUPT_REASON = "connection_idle"; private static final String TOKEN_REFRESH_INTERRUPT_REASON = "token_refresh"; + + private static final Logger logger = LoggerFactory.getLogger(PersistentConnection.class); + private static long connectionIds = 0; private final Delegate delegate; @@ -99,8 +103,8 @@ public class PersistentConnectionImpl implements Connection.Delegate, Persistent private final ConnectionFactory connFactory; private final ConnectionAuthTokenProvider authTokenProvider; private final ScheduledExecutorService executorService; - private final LogWrapper logger; private final RetryHelper retryHelper; + private final String label; private String cachedHost; private HashSet interruptReasons = new HashSet<>(); @@ -143,7 +147,7 @@ public PersistentConnectionImpl(ConnectionContext context, HostInfo info, Delega this.outstandingPuts = new HashMap<>(); this.onDisconnectRequestQueue = new ArrayList<>(); this.retryHelper = - new RetryHelper.Builder(this.executorService, context.getLogger(), RetryHelper.class) + new RetryHelper.Builder(this.executorService, RetryHelper.class) .withMinDelayAfterFailure(1000) .withRetryExponent(1.3) .withMaxDelay(30 * 1000) @@ -151,7 +155,7 @@ public PersistentConnectionImpl(ConnectionContext context, HostInfo info, Delega .build(); long connId = connectionIds++; - this.logger = new LogWrapper(context.getLogger(), PersistentConnection.class, "pc_" + connId); + this.label = "[pc_" + connId + "]"; this.lastSessionId = null; doIdleCheck(); } @@ -159,9 +163,7 @@ public PersistentConnectionImpl(ConnectionContext context, HostInfo info, Delega // Connection.Delegate methods @Override public void onReady(long timestamp, String sessionId) { - if (logger.logsDebug()) { - logger.debug("onReady"); - } + logger.debug("{} onReady", label); lastConnectionEstablishedTime = System.currentTimeMillis(); handleTimestamp(timestamp); @@ -188,16 +190,12 @@ public void listen( Long tag, RequestResultCallback listener) { ListenQuerySpec query = new ListenQuerySpec(path, queryParams); - if (logger.logsDebug()) { - logger.debug("Listening on " + query); - } + logger.debug("{} Listening on {}", label, query); // TODO: Fix this somehow? //hardAssert(query.isDefault() || !query.loadsAllData(), "listen() called for non-default but " // + "complete query"); hardAssert(!listens.containsKey(query), "listen() called twice for same QuerySpec."); - if (logger.logsDebug()) { - logger.debug("Adding listen query: " + query); - } + logger.debug("{} Adding listen query: {}", label, query); OutstandingListen outstandingListen = new OutstandingListen(listener, query, tag, currentHashFn); listens.put(query, outstandingListen); @@ -277,23 +275,19 @@ public void onDataMessage(Map message) { Map body = (Map) message.get(SERVER_ASYNC_PAYLOAD); onDataPush(action, body); } else { - if (logger.logsDebug()) { - logger.debug("Ignoring unknown message: " + message); - } + logger.debug("{} Ignoring unknown message: {}", label, message); } } @Override public void onDisconnect(Connection.DisconnectReason reason) { - if (logger.logsDebug()) { - logger.debug("Got on disconnect due to " + reason.name()); - } + logger.debug("{} Got on disconnect due to {}", label, reason.name()); this.connectionState = ConnectionState.Disconnected; this.realtime = null; this.hasOnDisconnects = false; requestCBHash.clear(); if (inactivityTimer != null) { - logger.debug("cancelling idle time checker"); + logger.debug("{} Cancelling idle time checker", label); inactivityTimer.cancel(false); inactivityTimer = null; } @@ -319,21 +313,16 @@ public void onDisconnect(Connection.DisconnectReason reason) { @Override public void onKill(String reason) { - if (logger.logsDebug()) { - logger.debug( - "Firebase Database connection was forcefully killed by the server. Will not attempt " - + "reconnect. Reason: " - + reason); - } + logger.debug( + "{} Firebase Database connection was forcefully killed by the server. Will not attempt " + + "reconnect. Reason: {}", label, reason); interrupt(SERVER_KILL_INTERRUPT_REASON); } @Override public void unlisten(List path, Map queryParams) { ListenQuerySpec query = new ListenQuerySpec(path, queryParams); - if (logger.logsDebug()) { - logger.debug("unlistening on " + query); - } + logger.debug("{} Unlistening on {}", label, query); // TODO: fix this by understanding query params? //Utilities.hardAssert(query.isDefault() || !query.loadsAllData(), @@ -395,9 +384,7 @@ public void onDisconnectCancel(List path, RequestResultCallback onComple @Override public void interrupt(String reason) { - if (logger.logsDebug()) { - logger.debug("Connection interrupted for: " + reason); - } + logger.debug("{} Connection interrupted for: {}", label, reason); interruptReasons.add(reason); if (realtime != null) { @@ -414,10 +401,7 @@ public void interrupt(String reason) { @Override public void resume(String reason) { - if (logger.logsDebug()) { - logger.debug("Connection no longer interrupted for: " + reason); - } - + logger.debug("{} Connection no longer interrupted for: {}", label, reason); interruptReasons.remove(reason); if (shouldReconnect() && connectionState == ConnectionState.Disconnected) { @@ -444,7 +428,7 @@ public void refreshAuthToken() { // we close the connection to make sure any writes/listens are queued until the connection // is reauthed with the current token after reconnecting. Note that this will trigger // onDisconnects which isn't ideal. - logger.debug("Auth token refresh requested"); + logger.debug("{} Auth token refresh requested", label); // By using interrupt instead of closing the connection we make sure there are no race // conditions with other fetch token attempts (interrupt/resume is expected to handle those @@ -455,7 +439,7 @@ public void refreshAuthToken() { @Override public void refreshAuthToken(String token) { - logger.debug("Auth token refreshed."); + logger.debug("{} Auth token refreshed.", label); this.authToken = token; if (connected()) { if (token != null) { @@ -473,13 +457,13 @@ private void tryScheduleReconnect() { "Not in disconnected state: %s", this.connectionState); final boolean forceRefresh = this.forceAuthTokenRefresh; - logger.debug("Scheduling connection attempt"); + logger.debug("{} Scheduling connection attempt", label); this.forceAuthTokenRefresh = false; retryHelper.retry( new Runnable() { @Override public void run() { - logger.debug("Trying to fetch auth token"); + logger.debug("{} Trying to fetch auth token", label); hardAssert( connectionState == ConnectionState.Disconnected, "Not in disconnected state: %s", @@ -496,7 +480,7 @@ public void onSuccess(String token) { // Someone could have interrupted us while fetching the token, // marking the connection as Disconnected if (connectionState == ConnectionState.GettingToken) { - logger.debug("Successfully fetched token, opening connection"); + logger.debug("{} Successfully fetched token, opening connection", label); openNetworkConnection(token); } else { hardAssert( @@ -504,13 +488,13 @@ public void onSuccess(String token) { "Expected connection state disconnected, but was %s", connectionState); logger.debug( - "Not opening connection after token refresh, " - + "because connection was set to disconnected"); + "{} Not opening connection after token refresh, because connection " + + "was set to disconnected", label); } } else { logger.debug( - "Ignoring getToken result, because this was not the " - + "latest attempt."); + "{} Ignoring getToken result, because this was not the " + + "latest attempt.", label); } } @@ -518,12 +502,12 @@ public void onSuccess(String token) { public void onError(String error) { if (thisGetTokenAttempt == currentGetTokenAttempt) { connectionState = ConnectionState.Disconnected; - logger.debug("Error fetching token: " + error); + logger.debug("{} Error fetching token: {}", label, error); tryScheduleReconnect(); } else { logger.debug( - "Ignoring getToken error, because this was not the " - + "latest attempt."); + "{} Ignoring getToken error, because this was not the " + + "latest attempt.", label); } } }); @@ -609,14 +593,10 @@ private void sendUnlisten(OutstandingListen listen) { } private OutstandingListen removeListen(ListenQuerySpec query) { - if (logger.logsDebug()) { - logger.debug("removing query " + query); - } + logger.debug("{} removing query {}", label, query); if (!listens.containsKey(query)) { - if (logger.logsDebug()) { - logger.debug( - "Trying to remove listener for QuerySpec " + query + " but no listener exists."); - } + logger.debug( + "{} Trying to remove listener for QuerySpec {} but no listener exists.", label, query); return null; } else { OutstandingListen oldListen = listens.get(query); @@ -627,9 +607,7 @@ private OutstandingListen removeListen(ListenQuerySpec query) { } private Collection removeListens(List path) { - if (logger.logsDebug()) { - logger.debug("removing all listens at path " + path); - } + logger.debug("{} Removing all listens at path {}", label, path); List removedListens = new ArrayList<>(); for (Map.Entry entry : listens.entrySet()) { ListenQuerySpec query = entry.getKey(); @@ -649,9 +627,7 @@ private Collection removeListens(List path) { } private void onDataPush(String action, Map body) { - if (logger.logsDebug()) { - logger.debug("handleServerMessage: " + action + " " + body); - } + logger.debug("{} handleServerMessage: {} {}", label, action, body); if (action.equals(SERVER_ASYNC_DATA_UPDATE) || action.equals(SERVER_ASYNC_DATA_MERGE)) { boolean isMerge = action.equals(SERVER_ASYNC_DATA_MERGE); @@ -660,9 +636,7 @@ private void onDataPush(String action, Map body) { Long tagNumber = ConnectionUtils.longFromObject(body.get(SERVER_DATA_TAG)); // ignore empty merges if (isMerge && (payloadData instanceof Map) && ((Map) payloadData).size() == 0) { - if (logger.logsDebug()) { - logger.debug("ignoring empty merge for path " + pathString); - } + logger.debug("{} Ignoring empty merge for path {}", label, pathString); } else { List path = ConnectionUtils.stringToPath(pathString); delegate.onDataUpdate(path, payloadData, isMerge, tagNumber); @@ -684,9 +658,7 @@ private void onDataPush(String action, Map body) { rangeMerges.add(new RangeMerge(start, end, update)); } if (rangeMerges.isEmpty()) { - if (logger.logsDebug()) { - logger.debug("Ignoring empty range merge for path " + pathString); - } + logger.debug("{} Ignoring empty range merge for path {}", label, pathString); } else { this.delegate.onRangeMergeUpdate(path, rangeMerges, tag); } @@ -701,9 +673,7 @@ private void onDataPush(String action, Map body) { } else if (action.equals(SERVER_ASYNC_SECURITY_DEBUG)) { onSecurityDebugPacket(body); } else { - if (logger.logsDebug()) { - logger.debug("Unrecognized action from server: " + action); - } + logger.debug("{} Unrecognized action from server: {}", label, action); } } @@ -723,7 +693,7 @@ private void onAuthRevoked(String errorCode, String errorMessage) { // This might be for an earlier token than we just recently sent. But since we need to close // the connection anyways, we can set it to null here and we will refresh the token later // on reconnect. - logger.debug("Auth token revoked: " + errorCode + " (" + errorMessage + ")"); + logger.debug("{} Auth token revoked: {} ({})", label, errorCode, errorMessage); this.authToken = null; this.forceAuthTokenRefresh = true; this.delegate.onAuthStatus(false); @@ -733,7 +703,7 @@ private void onAuthRevoked(String errorCode, String errorMessage) { private void onSecurityDebugPacket(Map message) { // TODO: implement on iOS too - logger.info((String) message.get("msg")); + logger.info("{} {}", label, message.get("msg")); } private void upgradeAuth() { @@ -766,7 +736,7 @@ public void onResponse(Map response) { forceAuthTokenRefresh = true; delegate.onAuthStatus(false); String reason = (String) response.get(SERVER_RESPONSE_DATA); - logger.debug("Authentication failed: " + status + " (" + reason + ")"); + logger.debug("{} Authentication failed: {} ({})", label, status, reason); realtime.close(); if (status.equals("invalid_token") || status.equals("permission_denied")) { @@ -778,11 +748,11 @@ public void onResponse(Map response) { // Set a long reconnect delay because recovery is unlikely. retryHelper.setMaxDelay(); logger.warn( - "Provided authentication credentials are invalid. This " + "{} Provided authentication credentials are invalid. This " + "usually indicates your FirebaseApp instance was not initialized " + "correctly. Make sure your database URL is correct and that your " + "service account is for the correct project and is authorized to " - + "access it."); + + "access it.", label); } } } @@ -814,9 +784,7 @@ private void sendUnauth() { } private void restoreAuth() { - if (logger.logsDebug()) { - logger.debug("calling restore state"); - } + logger.debug("{} Calling restore state", label); hardAssert( this.connectionState == ConnectionState.Connecting, @@ -824,15 +792,11 @@ private void restoreAuth() { this.connectionState); if (authToken == null) { - if (logger.logsDebug()) { - logger.debug("Not restoring auth because token is null."); - } + logger.debug("{} Not restoring auth because token is null.", label); this.connectionState = ConnectionState.Connected; restoreState(); } else { - if (logger.logsDebug()) { - logger.debug("Restoring auth."); - } + logger.debug("{} Restoring auth.", label); this.connectionState = ConnectionState.Authenticating; sendAuthAndRestoreState(); } @@ -845,19 +809,13 @@ private void restoreState() { this.connectionState); // Restore listens - if (logger.logsDebug()) { - logger.debug("Restoring outstanding listens"); - } + logger.debug("{} Restoring outstanding listens", label); for (OutstandingListen listen : listens.values()) { - if (logger.logsDebug()) { - logger.debug("Restoring listen " + listen.getQuery()); - } + logger.debug("{} Restoring listen {}", label, listen.getQuery()); sendListen(listen); } - if (logger.logsDebug()) { - logger.debug("Restoring writes."); - } + logger.debug("{} Restoring writes.", label); // Restore puts ArrayList outstanding = new ArrayList<>(outstandingPuts.keySet()); // Make sure puts are restored in order @@ -878,9 +836,7 @@ private void restoreState() { } private void handleTimestamp(long timestamp) { - if (logger.logsDebug()) { - logger.debug("handling timestamp"); - } + logger.debug("{} Handling timestamp", label); long timestampDelta = timestamp - System.currentTimeMillis(); Map updates = new HashMap<>(); updates.put(Constants.DOT_INFO_SERVERTIME_OFFSET, timestampDelta); @@ -930,9 +886,7 @@ assert canSendWrites() new ConnectionRequestCallback() { @Override public void onResponse(Map response) { - if (logger.logsDebug()) { - logger.debug(action + " response: " + response); - } + logger.debug("{} {} response: {}", label, action, response); OutstandingPut currentPut = outstandingPuts.get(putId); if (currentPut == put) { @@ -948,10 +902,8 @@ public void onResponse(Map response) { } } } else { - if (logger.logsDebug()) { - logger.debug( - "Ignoring on complete for put " + putId + " because it was removed already."); - } + logger.debug("{} Ignoring on complete for put {} because it was removed already.", + label, putId); } doIdleCheck(); } @@ -1032,17 +984,13 @@ public void onResponse(Map response) { String status = (String) response.get(REQUEST_STATUS); if (!status.equals("ok")) { String errorMessage = (String) response.get(SERVER_DATA_UPDATE_BODY); - if (logger.logsDebug()) { - logger.debug( - "Failed to send stats: " + status + " (message: " + errorMessage + ")"); - } + logger.debug( + "{} Failed to send stats: {} (message: {})", label, stats, errorMessage); } } }); } else { - if (logger.logsDebug()) { - logger.debug("Not sending stats because stats are empty"); - } + logger.debug("{} Not sending stats because stats are empty", label); } } @@ -1051,11 +999,9 @@ private void warnOnListenerWarnings(List warnings, ListenQuerySpec query if (warnings.contains("no_index")) { String indexSpec = "\".indexOn\": \"" + query.queryParams.get("i") + '\"'; logger.warn( - "Using an unspecified index. Consider adding '" - + indexSpec - + "' at " - + ConnectionUtils.pathToString(query.path) - + " to your security and Firebase Database rules for better performance"); + "{} Using an unspecified index. Consider adding '{}' at {} to your security and " + + "Firebase Database rules for better performance", + label, indexSpec, ConnectionUtils.pathToString(query.path)); } } @@ -1064,9 +1010,7 @@ private void sendConnectStats() { assert !this.context.isPersistenceEnabled() : "Stats for persistence on JVM missing (persistence not yet supported)"; stats.put("sdk.admin_java." + context.getClientSdkVersion().replace('.', '-'), 1); - if (logger.logsDebug()) { - logger.debug("Sending first connection stats"); - } + logger.debug("{} Sending first connection stats", label); sendStats(stats); } 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 9b28a19dd..a421b90d4 100644 --- a/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java +++ b/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java @@ -20,7 +20,6 @@ import static com.google.common.base.Preconditions.checkState; import com.google.common.collect.ImmutableList; -import com.google.firebase.database.logging.LogWrapper; import com.google.firebase.database.util.JsonMapper; import java.io.EOFException; @@ -33,6 +32,8 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Represents a WebSocket connection to the Firebase Realtime Database. This abstraction acts as @@ -47,11 +48,12 @@ class WebsocketConnection { private static final long CONNECT_TIMEOUT_MS = 30 * 1000; // 30 seconds private static final int MAX_FRAME_SIZE = 16384; private static final AtomicLong CONN_ID = new AtomicLong(0); + private static final Logger logger = LoggerFactory.getLogger(WebsocketConnection.class); private final ScheduledExecutorService executorService; - private final LogWrapper logger; private final WSClient conn; private final Delegate delegate; + private final String label; private StringList buffer; private boolean everConnected = false; @@ -75,8 +77,7 @@ class WebsocketConnection { WSClientFactory clientFactory) { this.executorService = connectionContext.getExecutorService(); this.delegate = delegate; - this.logger = new LogWrapper(connectionContext.getLogger(), WebsocketConnection.class, - "ws_" + CONN_ID.getAndIncrement()); + this.label = "[ws_" + CONN_ID.getAndIncrement() + "]"; this.conn = clientFactory.newClient(new WSClientHandlerImpl()); } @@ -99,9 +100,7 @@ void start() { } void close() { - if (logger.logsDebug()) { - logger.debug("websocket is being closed"); - } + logger.debug("{} Websocket is being closed", label); isClosed = true; conn.close(); @@ -130,7 +129,7 @@ void send(Map message) { conn.send(seg); } } catch (IOException e) { - logger.error("Failed to serialize message: " + message.toString(), e); + logger.error("{} Failed to serialize message: {}", label, message, e); closeAndNotify(); } } @@ -150,9 +149,7 @@ private List splitIntoFrames(String src, int maxFrameSize) { } private void handleNewFrameCount(int numFrames) { - if (logger.logsDebug()) { - logger.debug("HandleNewFrameCount: " + numFrames); - } + logger.debug("{} Handle new frame count: {}", label, numFrames); buffer = new StringList(numFrames); } @@ -165,15 +162,13 @@ private void appendFrame(String message) { String combined = buffer.combine(); try { Map decoded = JsonMapper.parseJson(combined); - if (logger.logsDebug()) { - logger.debug("handleIncomingFrame complete frame: " + decoded); - } + logger.debug("{} Parsed complete frame: {}", label, decoded); delegate.onMessage(decoded); } catch (IOException e) { - logger.error("Error parsing frame: " + combined, e); + logger.error("{} Error parsing frame: {}", label, combined, e); closeAndNotify(); } catch (ClassCastException e) { - logger.error("Error parsing frame (cast error): " + combined, e); + logger.error("{} Error parsing frame (cast error): {}", label, combined, e); closeAndNotify(); } } @@ -218,13 +213,10 @@ private void resetKeepAlive() { } if (keepAlive != null) { keepAlive.cancel(false); - if (logger.logsDebug()) { - logger.debug("Reset keepAlive. Remaining: " + keepAlive.getDelay(TimeUnit.MILLISECONDS)); - } + logger.debug("{} Reset keepAlive. Remaining: {}", label, + keepAlive.getDelay(TimeUnit.MILLISECONDS)); } else { - if (logger.logsDebug()) { - logger.debug("Reset keepAlive"); - } + logger.debug("{} Reset keepAlive", label); } keepAlive = executorService.schedule(nop(), KEEP_ALIVE_TIMEOUT_MS, TimeUnit.MILLISECONDS); } @@ -253,18 +245,14 @@ private void closeAndNotify() { private void onClosed() { if (!isClosed) { - if (logger.logsDebug()) { - logger.debug("closing itself"); - } + logger.debug("{} Closing itself", label); closeAndNotify(); } } private void closeIfNeverConnected() { if (!everConnected && !isClosed) { - if (logger.logsDebug()) { - logger.debug("timed out on connect"); - } + logger.debug("{} Timed out on connect", label); closeAndNotify(); } } @@ -278,9 +266,7 @@ private class WSClientHandlerImpl implements WSClientEventHandler { @Override public void onOpen() { - if (logger.logsDebug()) { - logger.debug("websocket opened"); - } + logger.debug("{} Websocket opened", label); executorService.execute(new Runnable() { @Override public void run() { @@ -293,9 +279,7 @@ public void run() { @Override public void onMessage(final String message) { - if (logger.logsDebug()) { - logger.debug("ws message: " + message); - } + logger.debug("{} WS message: {}", label, message); executorService.execute(new Runnable() { @Override public void run() { @@ -306,9 +290,7 @@ public void run() { @Override public void onClose() { - if (logger.logsDebug()) { - logger.debug("closed"); - } + logger.debug("{} Closed", label); if (!isClosed) { // If the connection tear down was initiated by the higher-layer, isClosed will already // be true. Nothing more to do in that case. @@ -325,9 +307,9 @@ public void run() { @Override public void onError(final Throwable e) { if (e instanceof EOFException || e.getCause() instanceof EOFException) { - logger.debug("WebSocket reached EOF", e); + logger.debug("{} WebSocket reached EOF", label, e); } else { - logger.error("WebSocket error", e); + logger.error("{} WebSocket error", label, e); } executorService.execute( new Runnable() { @@ -431,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/util/RetryHelper.java b/src/main/java/com/google/firebase/database/connection/util/RetryHelper.java index 2563a30ca..f8f6bb77a 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 @@ -16,18 +16,18 @@ package com.google.firebase.database.connection.util; -import com.google.firebase.database.logging.LogWrapper; -import com.google.firebase.database.logging.Logger; - import java.util.Random; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class RetryHelper { + private static final Logger logger = LoggerFactory.getLogger(RetryHelper.class); + private final ScheduledExecutorService executorService; - private final LogWrapper logger; /** The minimum delay for a retry in ms. */ private final long minRetryDelayAfterFailure; /** The maximum retry delay in ms. */ @@ -49,13 +49,11 @@ public class RetryHelper { private RetryHelper( ScheduledExecutorService executorService, - LogWrapper logger, long minRetryDelayAfterFailure, long maxRetryDelay, double retryExponent, double jitterFactor) { this.executorService = executorService; - this.logger = logger; this.minRetryDelayAfterFailure = minRetryDelayAfterFailure; this.maxRetryDelay = maxRetryDelay; this.retryExponent = retryExponent; @@ -84,7 +82,7 @@ public void retry(final Runnable runnable) { + (jitterFactor * currentRetryDelay * random.nextDouble())); } this.lastWasSuccess = false; - logger.debug("Scheduling retry in %dms", delay); + logger.debug("Scheduling retry in {}ms", delay); Runnable wrapped = new Runnable() { @Override @@ -119,15 +117,13 @@ public void cancel() { public static class Builder { private final ScheduledExecutorService service; - private final LogWrapper logger; private long minRetryDelayAfterFailure = 1000; private double jitterFactor = 0.5; private long retryMaxDelay = 30 * 1000; private double retryExponent = 1.3; - public Builder(ScheduledExecutorService service, Logger logger, Class tag) { + public Builder(ScheduledExecutorService service, Class tag) { this.service = service; - this.logger = new LogWrapper(logger, tag); } public Builder withMinDelayAfterFailure(long delay) { @@ -156,7 +152,6 @@ public Builder withJitterFactor(double random) { public RetryHelper build() { return new RetryHelper( this.service, - this.logger, this.minRetryDelayAfterFailure, this.retryMaxDelay, this.retryExponent, 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 52c07b8c6..a0025f997 100644 --- a/src/main/java/com/google/firebase/database/core/Context.java +++ b/src/main/java/com/google/firebase/database/core/Context.java @@ -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; @@ -26,29 +27,24 @@ import com.google.firebase.database.connection.PersistentConnection; import com.google.firebase.database.core.persistence.NoopPersistenceManager; import com.google.firebase.database.core.persistence.PersistenceManager; -import com.google.firebase.database.logging.LogWrapper; -import com.google.firebase.database.logging.Logger; import com.google.firebase.database.utilities.DefaultRunLoop; -import java.util.List; import java.util.concurrent.ScheduledExecutorService; public class Context { private static final long DEFAULT_CACHE_SIZE = 10 * 1024 * 1024; - protected Logger logger; - protected EventTarget eventTarget; - protected AuthTokenProvider authTokenProvider; - protected RunLoop runLoop; - protected String persistenceKey; - protected List loggedComponents; - protected String userAgent; - protected Logger.Level logLevel = Logger.Level.INFO; - protected boolean persistenceEnabled; - protected long cacheSize = DEFAULT_CACHE_SIZE; - protected FirebaseApp firebaseApp; - private PersistenceManager forcedPersistenceManager; + FirebaseApp firebaseApp; + + EventTarget eventTarget; + AuthTokenProvider authTokenProvider; + RunLoop runLoop; + String persistenceKey; + boolean persistenceEnabled; + long cacheSize = DEFAULT_CACHE_SIZE; + + private String userAgent; private boolean frozen = false; private boolean stopped = false; @@ -78,19 +74,11 @@ public void onError(String error) { private Platform getPlatform() { if (platform == null) { - if (GaePlatform.isActive()) { - platform = new GaePlatform(firebaseApp); - } else { - platform = new JvmPlatform(firebaseApp); - } + platform = new JvmPlatform(firebaseApp); } return platform; } - public boolean isFrozen() { - return frozen; - } - public boolean isStopped() { return stopped; } @@ -110,8 +98,6 @@ public void requireStarted() { } private void initServices() { - // Do the logger first, so that other components can get a LogWrapper - ensureLogger(); // Cache platform getPlatform(); ensureUserAgent(); @@ -137,28 +123,15 @@ void stop() { } } - protected void assertUnfrozen() { - if (isFrozen()) { + void assertUnfrozen() { + if (frozen) { throw new DatabaseException( "Modifications to DatabaseConfig objects must occur before they are in use"); } } - public LogWrapper getLogger(String component) { - return new LogWrapper(logger, component, null); - } - - public LogWrapper getLogger(Class component) { - return new LogWrapper(logger, component); - } - - public LogWrapper getLogger(Class component, String prefix) { - return new LogWrapper(logger, component, prefix); - } - public ConnectionContext getConnectionContext() { return new ConnectionContext( - this.logger, wrapAuthTokenProvider(this.getAuthTokenProvider()), this.getExecutorService(), this.isPersistenceEnabled(), @@ -168,10 +141,6 @@ public ConnectionContext getConnectionContext() { } PersistenceManager getPersistenceManager(String firebaseId) { - // TODO[persistence]: Create this once and store it. - if (forcedPersistenceManager != null) { - return forcedPersistenceManager; - } if (this.persistenceEnabled) { PersistenceManager cache = platform.createPersistenceManager(this, firebaseId); if (cache == null) { @@ -193,11 +162,6 @@ public long getPersistenceCacheSizeBytes() { return this.cacheSize; } - // For testing - void forcePersistenceManager(PersistenceManager persistenceManager) { - this.forcedPersistenceManager = persistenceManager; - } - public EventTarget getEventTarget() { return eventTarget; } @@ -210,14 +174,6 @@ public String getUserAgent() { return userAgent; } - public String getPlatformVersion() { - return getPlatform().getPlatformVersion(); - } - - public String getSessionPersistenceKey() { - return this.persistenceKey; - } - public AuthTokenProvider getAuthTokenProvider() { return this.authTokenProvider; } @@ -237,12 +193,6 @@ private ScheduledExecutorService getExecutorService() { return ((DefaultRunLoop) loop).getExecutorService(); } - private void ensureLogger() { - if (logger == null) { - logger = getPlatform().newLogger(this, logLevel, loggedComponents); - } - } - private void ensureRunLoop() { if (runLoop == null) { runLoop = platform.newRunLoop(this); @@ -284,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 491098d6a..844f59266 100644 --- a/src/main/java/com/google/firebase/database/core/DatabaseConfig.java +++ b/src/main/java/com/google/firebase/database/core/DatabaseConfig.java @@ -18,9 +18,6 @@ import com.google.firebase.FirebaseApp; import com.google.firebase.database.DatabaseException; -import com.google.firebase.database.Logger; - -import java.util.List; /** * TODO: Since this is no longer public, we should merge it with Context and clean all @@ -29,21 +26,6 @@ */ public class DatabaseConfig extends Context { - // TODO: Remove this from the public API since we currently can't pass logging - // across AIDL interface. - - /** - * If you would like to provide a custom log target, pass an object that implements the {@link - * com.google.firebase.database.Logger Logger} interface. - * - * @hide - * @param logger The custom logger that will be called with all log messages - */ - public synchronized void setLogger(com.google.firebase.database.logging.Logger logger) { - assertUnfrozen(); - this.logger = logger; - } - /** * In the default setup, the Firebase Database library will create a thread to handle all * callbacks. On Android, it will attempt to use the main debugComponents) { - assertUnfrozen(); - setLogLevel(Logger.Level.DEBUG); - loggedComponents = debugComponents; - } - public void setRunLoop(RunLoop runLoop) { this.runLoop = runLoop; } diff --git a/src/main/java/com/google/firebase/database/core/GaePlatform.java b/src/main/java/com/google/firebase/database/core/GaePlatform.java deleted file mode 100644 index b7698f3de..000000000 --- a/src/main/java/com/google/firebase/database/core/GaePlatform.java +++ /dev/null @@ -1,131 +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.database.core; - -import com.google.firebase.FirebaseApp; -import com.google.firebase.ImplFirebaseTrampolines; -import com.google.firebase.database.FirebaseDatabase; -import com.google.firebase.database.connection.ConnectionContext; -import com.google.firebase.database.connection.HostInfo; -import com.google.firebase.database.connection.PersistentConnection; -import com.google.firebase.database.connection.PersistentConnectionImpl; -import com.google.firebase.database.core.persistence.PersistenceManager; -import com.google.firebase.database.logging.DefaultLogger; -import com.google.firebase.database.logging.LogWrapper; -import com.google.firebase.database.logging.Logger; -import com.google.firebase.database.utilities.DefaultRunLoop; -import com.google.firebase.internal.GaeThreadFactory; -import com.google.firebase.internal.RevivingScheduledExecutor; - -import java.util.List; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ThreadFactory; - -/** - * Represents a Google AppEngine platform. - * - *

    This class is not thread-safe. - */ -class GaePlatform implements Platform { - - private static final String PROCESS_PLATFORM = "AppEngine"; - private final FirebaseApp firebaseApp; - - public GaePlatform(FirebaseApp firebaseApp) { - this.firebaseApp = firebaseApp; - } - - public static boolean isActive() { - return GaeThreadFactory.isAvailable(); - } - - @Override - public Logger newLogger(Context ctx, Logger.Level level, List components) { - return new DefaultLogger(level, components); - } - - private ThreadFactory getGaeThreadFactory() { - return ImplFirebaseTrampolines.getThreadFactory(firebaseApp); - } - - @Override - public EventTarget newEventTarget(Context ctx) { - RevivingScheduledExecutor eventExecutor = - new RevivingScheduledExecutor(getGaeThreadFactory(), "FirebaseDatabaseEventTarget", true); - return new ThreadPoolEventTarget(eventExecutor); - } - - @Override - public RunLoop newRunLoop(final Context context) { - final LogWrapper logger = context.getLogger(RunLoop.class); - return new DefaultRunLoop(getGaeThreadFactory(), /* periodicRestart= */ true, context) { - @Override - public void handleException(Throwable e) { - logger.error(DefaultRunLoop.messageForException(e), e); - } - }; - } - - @Override - public AuthTokenProvider newAuthTokenProvider(ScheduledExecutorService executorService) { - return new JvmAuthTokenProvider(this.firebaseApp, executorService); - } - - @Override - public PersistentConnection newPersistentConnection( - Context context, - ConnectionContext connectionContext, - HostInfo info, - PersistentConnection.Delegate delegate) { - return new PersistentConnectionImpl(context.getConnectionContext(), info, delegate); - } - - @Override - public String getUserAgent(Context ctx) { - return PROCESS_PLATFORM + "/" + DEVICE; - } - - @Override - public String getPlatformVersion() { - return "gae-" + FirebaseDatabase.getSdkVersion(); - } - - @Override - public PersistenceManager createPersistenceManager(Context ctx, String namespace) { - return null; - } - - @Override - public ThreadInitializer getThreadInitializer() { - return new ThreadInitializer() { - @Override - public void setName(Thread t, String name) { - // Unsupported by GAE - } - - @Override - public void setDaemon(Thread t, boolean isDaemon) { - // Unsupported by GAE - } - - @Override - public void setUncaughtExceptionHandler(Thread t, Thread.UncaughtExceptionHandler handler) { - // Unsupported by GAE - } - }; - } -} 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..9b862ba86 100644 --- a/src/main/java/com/google/firebase/database/core/JvmAuthTokenProvider.java +++ b/src/main/java/com/google/firebase/database/core/JvmAuthTokenProvider.java @@ -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 30a2e25df..2a226c078 100644 --- a/src/main/java/com/google/firebase/database/core/JvmPlatform.java +++ b/src/main/java/com/google/firebase/database/core/JvmPlatform.java @@ -24,14 +24,12 @@ import com.google.firebase.database.connection.PersistentConnection; import com.google.firebase.database.connection.PersistentConnectionImpl; import com.google.firebase.database.core.persistence.PersistenceManager; -import com.google.firebase.database.logging.DefaultLogger; -import com.google.firebase.database.logging.LogWrapper; -import com.google.firebase.database.logging.Logger; import com.google.firebase.database.utilities.DefaultRunLoop; -import java.util.List; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; class JvmPlatform implements Platform { @@ -39,24 +37,19 @@ class JvmPlatform implements Platform { private final FirebaseApp firebaseApp; - public JvmPlatform(FirebaseApp firebaseApp) { + JvmPlatform(FirebaseApp firebaseApp) { this.firebaseApp = firebaseApp; } - @Override - public Logger newLogger(Context ctx, Logger.Level level, List components) { - return new DefaultLogger(level, components); - } - @Override public EventTarget newEventTarget(Context ctx) { ThreadFactory threadFactory = ImplFirebaseTrampolines.getThreadFactory(firebaseApp); - return new ThreadPoolEventTarget(threadFactory, ThreadInitializer.defaultInstance); + return new ThreadPoolEventTarget(threadFactory); } @Override public RunLoop newRunLoop(final Context context) { - final LogWrapper logger = context.getLogger(RunLoop.class); + final Logger logger = LoggerFactory.getLogger(RunLoop.class); ThreadFactory threadFactory = ImplFirebaseTrampolines.getThreadFactory(firebaseApp); return new DefaultRunLoop(threadFactory) { @Override @@ -94,9 +87,4 @@ public String getPlatformVersion() { public PersistenceManager createPersistenceManager(Context ctx, String namespace) { return null; } - - @Override - public ThreadInitializer getThreadInitializer() { - return ThreadInitializer.defaultInstance; - } } 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 5e701c408..2d2742a62 100644 --- a/src/main/java/com/google/firebase/database/core/Platform.java +++ b/src/main/java/com/google/firebase/database/core/Platform.java @@ -20,17 +20,13 @@ import com.google.firebase.database.connection.HostInfo; import com.google.firebase.database.connection.PersistentConnection; import com.google.firebase.database.core.persistence.PersistenceManager; -import com.google.firebase.database.logging.Logger; -import java.util.List; import java.util.concurrent.ScheduledExecutorService; public interface Platform { String DEVICE = "AdminJava"; - Logger newLogger(Context ctx, Logger.Level level, List components); - EventTarget newEventTarget(Context ctx); RunLoop newRunLoop(Context ctx); @@ -48,6 +44,4 @@ PersistentConnection newPersistentConnection( String getPlatformVersion(); PersistenceManager createPersistenceManager(Context ctx, String firebaseId); - - ThreadInitializer getThreadInitializer(); } 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 add6ff3e0..98777d04d 100644 --- a/src/main/java/com/google/firebase/database/core/Repo.java +++ b/src/main/java/com/google/firebase/database/core/Repo.java @@ -38,7 +38,6 @@ import com.google.firebase.database.core.view.Event; import com.google.firebase.database.core.view.EventRaiser; import com.google.firebase.database.core.view.QuerySpec; -import com.google.firebase.database.logging.LogWrapper; import com.google.firebase.database.snapshot.ChildKey; import com.google.firebase.database.snapshot.EmptyNode; import com.google.firebase.database.snapshot.IndexedNode; @@ -54,6 +53,8 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class Repo implements PersistentConnection.Delegate { @@ -67,14 +68,14 @@ public class Repo implements PersistentConnection.Delegate { private static final String TRANSACTION_TOO_MANY_RETRIES = "maxretries"; private static final String TRANSACTION_OVERRIDE_BY_SET = "overriddenBySet"; + + private static final Logger logger = LoggerFactory.getLogger(Repo.class); + private final RepoInfo repoInfo; private final OffsetClock serverClock = new OffsetClock(new DefaultClock(), 0); private final PersistentConnection connection; private final EventRaiser eventRaiser; private final Context ctx; - private final LogWrapper operationLogger; - private final LogWrapper transactionLogger; - private final LogWrapper dataLogger; private SnapshotHolder infoData; private SparseSnapshotTree onDisconnect; private Tree> transactionQueueTree; @@ -90,11 +91,6 @@ public class Repo implements PersistentConnection.Delegate { this.repoInfo = repoInfo; this.ctx = ctx; this.database = database; - - operationLogger = this.ctx.getLogger(Repo.class); - transactionLogger = this.ctx.getLogger(Repo.class.getName() + ".Transaction"); - dataLogger = this.ctx.getLogger(Repo.class.getName() + ".DataOperation"); - this.eventRaiser = new EventRaiser(this.ctx); HostInfo hostInfo = new HostInfo(repoInfo.host, repoInfo.namespace, repoInfo.secure); @@ -134,7 +130,7 @@ private void deferredInitialization() { new AuthTokenProvider.TokenChangeListener() { @Override public void onTokenChange(String token) { - operationLogger.debug("Auth token changed, triggering auth token refresh"); + logger.debug("Auth token changed, triggering auth token refresh"); connection.refreshAuthToken(token); } }); @@ -150,7 +146,7 @@ public void onTokenChange(String token) { transactionQueueTree = new Tree<>(); - infoSyncTree = new SyncTree(ctx, new NoopPersistenceManager(), + infoSyncTree = new SyncTree(new NoopPersistenceManager(), new SyncTree.ListenProvider() { @Override public void startListening( @@ -178,7 +174,7 @@ public void run() { public void stopListening(QuerySpec query, Tag tag) {} }); - serverSyncTree = new SyncTree(ctx, persistenceManager, + serverSyncTree = new SyncTree(persistenceManager, new SyncTree.ListenProvider() { @Override public void startListening( @@ -263,12 +259,8 @@ boolean hasListeners() { public void onDataUpdate( List pathSegments, Object message, boolean isMerge, Long optTag) { Path path = new Path(pathSegments); - if (operationLogger.logsDebug()) { - operationLogger.debug("onDataUpdate: " + path); - } - if (dataLogger.logsDebug()) { - operationLogger.debug("onDataUpdate: " + path + " " + message); - } + logger.debug("onDataUpdate: {} {}", path, message); + List events; try { @@ -306,7 +298,7 @@ public void onDataUpdate( postEvents(events); } catch (DatabaseException e) { - operationLogger.error("FIREBASE INTERNAL ERROR", e); + logger.error("Firebase internal error", e); } } @@ -316,12 +308,7 @@ public void onRangeMergeUpdate( List merges, Long tagNumber) { Path path = new Path(pathSegments); - if (operationLogger.logsDebug()) { - operationLogger.debug("onRangeMergeUpdate: " + path); - } - if (dataLogger.logsDebug()) { - operationLogger.debug("onRangeMergeUpdate: " + path + " " + merges); - } + logger.debug("onRangeMergeUpdate: {} {}", path, merges); List parsedMerges = new ArrayList<>(merges.size()); for (com.google.firebase.database.connection.RangeMerge merge : merges) { @@ -383,12 +370,7 @@ public void setValue( final Path path, Node newValueUnresolved, final DatabaseReference.CompletionListener onComplete) { - if (operationLogger.logsDebug()) { - operationLogger.debug("set: " + path); - } - if (dataLogger.logsDebug()) { - dataLogger.debug("set: " + path + " " + newValueUnresolved); - } + logger.debug("set: {} {}", path, newValueUnresolved); Map serverValues = ServerValues.generateServerValues(serverClock); Node newValue = ServerValues.resolveDeferredValueSnapshot(newValueUnresolved, serverValues); @@ -421,16 +403,9 @@ public void updateChildren( CompoundWrite updates, final DatabaseReference.CompletionListener onComplete, Map unParsedUpdates) { - if (operationLogger.logsDebug()) { - operationLogger.debug("update: " + path); - } - if (dataLogger.logsDebug()) { - dataLogger.debug("update: " + path + " " + unParsedUpdates); - } + logger.debug("update: {} {}", path, unParsedUpdates); if (updates.isEmpty()) { - if (operationLogger.logsDebug()) { - operationLogger.debug("update called with no changes. No-op"); - } + logger.debug("update called with no changes. No-op"); // dispatch on complete callOnComplete(onComplete, null, path); return; @@ -468,9 +443,7 @@ public void onRequestResult(String optErrorCode, String optErrorMessage) { } public void purgeOutstandingWrites() { - if (operationLogger.logsDebug()) { - operationLogger.debug("Purging writes"); - } + logger.debug("Purging writes"); List events = serverSyncTree.removeAllWrites(); postEvents(events); // Abort any transactions @@ -619,7 +592,7 @@ private void updateInfo(ChildKey childKey, Object value) { List events = this.infoSyncTree.applyServerOverwrite(path, node); this.postEvents(events); } catch (DatabaseException e) { - operationLogger.error("Failed to parse info update", e); + logger.error("Failed to parse info update", e); } } @@ -652,21 +625,16 @@ private void warnIfWriteFailed(String writeType, Path path, DatabaseError error) if (error != null && !(error.getCode() == DatabaseError.DATA_STALE || error.getCode() == DatabaseError.WRITE_CANCELED)) { - operationLogger.warn(writeType + " at " + path.toString() + " failed: " + error.toString()); + logger.warn(writeType + " at " + path.toString() + " failed: " + error.toString()); } } public void startTransaction(Path path, final Transaction.Handler handler, boolean applyLocally) { - if (operationLogger.logsDebug()) { - operationLogger.debug("transaction: " + path); - } - if (dataLogger.logsDebug()) { - operationLogger.debug("transaction: " + path); - } + logger.debug("transaction: {}", path); if (this.ctx.isPersistenceEnabled() && !loggedTransactionPersistenceWarning) { loggedTransactionPersistenceWarning = true; - transactionLogger.info( + logger.info( "runTransaction() usage detected while persistence is enabled. Please be aware that " + "transactions *will not* be persisted across database restarts. See " + "https://www.firebase.com/docs/android/guide/offline-capabilities.html" @@ -1142,11 +1110,7 @@ public void visitTree(Tree> tree) { private Path abortTransactions(Path path, final int reason) { Path affectedPath = getAncestorTransactionNode(path).getPath(); - - if (transactionLogger.logsDebug()) { - operationLogger.debug( - "Aborting transactions for path: " + path + ". Affected: " + affectedPath); - } + logger.debug("Aborting transactions for path: {}. Affected: {}", path, affectedPath); Tree> transactionNode = transactionQueueTree.subTree(path); transactionNode.forEachAncestor( @@ -1247,7 +1211,7 @@ private void runTransactionOnComplete(Transaction.Handler handler, DatabaseError try { handler.onComplete(error, committed, snapshot); } catch (Exception e) { - operationLogger.error("Exception in transaction onComplete callback", e); + logger.error("Exception in transaction onComplete callback", e); } } 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 17fcad445..f7da11750 100644 --- a/src/main/java/com/google/firebase/database/core/SyncTree.java +++ b/src/main/java/com/google/firebase/database/core/SyncTree.java @@ -37,7 +37,6 @@ import com.google.firebase.database.core.view.Event; import com.google.firebase.database.core.view.QuerySpec; import com.google.firebase.database.core.view.View; -import com.google.firebase.database.logging.LogWrapper; import com.google.firebase.database.snapshot.ChildKey; import com.google.firebase.database.snapshot.CompoundHash; import com.google.firebase.database.snapshot.EmptyNode; @@ -57,6 +56,8 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.Callable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * SyncTree is the central class for managing event callback registration, data caching, views @@ -80,6 +81,9 @@ public class SyncTree { // Size after which we start including the compound hash private static final long SIZE_THRESHOLD_FOR_COMPOUND_HASH = 1024; + + private static final Logger logger = LoggerFactory.getLogger(SyncTree.class); + /** * A tree of all pending user writes (user-initiated set()'s, transaction()'s, update()'s, etc.). */ @@ -90,14 +94,13 @@ public class SyncTree { private final Set keepSyncedQueries; private final ListenProvider listenProvider; private final PersistenceManager persistenceManager; - private final LogWrapper logger; /** Tree of SyncPoints. There's a SyncPoint at any location that has 1 or more views. */ private ImmutableTree syncPointTree; /** Static tracker for next query tag. */ private long nextQueryTag = 1L; public SyncTree( - Context context, PersistenceManager persistenceManager, ListenProvider listenProvider) { + PersistenceManager persistenceManager, ListenProvider listenProvider) { this.syncPointTree = ImmutableTree.emptyInstance(); this.pendingWriteTree = new WriteTree(); this.tagToQueryMap = new HashMap<>(); @@ -105,7 +108,6 @@ public SyncTree( this.keepSyncedQueries = new HashSet<>(); this.listenProvider = listenProvider; this.persistenceManager = persistenceManager; - this.logger = context.getLogger(SyncTree.class); } public boolean isEmpty() { @@ -968,7 +970,7 @@ public List onListenComplete(DatabaseError error) { return SyncTree.this.applyListenComplete(query.getPath()); } } else { - logger.warn("Listen at " + view.getQuery().getPath() + " failed: " + error.toString()); + logger.warn("Listen at {} failed: {}", view.getQuery().getPath(), error); // If a listen failed, kill all of the listeners here, not just the one that triggered the // error. Note that this may need to be scoped to just this listener if we change diff --git a/src/main/java/com/google/firebase/database/core/ThreadInitializer.java b/src/main/java/com/google/firebase/database/core/ThreadInitializer.java deleted file mode 100644 index 23c3cab88..000000000 --- a/src/main/java/com/google/firebase/database/core/ThreadInitializer.java +++ /dev/null @@ -1,46 +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.database.core; - -import java.lang.Thread.UncaughtExceptionHandler; - -public interface ThreadInitializer { - - ThreadInitializer defaultInstance = - new ThreadInitializer() { - @Override - public void setName(Thread t, String name) { - t.setName(name); - } - - @Override - public void setDaemon(Thread t, boolean isDaemon) { - t.setDaemon(isDaemon); - } - - @Override - public void setUncaughtExceptionHandler(Thread t, UncaughtExceptionHandler handler) { - t.setUncaughtExceptionHandler(handler); - } - }; - - void setName(Thread t, String name); - - void setDaemon(Thread t, boolean isDaemon); - - void setUncaughtExceptionHandler(Thread t, Thread.UncaughtExceptionHandler handler); -} 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 abe599fdc..34162fbe2 100644 --- a/src/main/java/com/google/firebase/database/core/ThreadPoolEventTarget.java +++ b/src/main/java/com/google/firebase/database/core/ThreadPoolEventTarget.java @@ -16,18 +16,15 @@ package com.google.firebase.database.core; -import static com.google.common.base.Preconditions.checkNotNull; - +import com.google.firebase.internal.FirebaseScheduledExecutor; import java.lang.Thread.UncaughtExceptionHandler; -import java.util.concurrent.BlockingQueue; -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; -/** ThreadPoolEventTarget is an event target using a configurable threadpool. */ +/** ThreadPoolEventTarget is an event target using a configurable thread pool. */ class ThreadPoolEventTarget implements EventTarget, UncaughtExceptionHandler { private static final Logger logger = LoggerFactory.getLogger(ThreadPoolEventTarget.class); @@ -35,26 +32,9 @@ class ThreadPoolEventTarget implements EventTarget, UncaughtExceptionHandler { private final ThreadPoolExecutor executor; private UncaughtExceptionHandler exceptionHandler; - public ThreadPoolEventTarget( - final ThreadFactory wrappedFactory, final ThreadInitializer threadInitializer) { - int poolSize = 1; - BlockingQueue queue = new LinkedBlockingQueue<>(); - - executor = new ThreadPoolExecutor(poolSize, poolSize, 3, TimeUnit.SECONDS, queue, - new ThreadFactory() { - @Override - public Thread newThread(Runnable r) { - Thread thread = wrappedFactory.newThread(r); - threadInitializer.setName(thread, "FirebaseDatabaseEventTarget"); - threadInitializer.setDaemon(thread, true); - threadInitializer.setUncaughtExceptionHandler(thread, ThreadPoolEventTarget.this); - return thread; - } - }); - } - - public ThreadPoolEventTarget(final ThreadPoolExecutor executor) { - this.executor = checkNotNull(executor); + ThreadPoolEventTarget(ThreadFactory threadFactory) { + executor = new FirebaseScheduledExecutor(threadFactory, "firebase-database-event-target", this); + executor.setKeepAliveTime(3, TimeUnit.SECONDS); } @Override 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 1bc546295..40e1b99dc 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 @@ -17,12 +17,10 @@ package com.google.firebase.database.core.persistence; import com.google.firebase.database.core.CompoundWrite; -import com.google.firebase.database.core.Context; import com.google.firebase.database.core.Path; import com.google.firebase.database.core.UserWriteRecord; import com.google.firebase.database.core.view.CacheNode; import com.google.firebase.database.core.view.QuerySpec; -import com.google.firebase.database.logging.LogWrapper; import com.google.firebase.database.snapshot.ChildKey; import com.google.firebase.database.snapshot.EmptyNode; import com.google.firebase.database.snapshot.IndexedNode; @@ -34,24 +32,26 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.Callable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class DefaultPersistenceManager implements PersistenceManager { + private static final Logger logger = LoggerFactory.getLogger(PersistenceManager.class); + private final PersistenceStorageEngine storageLayer; private final TrackedQueryManager trackedQueryManager; - private final LogWrapper logger; private final CachePolicy cachePolicy; private long serverCacheUpdatesSinceLastPruneCheck = 0; public DefaultPersistenceManager( - Context ctx, PersistenceStorageEngine engine, CachePolicy cachePolicy) { - this(ctx, engine, cachePolicy, new DefaultClock()); + PersistenceStorageEngine engine, CachePolicy cachePolicy) { + this(engine, cachePolicy, new DefaultClock()); } public DefaultPersistenceManager( - Context ctx, PersistenceStorageEngine engine, CachePolicy cachePolicy, Clock clock) { + PersistenceStorageEngine engine, CachePolicy cachePolicy, Clock clock) { this.storageLayer = engine; - this.logger = ctx.getLogger(PersistenceManager.class); this.trackedQueryManager = new TrackedQueryManager(storageLayer, logger, clock); this.cachePolicy = cachePolicy; } @@ -253,15 +253,11 @@ public T runInTransaction(Callable callable) { private void doPruneCheckAfterServerUpdate() { serverCacheUpdatesSinceLastPruneCheck++; if (cachePolicy.shouldCheckCacheSize(serverCacheUpdatesSinceLastPruneCheck)) { - if (logger.logsDebug()) { - logger.debug("Reached prune check threshold."); - } + logger.debug("Reached prune check threshold."); serverCacheUpdatesSinceLastPruneCheck = 0; boolean canPrune = true; long cacheSize = storageLayer.serverCacheEstimatedSizeInBytes(); - if (logger.logsDebug()) { - logger.debug("Cache size: " + cacheSize); - } + logger.debug("Cache size: {}", cacheSize); while (canPrune && cachePolicy.shouldPrune(cacheSize, trackedQueryManager.countOfPrunableQueries())) { PruneForest pruneForest = this.trackedQueryManager.pruneOldQueries(cachePolicy); @@ -271,9 +267,7 @@ private void doPruneCheckAfterServerUpdate() { canPrune = false; } cacheSize = storageLayer.serverCacheEstimatedSizeInBytes(); - if (logger.logsDebug()) { - logger.debug("Cache size after prune: " + cacheSize); - } + logger.debug("Cache size after prune: {}", cacheSize); } } } 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 b4f6b545c..20278b947 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 @@ -23,7 +23,6 @@ import com.google.firebase.database.core.utilities.Predicate; import com.google.firebase.database.core.view.QueryParams; import com.google.firebase.database.core.view.QuerySpec; -import com.google.firebase.database.logging.LogWrapper; import com.google.firebase.database.snapshot.ChildKey; import com.google.firebase.database.utilities.Clock; import com.google.firebase.database.utilities.Utilities; @@ -36,6 +35,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import org.slf4j.Logger; public class TrackedQueryManager { @@ -74,7 +74,7 @@ public boolean evaluate(TrackedQuery query) { }; // DB, where we permanently store tracked queries. private final PersistenceStorageEngine storageLayer; - private final LogWrapper logger; + private final Logger logger; private final Clock clock; // In-memory cache of tracked queries. Should always be in-sync with the DB. private ImmutableTree> trackedQueryTree; @@ -82,7 +82,7 @@ public boolean evaluate(TrackedQuery query) { private long currentQueryId = 0; public TrackedQueryManager( - PersistenceStorageEngine storageLayer, LogWrapper logger, Clock clock) { + PersistenceStorageEngine storageLayer, Logger logger, Clock clock) { this.storageLayer = storageLayer; this.logger = logger; this.clock = clock; @@ -226,13 +226,8 @@ public PruneForest pruneOldQueries(CachePolicy cachePolicy) { long countToPrune = calculateCountToPrune(cachePolicy, prunable.size()); PruneForest forest = new PruneForest(); - if (logger.logsDebug()) { - logger.debug( - "Pruning old queries. Prunable: " - + prunable.size() - + " Count to prune: " - + countToPrune); - } + logger.debug( + "Pruning old queries. Prunable: {} Count to prune: {}", prunable.size(), countToPrune); Collections.sort( prunable, @@ -257,9 +252,7 @@ public int compare(TrackedQuery q1, TrackedQuery q2) { // Also keep the unprunable queries. List unprunable = getQueriesMatching(IS_QUERY_UNPRUNABLE_PREDICATE); - if (logger.logsDebug()) { - logger.debug("Unprunable queries: " + unprunable.size()); - } + logger.debug("Unprunable queries: {}", unprunable.size()); for (TrackedQuery toKeep : unprunable) { forest = forest.keep(toKeep.querySpec.getPath()); } 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 47579abd8..d2bbacee6 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 @@ -18,10 +18,11 @@ import com.google.firebase.database.core.Context; import com.google.firebase.database.core.EventTarget; -import com.google.firebase.database.logging.LogWrapper; import java.util.ArrayList; import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Each view owns an instance of this class, and it is used to send events to the event target @@ -33,18 +34,16 @@ */ public class EventRaiser { + private static final Logger logger = LoggerFactory.getLogger(EventRaiser.class); + private final EventTarget eventTarget; - private final LogWrapper logger; public EventRaiser(Context ctx) { eventTarget = ctx.getEventTarget(); - logger = ctx.getLogger(EventRaiser.class); } public void raiseEvents(final List events) { - if (logger.logsDebug()) { - logger.debug("Raising " + events.size() + " event(s)"); - } + logger.debug("Raising {} event(s)", events.size()); // TODO: Use an immutable data structure for events so we don't have to clone to be safe. final ArrayList eventsClone = new ArrayList<>(events); eventTarget.postEvent( @@ -52,9 +51,7 @@ public void raiseEvents(final List events) { @Override public void run() { for (Event event : eventsClone) { - if (logger.logsDebug()) { - logger.debug("Raising " + event.toString()); - } + logger.debug("Raising {}", event); event.fire(); } } diff --git a/src/main/java/com/google/firebase/database/logging/DefaultLogger.java b/src/main/java/com/google/firebase/database/logging/DefaultLogger.java deleted file mode 100644 index d9ba110c7..000000000 --- a/src/main/java/com/google/firebase/database/logging/DefaultLogger.java +++ /dev/null @@ -1,93 +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.database.logging; - -import java.util.Date; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -public class DefaultLogger implements Logger { - - private final Set enabledComponents; - private final Level minLevel; - - public DefaultLogger(Level level, List enabledComponents) { - if (enabledComponents != null) { - this.enabledComponents = new HashSet<>(enabledComponents); - } else { - this.enabledComponents = null; - } - minLevel = level; - } - - @Override - public Level getLogLevel() { - return this.minLevel; - } - - @Override - public void onLogMessage(Level level, String tag, String message, long msTimestamp) { - if (shouldLog(level, tag)) { - String toLog = buildLogMessage(level, tag, message, msTimestamp); - switch (level) { - case ERROR: - error(tag, toLog); - break; - case WARN: - warn(tag, toLog); - break; - case INFO: - info(tag, toLog); - break; - case DEBUG: - debug(tag, toLog); - break; - default: - throw new RuntimeException("Should not reach here!"); - } - } - } - - protected String buildLogMessage(Level level, String tag, String message, long msTimestamp) { - Date now = new Date(msTimestamp); - return now.toString() + " " + "[" + level + "] " + tag + ": " + message; - } - - protected void error(String tag, String toLog) { - System.err.println(toLog); - } - - protected void warn(String tag, String toLog) { - System.out.println(toLog); - } - - protected void info(String tag, String toLog) { - System.out.println(toLog); - } - - protected void debug(String tag, String toLog) { - System.out.println(toLog); - } - - protected boolean shouldLog(Level level, String tag) { - return (level.ordinal() >= minLevel.ordinal() - && (enabledComponents == null - || level.ordinal() > Level.DEBUG.ordinal() - || enabledComponents.contains(tag))); - } -} diff --git a/src/main/java/com/google/firebase/database/logging/LogWrapper.java b/src/main/java/com/google/firebase/database/logging/LogWrapper.java deleted file mode 100644 index 21e28e526..000000000 --- a/src/main/java/com/google/firebase/database/logging/LogWrapper.java +++ /dev/null @@ -1,127 +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.database.logging; - -import java.io.PrintWriter; -import java.io.StringWriter; -import org.slf4j.LoggerFactory; - -/** - * Legacy logging interface for database implementation. This class attempts to reconcile SLF4J - * with the old logging implementation of the Admin SDK. When SLF4J is available, logs using that - * API. Otherwise falls back to the old logging implementation. This prevents individual log - * statements from being written to both log APIs. - * - * @deprecated This class will be removed in a future release, and SLF4J will be used universally - * throughout the codebase. - */ -public class LogWrapper { - - private final org.slf4j.Logger slf4jLogger; - private final Logger logger; - private final String component; - private final String prefix; - - public LogWrapper(Logger logger, Class component) { - this(logger, component, null); - } - - public LogWrapper(Logger logger, Class component, String prefix) { - this.slf4jLogger = LoggerFactory.getLogger(component); - this.logger = logger; - this.component = component.getName(); - this.prefix = prefix; - } - - public LogWrapper(Logger logger, String component, String prefix) { - this.slf4jLogger = LoggerFactory.getLogger(component); - this.logger = logger; - this.component = component; - this.prefix = prefix; - } - - private static String exceptionStacktrace(Throwable e) { - StringWriter writer = new StringWriter(); - PrintWriter printWriter = new PrintWriter(writer); - e.printStackTrace(printWriter); - return writer.toString(); - } - - public void error(String message, Throwable e) { - if (slf4jLogger.isErrorEnabled()) { - slf4jLogger.error(toLog(message), e); - } else { - String logMsg = toLog(message) + "\n" + exceptionStacktrace(e); - logger.onLogMessage(Logger.Level.ERROR, component, logMsg, now()); - } - } - - public void warn(String message) { - warn(message, null); - } - - public void warn(String message, Throwable e) { - if (slf4jLogger.isWarnEnabled()) { - slf4jLogger.warn(toLog(message), e); - } else { - String logMsg = toLog(message); - if (e != null) { - logMsg = logMsg + "\n" + exceptionStacktrace(e); - } - logger.onLogMessage(Logger.Level.WARN, component, logMsg, now()); - } - } - - public void info(String message) { - if (slf4jLogger.isInfoEnabled()) { - slf4jLogger.info(toLog(message)); - } else { - logger.onLogMessage(Logger.Level.INFO, component, toLog(message), now()); - } - } - - public void debug(String message, Object... args) { - this.debug(message, null, args); - } - - /** Log a non-fatal exception. Typically something like an IO error on a failed connection */ - public void debug(String message, Throwable e, Object... args) { - if (slf4jLogger.isDebugEnabled()) { - slf4jLogger.debug(toLog(message, args), e); - } else { - String logMsg = toLog(message, args); - if (e != null) { - logMsg = logMsg + "\n" + exceptionStacktrace(e); - } - logger.onLogMessage(Logger.Level.DEBUG, component, logMsg, now()); - } - } - - public boolean logsDebug() { - return this.logger.getLogLevel().ordinal() <= Logger.Level.DEBUG.ordinal() - || slf4jLogger.isDebugEnabled(); - } - - private long now() { - return System.currentTimeMillis(); - } - - private String toLog(String message, Object... args) { - String formatted = (args.length > 0) ? String.format(message, args) : message; - return prefix == null ? formatted : prefix + " - " + formatted; - } -} diff --git a/src/main/java/com/google/firebase/database/logging/Logger.java b/src/main/java/com/google/firebase/database/logging/Logger.java deleted file mode 100644 index 7fd2e6e0e..000000000 --- a/src/main/java/com/google/firebase/database/logging/Logger.java +++ /dev/null @@ -1,47 +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.database.logging; - -/** - * Private (internal) logging interface used by Firebase Database. See {@link - * com.google.firebase.database.core.DatabaseConfig DatabaseConfig} for more information. - * - * @deprecated Use SLF4J-based logging - */ -public interface Logger { - - /** - * This method will be triggered whenever the library has something to log - * - * @param level The level of the log message - * @param tag The component that this log message is coming from - * @param message The message to be logged - * @param msTimestamp The timestamp, in milliseconds, at which this message was generated - */ - void onLogMessage(Level level, String tag, String message, long msTimestamp); - - Level getLogLevel(); - - /** The log levels used by the Firebase Database library */ - enum Level { - DEBUG, - INFO, - WARN, - ERROR, - NONE - } -} 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..11c358fc6 --- /dev/null +++ b/src/main/java/com/google/firebase/database/util/EmulatorHelper.java @@ -0,0 +1,72 @@ +/* + * Copyright 2019 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.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/JsonMapper.java b/src/main/java/com/google/firebase/database/util/JsonMapper.java index 0c178f7e1..0321c8bb2 100644 --- a/src/main/java/com/google/firebase/database/util/JsonMapper.java +++ b/src/main/java/com/google/firebase/database/util/JsonMapper.java @@ -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/utilities/DefaultRunLoop.java b/src/main/java/com/google/firebase/database/utilities/DefaultRunLoop.java index d43deb6b3..e84b7b561 100644 --- a/src/main/java/com/google/firebase/database/utilities/DefaultRunLoop.java +++ b/src/main/java/com/google/firebase/database/utilities/DefaultRunLoop.java @@ -18,13 +18,13 @@ import com.google.firebase.database.DatabaseException; import com.google.firebase.database.FirebaseDatabase; -import com.google.firebase.database.annotations.Nullable; -import com.google.firebase.database.core.Context; -import com.google.firebase.database.core.RepoManager; import com.google.firebase.database.core.RunLoop; -import com.google.firebase.internal.RevivingScheduledExecutor; +import com.google.firebase.internal.FirebaseScheduledExecutor; import java.lang.Thread.UncaughtExceptionHandler; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; @@ -33,43 +33,39 @@ public abstract class DefaultRunLoop implements RunLoop { - private ScheduledThreadPoolExecutor executor; + private final ScheduledThreadPoolExecutor executor; private UncaughtExceptionHandler exceptionHandler; - /** Creates a DefaultRunLoop that does not periodically restart its threads. */ - public DefaultRunLoop(ThreadFactory threadFactory) { - this(threadFactory, false, null); - } - /** * Creates a DefaultRunLoop that optionally restarts its threads periodically. If 'context' is * provided, these restarts will automatically interrupt and resume all Repo connections. */ - public DefaultRunLoop( - final ThreadFactory threadFactory, - final boolean periodicRestart, - @Nullable final Context context) { - executor = - new RevivingScheduledExecutor(threadFactory, "FirebaseDatabaseWorker", periodicRestart) { - @Override - protected void handleException(Throwable throwable) { - DefaultRunLoop.this.handleExceptionInternal(throwable); - } - - @Override - protected void beforeRestart() { - if (context != null) { - RepoManager.interrupt(context); + protected DefaultRunLoop(ThreadFactory threadFactory) { + executor = new FirebaseScheduledExecutor(threadFactory, "firebase-database-worker") { + @Override + protected void afterExecute(Runnable runnable, Throwable throwable) { + super.afterExecute(runnable, throwable); + if (throwable == null && runnable instanceof Future) { + Future future = (Future) runnable; + try { + // Not all Futures will be done, e.g. when used with scheduledAtFixedRate + if (future.isDone()) { + future.get(); } + } catch (CancellationException ce) { + // Cancellation exceptions are okay, we expect them to happen sometimes + } catch (ExecutionException ee) { + throwable = ee.getCause(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } + } - @Override - protected void afterRestart() { - if (context != null) { - RepoManager.resume(context); - } - } - }; + if (throwable != null) { + handleExceptionInternal(throwable); + } + } + }; // Core threads don't time out, this only takes effect when we drop the number of required // core threads 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 bd055d273..6181071ec 100644 --- a/src/main/java/com/google/firebase/database/utilities/Utilities.java +++ b/src/main/java/com/google/firebase/database/utilities/Utilities.java @@ -16,80 +16,109 @@ package com.google.firebase.database.utilities; +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 com.google.firebase.tasks.Task; -import com.google.firebase.tasks.TaskCompletionSource; - 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) { @@ -237,22 +266,22 @@ public static void hardAssert(boolean condition, String message) { } } - public static Pair, DatabaseReference.CompletionListener> wrapOnComplete( + public static Pair, DatabaseReference.CompletionListener> wrapOnComplete( DatabaseReference.CompletionListener optListener) { if (optListener == null) { - final TaskCompletionSource source = new TaskCompletionSource<>(); + final SettableApiFuture future = SettableApiFuture.create(); DatabaseReference.CompletionListener listener = new DatabaseReference.CompletionListener() { @Override public void onComplete(DatabaseError error, DatabaseReference ref) { if (error != null) { - source.setException(error.toException()); + future.setException(error.toException()); } else { - source.setResult(null); + future.set(null); } } }; - return new Pair<>(source.getTask(), listener); + return new Pair, DatabaseReference.CompletionListener>(future, listener); } else { // If a listener is supplied we do not want to create a Task return new Pair<>(null, optListener); 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..c5ef85e4a 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 @@ -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/iid/FirebaseInstanceId.java b/src/main/java/com/google/firebase/iid/FirebaseInstanceId.java index 6993b8fd1..47026e6f1 100644 --- a/src/main/java/com/google/firebase/iid/FirebaseInstanceId.java +++ b/src/main/java/com/google/firebase/iid/FirebaseInstanceId.java @@ -17,32 +17,28 @@ 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.auth.http.HttpCredentialsAdapter; -import com.google.auth.oauth2.GoogleCredentials; 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.ErrorHandlingHttpClient; import com.google.firebase.internal.FirebaseService; +import com.google.firebase.internal.HttpRequestInfo; import com.google.firebase.internal.NonNull; -import com.google.firebase.internal.TaskToApiFuture; -import com.google.firebase.tasks.Task; import java.util.Map; -import java.util.concurrent.Callable; /** * This class is the entry point for all server-side Firebase Instance ID actions. @@ -66,24 +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(); - GoogleCredentials credentials = ImplFirebaseTrampolines.getCredentials(app); - this.app = app; - this.requestFactory = httpTransport.createRequestFactory( - new HttpCredentialsAdapter(credentials)); - 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 GCLOUD_PROJECT environment variable."); + + "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()); } /** @@ -111,58 +113,84 @@ public static synchronized FirebaseInstanceId getInstance(FirebaseApp app) { @VisibleForTesting void setInterceptor(HttpResponseInterceptor interceptor) { - this.interceptor = interceptor; + httpClient.setInterceptor(interceptor); } /** - * Deletes the specified instance ID from Firebase. + * Deletes the specified instance ID and the associated data from Firebase. * - *

    This can be used to delete an instance ID and associated user data from a Firebase project, - * pursuant to the General Data Protection Regulation (GDPR). + *

    Note that Google Analytics for Firebase uses its own form of Instance ID to keep track of + * analytics data. Therefore deleting a regular Instance ID does not delete Analytics data. + * See + * Delete an Instance ID for more information. + * + * @param instanceId A non-null, non-empty instance ID string. + * @throws IllegalArgumentException If the instance ID is null or empty. + * @throws FirebaseInstanceIdException If an error occurs while deleting the instance ID. + */ + public void deleteInstanceId(@NonNull String instanceId) throws FirebaseInstanceIdException { + deleteInstanceIdOp(instanceId).call(); + } + + /** + * Similar to {@link #deleteInstanceId(String)} but performs the operation asynchronously. * * @param instanceId A non-null, non-empty instance ID string. * @return An {@code ApiFuture} which will complete successfully when the instance ID is deleted, - * or unsuccessfully with the failure Exception.. + * or unsuccessfully with the failure Exception. + * @throws IllegalArgumentException If the instance ID is null or empty. */ public ApiFuture deleteInstanceIdAsync(@NonNull String instanceId) { - return new TaskToApiFuture<>(deleteInstanceId(instanceId)); + return deleteInstanceIdOp(instanceId).callAsync(app); } - private Task deleteInstanceId(final String instanceId) { + private CallableOperation deleteInstanceIdOp( + final String instanceId) { checkArgument(!Strings.isNullOrEmpty(instanceId), "instance ID must not be null or empty"); - return ImplFirebaseTrampolines.submitCallable(app, new Callable(){ + return new CallableOperation() { @Override - public Void call() throws Exception { + protected Void execute() throws FirebaseInstanceIdException { String url = String.format( "%s/project/%s/instanceId/%s", IID_SERVICE_URL, projectId, instanceId); - HttpRequest request = requestFactory.buildDeleteRequest(new GenericUrl(url)); - request.setParser(new JsonObjectParser(jsonFactory)); - request.setResponseInterceptor(interceptor); - HttpResponse response = null; - try { - response = request.execute(); - ByteStreams.exhaust(response.getContent()); - } catch (Exception e) { - handleError(instanceId, e); - } finally { - if (response != null) { - response.disconnect(); - } - } + HttpRequestInfo request = HttpRequestInfo.buildDeleteRequest(url); + httpClient.send(request); return null; } - }); + }; } - 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 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 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(); @@ -172,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..b0909e4f7 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/AbstractPlatformErrorHandler.java @@ -0,0 +1,98 @@ +/* + * Copyright 2020 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 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/ApacheHttp2AsyncEntityProducer.java b/src/main/java/com/google/firebase/internal/ApacheHttp2AsyncEntityProducer.java new file mode 100644 index 000000000..9bdf208c7 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2AsyncEntityProducer.java @@ -0,0 +1,150 @@ +/* + * Copyright 2024 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.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/ApacheHttp2Request.java b/src/main/java/com/google/firebase/internal/ApacheHttp2Request.java new file mode 100644 index 000000000..56c2c9034 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2Request.java @@ -0,0 +1,147 @@ +/* + * Copyright 2024 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.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.SimpleHttpResponse; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.client5.http.async.methods.SimpleResponseConsumer; +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.nio.support.BasicRequestProducer; +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 ApacheHttp2AsyncEntityProducer entityProducer; + + ApacheHttp2Request( + CloseableHttpAsyncClient httpAsyncClient, SimpleRequestBuilder requestBuilder) { + this.httpAsyncClient = httpAsyncClient; + this.requestBuilder = requestBuilder; + this.writeTimeout = 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 { + 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 Producer + CompletableFuture writeFuture = new CompletableFuture<>(); + entityProducer = new ApacheHttp2AsyncEntityProducer(this, writeFuture); + + // Execute + final Future responseFuture = httpAsyncClient.execute( + new BasicRequestProducer(request, entityProducer), + SimpleResponseConsumer.create(), + new FutureCallback() { + @Override + public void completed(final SimpleHttpResponse 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 SimpleHttpResponse response = 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); + } + } + + @VisibleForTesting + ApacheHttp2AsyncEntityProducer getEntityProducer() { + return entityProducer; + } +} 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..4c05b0e03 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2Response.java @@ -0,0 +1,100 @@ +/* + * Copyright 2024 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.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.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; + +public class ApacheHttp2Response extends LowLevelHttpResponse { + private final SimpleHttpResponse response; + private final Header[] allHeaders; + + ApacheHttp2Response(SimpleHttpResponse response) { + this.response = response; + allHeaders = response.getHeaders(); + } + + @Override + public int getStatusCode() { + return response.getCode(); + } + + @Override + public InputStream getContent() throws IOException { + return new ByteArrayInputStream(response.getBodyBytes()); + } + + @Override + public String getContentEncoding() { + Header contentEncodingHeader = response.getFirstHeader("Content-Encoding"); + return contentEncodingHeader == null ? null : contentEncodingHeader.getValue(); + } + + @Override + public long getContentLength() { + String bodyText = response.getBodyText(); + return bodyText == null ? 0 : bodyText.length(); + } + + @Override + public String getContentType() { + ContentType contentType = response.getContentType(); + return contentType == null ? null : contentType.toString(); + } + + @Override + public String getReasonPhrase() { + return response.getReasonPhrase(); + } + + @Override + public String getStatusLine() { + return response.toString(); + } + + public String getHeaderValue(String name) { + return response.getLastHeader(name).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 SimpleHttpResponse getResponse() { + return response; + } +} 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..9c0e413fb --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2Transport.java @@ -0,0 +1,125 @@ +/* + * Copyright 2024 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.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..9d501fae3 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApiClientUtils.java @@ -0,0 +1,97 @@ +/* + * Copyright 2019 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.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..93672bbe9 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApplicationDefaultCredentialsProvider.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021 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.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 new file mode 100644 index 000000000..ac28e8a7c --- /dev/null +++ b/src/main/java/com/google/firebase/internal/CallableOperation.java @@ -0,0 +1,49 @@ +/* + * 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 static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.core.ApiFuture; +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import java.util.concurrent.Callable; + +/** + * An operation that can be invoked synchronously or asynchronously. Subclasses can specify + * the return type and a specific exception type to be thrown. + */ +public abstract class CallableOperation implements Callable { + + protected abstract T execute() throws V; + + @Override + public final T call() throws V { + return execute(); + } + + /** + * Run this operation asynchronously on the main thread pool of the specified {@link FirebaseApp}. + * + * @param app A non-null {@link FirebaseApp}. + * @return An {@code ApiFuture}. + */ + public final ApiFuture callAsync(@NonNull FirebaseApp app) { + checkNotNull(app); + return ImplFirebaseTrampolines.submitCallable(app, this); + } +} 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..b13e971d0 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/EmulatorCredentials.java @@ -0,0 +1,52 @@ +/* + * Copyright 2022 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.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..cccd0cedb --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ErrorHandlingHttpClient.java @@ -0,0 +1,145 @@ +/* + * Copyright 2020 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 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..a9c3022d4 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/FirebaseProcessEnvironment.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021 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.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 new file mode 100644 index 000000000..e12690629 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java @@ -0,0 +1,78 @@ +/* + * Copyright 2018 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.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.auth.http.HttpCredentialsAdapter; +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. Initializes requests with + * OAuth2 credentials, timeout and retry settings. + */ +public final class FirebaseRequestInitializer implements HttpRequestInitializer { + + private final List initializers; + + public FirebaseRequestInitializer(FirebaseApp app) { + 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 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 new file mode 100644 index 000000000..0a472ba47 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/FirebaseScheduledExecutor.java @@ -0,0 +1,60 @@ +/* + * Copyright 2018 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 static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Strings; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; + +/** + * A single-threaded scheduled executor implementation. Allows naming the threads, and spawns + * new threads as daemons. + */ +public class FirebaseScheduledExecutor extends ScheduledThreadPoolExecutor { + + public FirebaseScheduledExecutor(@NonNull ThreadFactory threadFactory, @NonNull String name) { + this(threadFactory, name, null); + } + + public FirebaseScheduledExecutor( + @NonNull ThreadFactory threadFactory, @NonNull String name, + @Nullable Thread.UncaughtExceptionHandler handler) { + super(1, decorateThreadFactory(threadFactory, name, handler)); + setRemoveOnCancelPolicy(true); + } + + static ThreadFactory getThreadFactoryWithName( + @NonNull ThreadFactory threadFactory, @NonNull String name) { + return decorateThreadFactory(threadFactory, name, null); + } + + private static ThreadFactory decorateThreadFactory( + ThreadFactory threadFactory, String name, Thread.UncaughtExceptionHandler handler) { + checkArgument(!Strings.isNullOrEmpty(name)); + ThreadFactoryBuilder builder = new ThreadFactoryBuilder() + .setThreadFactory(threadFactory) + .setNameFormat(name) + .setDaemon(true); + if (handler != null) { + builder.setUncaughtExceptionHandler(handler); + } + return builder.build(); + } +} diff --git a/src/main/java/com/google/firebase/internal/FirebaseService.java b/src/main/java/com/google/firebase/internal/FirebaseService.java index d3e87551f..2b802aee8 100644 --- a/src/main/java/com/google/firebase/internal/FirebaseService.java +++ b/src/main/java/com/google/firebase/internal/FirebaseService.java @@ -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 0e503ba36..c14fbd611 100644 --- a/src/main/java/com/google/firebase/internal/FirebaseThreadManagers.java +++ b/src/main/java/com/google/firebase/internal/FirebaseThreadManagers.java @@ -16,9 +16,6 @@ package com.google.firebase.internal; -import static com.google.common.base.Preconditions.checkState; - -import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.google.firebase.FirebaseApp; import com.google.firebase.ThreadManager; @@ -26,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; @@ -36,15 +36,7 @@ public class FirebaseThreadManagers { private static final Logger logger = LoggerFactory.getLogger(FirebaseThreadManagers.class); - public static final ThreadManager DEFAULT_THREAD_MANAGER; - - static { - if (GaeThreadFactory.isAvailable()) { - DEFAULT_THREAD_MANAGER = new GaeThreadManager(); - } else { - DEFAULT_THREAD_MANAGER = new DefaultThreadManager(); - } - } + public static final ThreadManager DEFAULT_THREAD_MANAGER = new DefaultThreadManager(); /** * An abstract ThreadManager implementation that uses the same executor service @@ -93,13 +85,13 @@ private static class DefaultThreadManager extends GlobalThreadManager { @Override protected ExecutorService doInit() { - // Create threads as daemons to ensure JVM exit when all foreground jobs are complete. - ThreadFactory threadFactory = new ThreadFactoryBuilder() - .setNameFormat("firebase-default-%d") - .setDaemon(true) - .setThreadFactory(getThreadFactory()) - .build(); - return Executors.newCachedThreadPool(threadFactory); + ThreadFactory threadFactory = FirebaseScheduledExecutor.getThreadFactoryWithName( + getThreadFactory(), "firebase-default-%d"); + + ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(100, 100, 60L, + TimeUnit.SECONDS, new LinkedBlockingQueue(), threadFactory); + threadPoolExecutor.allowCoreThreadTimeOut(true); + return threadPoolExecutor; } @Override @@ -113,52 +105,4 @@ protected ThreadFactory getThreadFactory() { return Executors.defaultThreadFactory(); } } - - /** - * The ThreadManager implementation that will be used by default in the Google App Engine - * environment. - * - *

    Auto-scaling: Creates an ExecutorService backed by the request-scoped ThreadFactory. This - * can be used for any short-lived task, such as the ones submitted by components like - * FirebaseAuth. {@link #getThreadFactory()} throws an exception, since long-lived threads - * cannot be supported. Therefore task scheduling and RTDB will not work. - * - *

    Manual-scaling: Creates a single-threaded ExecutorService backed by the background - * ThreadFactory. Keeps the threads alive indefinitely by periodically restarting them (see - * {@link RevivingScheduledExecutor}). Threads will be terminated only when the method - * {@link #releaseExecutor(FirebaseApp, ExecutorService)} is invoked. The - * {@link #getThreadFactory()} also returns the background ThreadFactory enabling other - * components in the SDK to start long-lived threads when necessary. Therefore task scheduling - * and RTDB can be supported as if running on the regular JVM. - * - *

    Basic-scaling: Behavior is similar to manual-scaling. Since the threads are kept alive - * indefinitely, prevents the GAE idle instance shutdown. Developers are advised to use - * a custom ThreadManager implementation if idle instance shutdown should be supported. In - * general, a ThreadManager implementation that uses the request-scoped ThreadFactory, or the - * background ThreadFactory with specific keep-alive times can easily facilitate GAE idle - * instance shutdown. Note that this often comes at the cost of losing scheduled tasks and RTDB - * support. Therefore, for these features, manual-scaling is the recommended GAE deployment mode - * regardless of the ThreadManager implementation used. - */ - private static class GaeThreadManager extends GlobalThreadManager { - - @Override - protected ExecutorService doInit() { - return new GaeExecutorService("gae-firebase-default"); - } - - @Override - protected void doCleanup(ExecutorService executorService) { - executorService.shutdownNow(); - } - - @Override - protected ThreadFactory getThreadFactory() { - GaeThreadFactory threadFactory = GaeThreadFactory.getInstance(); - checkState(threadFactory.isUsingBackgroundThreads(), - "Failed to initialize a GAE background thread factory. Background thread support " - + "is required to create long-lived threads."); - return threadFactory; - } - } } diff --git a/src/main/java/com/google/firebase/internal/GaeExecutorService.java b/src/main/java/com/google/firebase/internal/GaeExecutorService.java deleted file mode 100644 index 6d1b323e8..000000000 --- a/src/main/java/com/google/firebase/internal/GaeExecutorService.java +++ /dev/null @@ -1,195 +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 static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Preconditions.checkState; - -import com.google.common.base.Strings; -import com.google.common.collect.ImmutableList; - -import java.util.Collection; -import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; -import java.util.concurrent.SynchronousQueue; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicReference; - -/** - * An ExecutorService instance that can operate in the Google App Engine environment. When - * available, uses background thread support to initialize an ExecutorService with long-lived - * threads. Otherwise, creates an ExecutorService that spawns short-lived threads as tasks - * are submitted. The actual ExecutorService implementation is lazy-loaded to prevent making - * unnecessary RPC calls to the GAE's native ThreadFactory mechanism. - */ -class GaeExecutorService implements ExecutorService { - - private final AtomicReference executor = new AtomicReference<>(); - private final String threadName; - private final ThreadFactory threadFactory; - private boolean shutdown; - - GaeExecutorService(String threadName) { - this(threadName, GaeThreadFactory.getInstance()); - } - - GaeExecutorService(String threadName, ThreadFactory threadFactory) { - checkArgument(!Strings.isNullOrEmpty(threadName)); - this.threadName = threadName; - this.threadFactory = threadFactory; - } - - private ExecutorService ensureExecutorService() { - ExecutorService executorService = executor.get(); - if (executorService == null) { - synchronized (executor) { - checkState(!shutdown); - executorService = executor.get(); - if (executorService == null) { - executorService = newExecutorService(threadFactory, threadName); - executor.compareAndSet(null, executorService); - } - } - } - return executorService; - } - - @Override - public Future submit(Callable task) { - return ensureExecutorService().submit(task); - } - - @Override - public Future submit(Runnable task, T result) { - return ensureExecutorService().submit(task, result); - } - - @Override - public Future submit(Runnable task) { - return ensureExecutorService().submit(task); - } - - @Override - public List> invokeAll(Collection> tasks) - throws InterruptedException { - return ensureExecutorService().invokeAll(tasks); - } - - @Override - public List> invokeAll( - Collection> tasks, long timeout, TimeUnit unit) - throws InterruptedException { - return ensureExecutorService().invokeAll(tasks, timeout, unit); - } - - @Override - public T invokeAny(Collection> tasks) - throws InterruptedException, ExecutionException { - return ensureExecutorService().invokeAny(tasks); - } - - @Override - public T invokeAny(Collection> tasks, long timeout, TimeUnit unit) - throws InterruptedException, ExecutionException, TimeoutException { - return ensureExecutorService().invokeAny(tasks, timeout, unit); - } - - @Override - public void shutdown() { - synchronized (executor) { - ExecutorService executorService = executor.get(); - if (executorService != null && !shutdown) { - executorService.shutdown(); - } - shutdown = true; - } - } - - @Override - public List shutdownNow() { - synchronized (executor) { - ExecutorService executorService = executor.get(); - List result; - if (executorService != null && !shutdown) { - result = executorService.shutdownNow(); - } else { - result = ImmutableList.of(); - } - shutdown = true; - return result; - } - } - - @Override - public boolean isShutdown() { - synchronized (executor) { - return shutdown; - } - } - - @Override - public boolean isTerminated() { - synchronized (executor) { - if (!shutdown) { - return false; - } - ExecutorService executorService = executor.get(); - return executorService == null || executorService.isTerminated(); - } - } - - @Override - public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { - ExecutorService executorService; - synchronized (executor) { - executorService = executor.get(); - } - // call await outside the lock - return executorService == null || executorService.awaitTermination(timeout, unit); - } - - @Override - public void execute(Runnable command) { - ensureExecutorService().execute(command); - } - - private static ExecutorService newExecutorService( - ThreadFactory threadFactory, String threadName) { - boolean background = threadFactory instanceof GaeThreadFactory - && ((GaeThreadFactory) threadFactory).isUsingBackgroundThreads(); - if (background) { - // Create a thread pool with long-lived threads if background thread support is available. - return new RevivingScheduledExecutor(threadFactory, threadName, true); - } else { - // Create an executor that creates a new thread for each submitted task, when background - // thread support is not available. - return new ThreadPoolExecutor( - 0, - Integer.MAX_VALUE, - 0L, - TimeUnit.SECONDS, - new SynchronousQueue(), - threadFactory); - } - } -} diff --git a/src/main/java/com/google/firebase/internal/GaeThreadFactory.java b/src/main/java/com/google/firebase/internal/GaeThreadFactory.java deleted file mode 100644 index 3791dad78..000000000 --- a/src/main/java/com/google/firebase/internal/GaeThreadFactory.java +++ /dev/null @@ -1,165 +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 static com.google.common.base.Preconditions.checkNotNull; - -import java.lang.reflect.InvocationTargetException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.atomic.AtomicReference; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * GaeThreadFactory is a thread factory that works on App Engine. It uses background threads on - * manually-scaled GAE backends and request-scoped threads on automatically scaled instances. - * - *

    This class is thread-safe. - */ -public class GaeThreadFactory implements ThreadFactory { - - private static final Logger logger = LoggerFactory.getLogger(GaeThreadFactory.class); - - public static final ExecutorService DEFAULT_EXECUTOR = - new GaeExecutorService("LegacyFirebaseDefault"); - private static final String GAE_THREAD_MANAGER_CLASS = "com.google.appengine.api.ThreadManager"; - private static final GaeThreadFactory instance = new GaeThreadFactory(); - private final AtomicReference threadFactory = new AtomicReference<>(null); - - private GaeThreadFactory() {} - - public static GaeThreadFactory getInstance() { - return instance; - } - - /** Returns whether GaeThreadFactory can be used on this system (true for GAE). */ - public static boolean isAvailable() { - try { - Class.forName(GAE_THREAD_MANAGER_CLASS); - return System.getProperty("com.google.appengine.runtime.environment") != null; - } catch (ClassNotFoundException e) { - return false; - } - } - - private static ThreadFactory createBackgroundFactory() - throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, - IllegalAccessException { - Class gaeThreadManager = Class.forName(GAE_THREAD_MANAGER_CLASS); - return (ThreadFactory) gaeThreadManager.getMethod("backgroundThreadFactory").invoke(null); - } - - private static ThreadFactory createRequestScopedFactory() - throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, - IllegalAccessException { - Class gaeThreadManager = Class.forName(GAE_THREAD_MANAGER_CLASS); - return (ThreadFactory) gaeThreadManager.getMethod("currentRequestThreadFactory").invoke(null); - } - - @Override - public Thread newThread(Runnable r) { - ThreadFactoryWrapper wrapper = threadFactory.get(); - if (wrapper != null) { - return wrapper.getThreadFactory().newThread(r); - } - return initThreadFactory(r); - } - - /** - * Checks whether background thread support is available in the current environment. This method - * forces the ThreadFactory to get fully initialized (if not already initialized), by running a - * no-op thread. - * - * @return true if background thread support is available, and false otherwise. - */ - boolean isUsingBackgroundThreads() { - ThreadFactoryWrapper wrapper = threadFactory.get(); - if (wrapper != null) { - return wrapper.isUsingBackgroundThreads(); - } - - // Create a no-op thread to force initialize the ThreadFactory implementation. - // Start the resulting thread, since GAE code seems to expect that. - initThreadFactory(new Runnable() { - @Override - public void run() {} - }).start(); - return threadFactory.get().isUsingBackgroundThreads(); - } - - private Thread initThreadFactory(Runnable r) { - ThreadFactory threadFactory; - boolean usesBackgroundThreads = false; - Thread thread; - // Since we can't tell manually-scaled GAE instances apart until we spawn a thread (which - // sends an RPC and thus is done after class initialization), we initialize both of GAE's - // thread factories here and discard one once we detect that we are running in an - // automatically scaled instance. - // - // Note: It's fine if multiple threads access this block at the same time. - try { - try { - threadFactory = createBackgroundFactory(); - thread = threadFactory.newThread(r); - usesBackgroundThreads = true; - } catch (IllegalStateException e) { - logger.info("Falling back to GAE's request-scoped threads. Firebase requires " - + "manually-scaled instances for most operations."); - threadFactory = createRequestScopedFactory(); - thread = threadFactory.newThread(r); - } - } catch (ClassNotFoundException - | InvocationTargetException - | NoSuchMethodException - | IllegalAccessException e) { - threadFactory = - new ThreadFactory() { - @Override - public Thread newThread(Runnable r) { - logger.warn("Failed to initialize native GAE thread factory. " - + "GaeThreadFactory cannot be used in a non-GAE environment."); - return null; - } - }; - thread = null; - } - - ThreadFactoryWrapper wrapper = new ThreadFactoryWrapper(threadFactory, usesBackgroundThreads); - this.threadFactory.compareAndSet(null, wrapper); - return thread; - } - - private static class ThreadFactoryWrapper { - - private final ThreadFactory threadFactory; - private final boolean usingBackgroundThreads; - - private ThreadFactoryWrapper(ThreadFactory threadFactory, boolean usingBackgroundThreads) { - this.threadFactory = checkNotNull(threadFactory); - this.usingBackgroundThreads = usingBackgroundThreads; - } - - ThreadFactory getThreadFactory() { - return threadFactory; - } - - boolean isUsingBackgroundThreads() { - return usingBackgroundThreads; - } - } -} 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..988b45a45 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/HttpErrorHandler.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020 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.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..375e332fb --- /dev/null +++ b/src/main/java/com/google/firebase/internal/HttpRequestInfo.java @@ -0,0 +1,132 @@ +/* + * Copyright 2020 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 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/tasks/OnFailureListener.java b/src/main/java/com/google/firebase/internal/ListenableFuture2ApiFuture.java similarity index 54% rename from src/main/java/com/google/firebase/tasks/OnFailureListener.java rename to src/main/java/com/google/firebase/internal/ListenableFuture2ApiFuture.java index be44b9837..18d98da7e 100644 --- a/src/main/java/com/google/firebase/tasks/OnFailureListener.java +++ b/src/main/java/com/google/firebase/internal/ListenableFuture2ApiFuture.java @@ -14,21 +14,19 @@ * limitations under the License. */ -package com.google.firebase.tasks; +package com.google.firebase.internal; -import com.google.firebase.internal.NonNull; +import com.google.api.core.ApiFuture; +import com.google.common.util.concurrent.ForwardingListenableFuture.SimpleForwardingListenableFuture; +import com.google.common.util.concurrent.ListenableFuture; /** - * Listener called when a {@link Task} fails with an exception. - * - * @see Task#addOnFailureListener(OnFailureListener) + * Adapter from Guava ListenableFuture to GAX ApiFuture. */ -public interface OnFailureListener { +public class ListenableFuture2ApiFuture extends SimpleForwardingListenableFuture implements + ApiFuture { - /** - * Called when the Task fails with an exception. - * - * @param e the exception that caused the Task to fail. Never null - */ - void onFailure(@NonNull Exception e); + public ListenableFuture2ApiFuture(ListenableFuture delegate) { + super(delegate); + } } 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..c859ec485 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/MockApacheHttp2AsyncClient.java @@ -0,0 +1,73 @@ +/* + * Copyright 2024 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 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/RetryConfig.java b/src/main/java/com/google/firebase/internal/RetryConfig.java new file mode 100644 index 000000000..a17780ed0 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/RetryConfig.java @@ -0,0 +1,177 @@ +/* + * Copyright 2019 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 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..fbe0a13ea --- /dev/null +++ b/src/main/java/com/google/firebase/internal/RetryInitializer.java @@ -0,0 +1,112 @@ +/* + * Copyright 2019 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 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..dd00d2bfb --- /dev/null +++ b/src/main/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandler.java @@ -0,0 +1,112 @@ +/* + * Copyright 2019 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 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/RevivingScheduledExecutor.java b/src/main/java/com/google/firebase/internal/RevivingScheduledExecutor.java deleted file mode 100644 index efa989943..000000000 --- a/src/main/java/com/google/firebase/internal/RevivingScheduledExecutor.java +++ /dev/null @@ -1,213 +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 static com.google.common.base.Preconditions.checkNotNull; - -import com.google.common.annotations.VisibleForTesting; - -import java.security.AccessControlException; -import java.util.concurrent.Callable; -import java.util.concurrent.CancellationException; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -import java.util.concurrent.RunnableScheduledFuture; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * RevivingScheduledExecutor is an implementation of ScheduledThreadPoolExecutor that uses one - * periodically restarting worker thread as its work queue. This allows customers of this class to - * use this executor on App Engine despite App Engine's thread-lifetime limitations. - */ -public class RevivingScheduledExecutor extends ScheduledThreadPoolExecutor { - - private static final Logger logger = LoggerFactory.getLogger(RevivingScheduledExecutor.class); - - /** Exception to throw to shut down the core threads. */ - private static final RuntimeException REVIVE_THREAD_EXCEPTION = new RuntimeException( - "Restarting Firebase Worker Thread. This exception is expected to occur periodically " - + "when deployed in the App Engine environment, and can be ignored."); - - /** The lifetime of a thread. Maximum lifetime of a thread on GAE is 24 hours. */ - private static final long PERIODIC_RESTART_INTERVAL_MS = TimeUnit.HOURS.toMillis(12); - - /** - * Time by which we offset restarts to ensure that not all threads die at the same time. This is - * meant to decrease cross-thread liveliness issues during restarts. - */ - private static final long PERIODIC_RESTART_OFFSET_MS = TimeUnit.MINUTES.toMillis(5); - - private static final AtomicInteger INSTANCE_COUNTER = new AtomicInteger(0); - - private final long initialDelayMs; - private final long timeoutMs; - - // Flag set before throwing a REVIVE_THREAD_EXCEPTION and unset once a new thread has been - // created. Used to call afterRestart() appropriately. - private AtomicBoolean requestedRestart = new AtomicBoolean(); - - /** - * Creates a new RevivingScheduledExecutor that optionally restarts its worker thread every twelve - * hours. - * - * @param threadFactory Thread factory to use to restart threads. - * @param threadName Name of the threads in the pool. - * @param periodicRestart Periodically restart its worked threads. - */ - public RevivingScheduledExecutor( - final ThreadFactory threadFactory, final String threadName, final boolean periodicRestart) { - this( - threadFactory, - threadName, - periodicRestart ? PERIODIC_RESTART_OFFSET_MS * INSTANCE_COUNTER.get() : 0, - periodicRestart ? PERIODIC_RESTART_INTERVAL_MS : -1); - } - - @VisibleForTesting - RevivingScheduledExecutor( - final ThreadFactory threadFactory, - final String threadName, - final long initialDelayMs, - final long timeoutMs) { - super(0); - checkNotNull(threadFactory, "threadFactory must not be null"); - INSTANCE_COUNTER.incrementAndGet(); - this.initialDelayMs = initialDelayMs; - this.timeoutMs = timeoutMs; - setRemoveOnCancelPolicy(true); - setThreadFactory( - new ThreadFactory() { - @Override - public Thread newThread(Runnable r) { - logger.debug("Creating new thread for: {}", threadName); - Thread thread = threadFactory.newThread(r); - try { - thread.setName(threadName); - thread.setDaemon(true); - } catch (AccessControlException ignore) { - // Unsupported on App Engine. - } - if (requestedRestart.getAndSet(false)) { - afterRestart(); - } - return thread; - } - }); - } - - @Override - public void execute(Runnable runnable) { - // This gets called when the execute() method from Executor is directly invoked. - ensureRunning(); - super.execute(runnable); - } - - @Override - protected RunnableScheduledFuture decorateTask( - Runnable runnable, RunnableScheduledFuture task) { - // This gets called by ScheduledThreadPoolExecutor before scheduling a Runnable. - ensureRunning(); - return task; - } - - @Override - protected RunnableScheduledFuture decorateTask( - Callable callable, RunnableScheduledFuture task) { - // This gets called by ScheduledThreadPoolExecutor before scheduling a Callable. - ensureRunning(); - return task; - } - - @Override - protected void afterExecute(Runnable runnable, Throwable throwable) { - super.afterExecute(runnable, throwable); - if (throwable == null && runnable instanceof Future) { - Future future = (Future) runnable; - try { - // Not all Futures will be done, e.g. when used with scheduledAtFixedRate - if (future.isDone()) { - future.get(); - } - } catch (CancellationException ce) { - // Cancellation exceptions are okay, we expect them to happen sometimes - } catch (ExecutionException ee) { - throwable = ee.getCause(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - if (throwable == REVIVE_THREAD_EXCEPTION) { - // Re-throwing this exception will kill the thread and cause - // ScheduledThreadPoolExecutor to - // spawn a new thread. - throw (RuntimeException) throwable; - } else if (throwable != null) { - handleException(throwable); - } - } - - /** - * Called when an exception occurs during execution of a Runnable/Callable. The default - * implementation does nothing. - */ - protected void handleException(Throwable throwable) {} - - /** Called before the worker thread gets shutdown before a restart. */ - protected void beforeRestart() {} - - /** Called after the worker thread got recreated after a restart. */ - protected void afterRestart() {} - - private synchronized void ensureRunning() { - if (getCorePoolSize() == 0) { - setCorePoolSize(1); - schedulePeriodicShutdown(); - } - } - - private void schedulePeriodicShutdown() { - if (timeoutMs >= 0) { - @SuppressWarnings("unused") - Future possiblyIgnoredError = - schedule( - new Runnable() { - @Override - public void run() { - // We have to manually reschedule this task here as periodic tasks get - // cancelled after - // throwing exceptions. - @SuppressWarnings("unused") - Future possiblyIgnoredError1 = - RevivingScheduledExecutor.this.schedule( - this, timeoutMs, TimeUnit.MILLISECONDS); - requestedRestart.set(true); - beforeRestart(); - throw REVIVE_THREAD_EXCEPTION; - } - }, - initialDelayMs + timeoutMs, - TimeUnit.MILLISECONDS); - } - } -} diff --git a/src/main/java/com/google/firebase/internal/SdkUtils.java b/src/main/java/com/google/firebase/internal/SdkUtils.java index 2fa5e5767..cd9619087 100644 --- a/src/main/java/com/google/firebase/internal/SdkUtils.java +++ b/src/main/java/com/google/firebase/internal/SdkUtils.java @@ -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/TaskToApiFuture.java b/src/main/java/com/google/firebase/internal/TaskToApiFuture.java deleted file mode 100644 index 238ba43a3..000000000 --- a/src/main/java/com/google/firebase/internal/TaskToApiFuture.java +++ /dev/null @@ -1,82 +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 static com.google.common.base.Preconditions.checkNotNull; - -import com.google.api.core.ApiFuture; -import com.google.firebase.tasks.OnCompleteListener; -import com.google.firebase.tasks.Task; -import com.google.firebase.tasks.Tasks; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executor; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -/** - * An ApiFuture implementation that wraps a {@link Task}. This is an interim solution that enables - * us to expose Tasks as ApiFutures, until we fully remove the Task API. - * - * @param Type of the result produced by this Future. - */ -public class TaskToApiFuture implements ApiFuture { - - private final Task task; - private boolean cancelled; - - public TaskToApiFuture(Task task) { - this.task = checkNotNull(task, "task must not be null"); - } - - @Override - public void addListener(final Runnable runnable, Executor executor) { - task.addOnCompleteListener(executor, new OnCompleteListener() { - @Override - public void onComplete(Task task) { - runnable.run(); - } - }); - } - - @Override - public boolean cancel(boolean mayInterruptIfRunning) { - // Cannot be supported with Tasks - cancelled = true; - return false; - } - - @Override - public boolean isCancelled() { - return false; - } - - @Override - public boolean isDone() { - return cancelled || task.isComplete(); - } - - @Override - public T get() throws InterruptedException, ExecutionException { - return Tasks.await(task); - } - - @Override - public T get(long timeout, @NonNull TimeUnit unit) - throws InterruptedException, ExecutionException, TimeoutException { - return Tasks.await(task, timeout, unit); - } -} diff --git a/src/main/java/com/google/firebase/messaging/AndroidConfig.java b/src/main/java/com/google/firebase/messaging/AndroidConfig.java index 065376a79..e38e561d0 100644 --- a/src/main/java/com/google/firebase/messaging/AndroidConfig.java +++ b/src/main/java/com/google/firebase/messaging/AndroidConfig.java @@ -49,6 +49,12 @@ public class AndroidConfig { @Key("notification") private final AndroidNotification notification; + @Key("fcm_options") + private final AndroidFcmOptions fcmOptions; + + @Key("direct_boot_ok") + private final Boolean directBootOk; + private AndroidConfig(Builder builder) { this.collapseKey = builder.collapseKey; if (builder.priority != null) { @@ -71,6 +77,8 @@ 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; } /** @@ -98,13 +106,18 @@ public static class Builder { private String restrictedPackageName; private final Map data = new HashMap<>(); private AndroidNotification notification; + private AndroidFcmOptions fcmOptions; + private Boolean directBootOk; 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 +200,24 @@ 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; + } + /** * 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..b509a9843 100644 --- a/src/main/java/com/google/firebase/messaging/AndroidNotification.java +++ b/src/main/java/com/google/firebase/messaging/AndroidNotification.java @@ -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 cc486c2c6..39092ac17 100644 --- a/src/main/java/com/google/firebase/messaging/ApnsConfig.java +++ b/src/main/java/com/google/firebase/messaging/ApnsConfig.java @@ -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"), @@ -45,8 +51,10 @@ private ApnsConfig(Builder builder) { this.headers = builder.headers.isEmpty() ? null : ImmutableMap.copyOf(builder.headers); this.payload = ImmutableMap.builder() .putAll(builder.customData) - .put("aps", builder.aps) + .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 c35d550d4..10c5af181 100644 --- a/src/main/java/com/google/firebase/messaging/Aps.java +++ b/src/main/java/com/google/firebase/messaging/Aps.java @@ -18,8 +18,11 @@ 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 com.google.firebase.internal.NonNull; +import java.util.HashMap; +import java.util.Map; /** * Represents the @@ -27,37 +30,45 @@ */ public class Aps { - @Key("alert") - private final Object alert; - - @Key("badge") - private final Integer badge; - - @Key("sound") - private final String sound; - - @Key("content-available") - private final Integer contentAvailable; - - @Key("category") - private final String category; - - @Key("thread-id") - private final String threadId; + private final Map fields; 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) { - this.alert = builder.alert; - } else { - this.alert = builder.alertString; - } - this.badge = builder.badge; - this.sound = builder.sound; - this.contentAvailable = builder.contentAvailable ? 1 : null; - this.category = builder.category; - this.threadId = builder.threadId; + fields.put("alert", builder.alert); + } else if (builder.alertString != null) { + fields.put("alert", builder.alertString); + } + if (builder.badge != null) { + fields.put("badge", builder.badge); + } + 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); + } + if (builder.mutableContent) { + fields.put("mutable-content", 1); + } + if (builder.category != null) { + fields.put("category", builder.category); + } + if (builder.threadId != null) { + fields.put("thread-id", builder.threadId); + } + fields.putAll(builder.customData); + this.fields = fields.build(); + } + + Map getFields() { + return this.fields; } /** @@ -75,9 +86,12 @@ 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; private String threadId; + private final Map customData = new HashMap<>(); private Builder() {} @@ -116,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. @@ -126,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. * @@ -137,6 +163,41 @@ public Builder setContentAvailable(boolean contentAvailable) { return this; } + /** + * Specifies whether to set the {@code mutable-content} property on the message. When set, this + * property allows clients to modify the notification via app extensions. + * + * @param mutableContent True to make the content mutable via app extensions. + * @return This builder. + */ + public Builder setMutableContent(boolean mutableContent) { + this.mutableContent = mutableContent; + return this; + } + + /** + * Puts a custom key-value pair to the aps dictionary. + * + * @param key A non-null key. + * @param value A non-null, json-serializable value. + * @return This builder. + */ + public Builder putCustomData(@NonNull String key, @NonNull Object value) { + this.customData.put(key, value); + return this; + } + + /** + * Puts all the key-value pairs in the specified map to the aps dictionary. + * + * @param fields A non-null map. Map must not contain null keys or values. + * @return This builder. + */ + public Builder putAllCustomData(@NonNull Map fields) { + this.customData.putAll(fields); + return this; + } + /** * Sets the notification type. * @@ -159,6 +220,13 @@ public Builder setThreadId(String threadId) { return this; } + /** + * Builds a new {@link Aps} instance from the fields set on this builder. + * + * @return A non-null {@link Aps}. + * @throws IllegalArgumentException If the alert is specified both as an object and a string. + * Or if the same field is set both using a setter method, and as a custom field. + */ public Aps build() { return new Aps(this); } diff --git a/src/main/java/com/google/firebase/messaging/ApsAlert.java b/src/main/java/com/google/firebase/messaging/ApsAlert.java index 6f7e249f3..2efa94433 100644 --- a/src/main/java/com/google/firebase/messaging/ApsAlert.java +++ b/src/main/java/com/google/firebase/messaging/ApsAlert.java @@ -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..164403be4 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/BatchResponse.java @@ -0,0 +1,35 @@ +/* + * Copyright 2019 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.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..99cf63df1 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/BatchResponseImpl.java @@ -0,0 +1,58 @@ +/* + * Copyright 2020 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.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..ed293ba96 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/CriticalSound.java @@ -0,0 +1,115 @@ +/* + * Copyright 2018 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.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 21ab3898f..870940f77 100644 --- a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java +++ b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java @@ -18,36 +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.auth.http.HttpCredentialsAdapter; -import com.google.auth.oauth2.GoogleCredentials; +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.FirebaseService; import com.google.firebase.internal.NonNull; -import com.google.firebase.internal.TaskToApiFuture; -import com.google.firebase.tasks.Task; -import java.io.IOException; + +import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; /** * This class is the entry point for all server-side Firebase Cloud Messaging actions. @@ -57,62 +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 HttpResponseInterceptor interceptor; - - private FirebaseMessaging(FirebaseApp app) { - HttpTransport httpTransport = app.getOptions().getHttpTransport(); - GoogleCredentials credentials = ImplFirebaseTrampolines.getCredentials(app); - this.app = app; - this.requestFactory = httpTransport.createRequestFactory( - new HttpCredentialsAdapter(credentials)); - 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 GCLOUD_PROJECT environment variable."); - this.url = String.format(FCM_URL, projectId); + private final Supplier messagingClient; + private final Supplier instanceIdClient; + + private FirebaseMessaging(Builder builder) { + this.app = checkNotNull(builder.firebaseApp); + this.messagingClient = Suppliers.memoize(builder.messagingClient); + this.instanceIdClient = Suppliers.memoize(builder.instanceIdClient); } /** @@ -142,6 +83,36 @@ public static synchronized FirebaseMessaging getInstance(FirebaseApp app) { * Sends the given {@link Message} via Firebase Cloud Messaging. * * @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); + } + + /** + * 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. 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(); + } + + /** + * Similar to {@link #send(Message)} but performs the operation asynchronously. + * + * @param message A non-null {@link Message} to be sent. * @return An {@code ApiFuture} that will complete with a message ID string when the message * has been sent. */ @@ -150,10 +121,7 @@ public ApiFuture sendAsync(@NonNull Message message) { } /** - * 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. + * Similar to {@link #send(Message, boolean)} but performs the operation asynchronously. * * @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. @@ -161,17 +129,394 @@ public ApiFuture sendAsync(@NonNull Message message) { * has been sent, or when the emulation has finished. */ public ApiFuture sendAsync(@NonNull Message message, boolean dryRun) { - return new TaskToApiFuture<>(send(message, dryRun)); + return sendOp(message, dryRun).callAsync(app); } - private Task send(final Message message, final boolean dryRun) { + private CallableOperation sendOp( + final Message message, final boolean dryRun) { checkNotNull(message, "message must not be null"); - return ImplFirebaseTrampolines.submitCallable(app, new Callable() { + 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 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 in the {@link MulticastMessage}. + * + * @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 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 in the {@link MulticastMessage}. + * + * @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 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 + * in the {@link MulticastMessage}. + * + * @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 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 + * in the {@link MulticastMessage}. + * + * @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 - public String call() throws FirebaseMessagingException { - return makeSendRequest(message, dryRun); + protected BatchResponse execute() throws FirebaseMessagingException { + return messagingClient.sendAll(messages, dryRun); } - }); + }; + } + + @VisibleForTesting + FirebaseMessagingClient getMessagingClient() { + return messagingClient.get(); } /** @@ -180,28 +525,55 @@ public String call() throws FirebaseMessagingException { * @param registrationTokens A non-null, non-empty list of device registration tokens, with at * most 1000 entries. * @param topic Name of the topic to subscribe to. May contain the {@code /topics/} prefix. + * @return A {@link TopicManagementResponse}. + */ + public TopicManagementResponse subscribeToTopic(@NonNull List registrationTokens, + @NonNull String topic) throws FirebaseMessagingException { + return subscribeOp(registrationTokens, topic).call(); + } + + /** + * Similar to {@link #subscribeToTopic(List, String)} but performs the operation asynchronously. + * + * @param registrationTokens A non-null, non-empty list of device registration tokens, with at + * most 1000 entries. + * @param topic Name of the topic to subscribe to. May contain the {@code /topics/} prefix. * @return An {@code ApiFuture} that will complete with a {@link TopicManagementResponse}. */ public ApiFuture subscribeToTopicAsync( @NonNull List registrationTokens, @NonNull String topic) { - return new TaskToApiFuture<>(subscribeToTopic(registrationTokens, topic)); + return subscribeOp(registrationTokens, topic).callAsync(app); } - private Task subscribeToTopic( + private CallableOperation subscribeOp( final List registrationTokens, final String topic) { checkRegistrationTokens(registrationTokens); checkTopic(topic); - - return ImplFirebaseTrampolines.submitCallable(app, new Callable() { + final InstanceIdClient instanceIdClient = getInstanceIdClient(); + return new CallableOperation() { @Override - public TopicManagementResponse call() throws FirebaseMessagingException { - return makeTopicManagementRequest(registrationTokens, topic, IID_SUBSCRIBE_PATH); + 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. + * @param topic Name of the topic to unsubscribe from. May contain the {@code /topics/} prefix. + * @return A {@link TopicManagementResponse}. + */ + public TopicManagementResponse unsubscribeFromTopic(@NonNull List registrationTokens, + @NonNull String topic) throws FirebaseMessagingException { + return unsubscribeOp(registrationTokens, topic).call(); + } + + /** + * Similar to {@link #unsubscribeFromTopic(List, String)} but performs the operation + * asynchronously. * * @param registrationTokens A non-null, non-empty list of device registration tokens, with at * most 1000 entries. @@ -210,140 +582,28 @@ public TopicManagementResponse call() throws FirebaseMessagingException { */ public ApiFuture unsubscribeFromTopicAsync( @NonNull List registrationTokens, @NonNull String topic) { - return new TaskToApiFuture<>(unsubscribeFromTopic(registrationTokens, topic)); + return unsubscribeOp(registrationTokens, topic).callAsync(app); } - private Task unsubscribeFromTopic( + private CallableOperation unsubscribeOp( final List registrationTokens, final String topic) { checkRegistrationTokens(registrationTokens); checkTopic(topic); - - return ImplFirebaseTrampolines.submitCallable(app, new Callable() { + final InstanceIdClient instanceIdClient = getInstanceIdClient(); + return new CallableOperation() { @Override - public TopicManagementResponse call() throws FirebaseMessagingException { - return makeTopicManagementRequest(registrationTokens, topic, IID_UNSUBSCRIBE_PATH); + protected TopicManagementResponse execute() throws FirebaseMessagingException { + return instanceIdClient.unsubscribeFromTopic(topic, registrationTokens); } - }); - } - - private String makeSendRequest(Message message, - boolean dryRun) throws FirebaseMessagingException { - ImmutableMap.Builder payload = ImmutableMap.builder() - .put("message", message); - if (dryRun) { - payload.put("validate_only", true); - } - HttpResponse response = null; - 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); - } - } - - private void handleSendHttpError(HttpResponseException e) throws FirebaseMessagingException { - MessagingServiceErrorResponse response = new MessagingServiceErrorResponse(); - try { - JsonParser parser = jsonFactory.createJsonParser(e.getContent()); - parser.parseAndClose(response); - } catch (IOException ignored) { - // ignored - } - - String code = FCM_ERROR_CODES.get(response.getErrorCode()); - if (code == null) { - code = UNKNOWN_ERROR; - } - String msg = response.getErrorMessage(); - 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 TopicManagementResponse makeTopicManagementRequest(List registrationTokens, - String topic, String path) throws FirebaseMessagingException { - if (!topic.startsWith("/topics/")) { - topic = "/topics/" + topic; - } - Map payload = ImmutableMap.of( - "to", topic, - "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); - } - } - - 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, @@ -354,75 +614,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..da049565d --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java @@ -0,0 +1,32 @@ +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; + +} 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..1271bf98b --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java @@ -0,0 +1,348 @@ +/* + * Copyright 2019 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.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.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 FCM_URL = "https://fcm.googleapis.com/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 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.fcmSendUrl = String.format(FCM_URL, 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); + } + }; + } + + 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 HttpRequestFactory requestFactory; + private HttpRequestFactory childRequestFactory; + private JsonFactory jsonFactory; + private HttpResponseInterceptor responseInterceptor; + + private Builder() { } + + Builder setProjectId(String projectId) { + this.projectId = projectId; + 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..a3d92788a 100644 --- a/src/main/java/com/google/firebase/messaging/FirebaseMessagingException.java +++ b/src/main/java/com/google/firebase/messaging/FirebaseMessagingException.java @@ -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..5648fcf0c --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/InstanceIdClientImpl.java @@ -0,0 +1,165 @@ +/* + * Copyright 2019 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.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..e0e898374 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/LightSettings.java @@ -0,0 +1,128 @@ +/* + * Copyright 2019 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.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..cfec64995 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/LightSettingsColor.java @@ -0,0 +1,71 @@ +/* + * Copyright 2019 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.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..c69abaeef 100644 --- a/src/main/java/com/google/firebase/messaging/Message.java +++ b/src/main/java/com/google/firebase/messaging/Message.java @@ -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; @@ -62,6 +63,9 @@ public class Message { @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; @@ -77,6 +81,61 @@ private Message(Builder builder) { this.token = builder.token; 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; + } + + @VisibleForTesting + String getToken() { + return token; + } + + @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) { @@ -110,6 +169,7 @@ public static class Builder { private String token; private String topic; private String condition; + private FcmOptions fcmOptions; private Builder() {} @@ -216,6 +276,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 +295,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..b15e58e5f --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/MulticastMessage.java @@ -0,0 +1,221 @@ +/* + * Copyright 2019 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.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 to which the + * message should be sent. A single {@code MulticastMessage} may contain up to 500 registration + * tokens. + * + *

    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. 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 + * tokens were added to the {@link MulticastMessage.Builder}. + */ +public class MulticastMessage { + + private final List tokens; + 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(); + checkArgument(!this.tokens.isEmpty(), "at least one token must be specified"); + checkArgument(this.tokens.size() <= 500, "no more than 500 tokens can be specified"); + for (String token : this.tokens) { + checkArgument(!Strings.isNullOrEmpty(token), "none of the tokens 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() { + 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); + } + ImmutableList.Builder messages = ImmutableList.builder(); + for (String token : this.tokens) { + messages.add(builder.setToken(token).build()); + } + return messages.build(); + } + + /** + * Creates a new {@link MulticastMessage.Builder}. + * + * @return A {@link MulticastMessage.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private final ImmutableList.Builder tokens = 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 can be specified on + * a single instance of {@link MulticastMessage}. + * + * @param token A non-null, non-empty Firebase device registration token. + * @return This builder. + */ + public Builder addToken(@NonNull String token) { + this.tokens.add(token); + return this; + } + + /** + * Adds a collection of tokens to which the message should be sent. Up to 500 tokens can be + * specified on a single instance of {@link MulticastMessage}. + * + * @param tokens Collection of Firebase device registration tokens. + * @return This builder. + */ + public Builder addAllTokens(@NonNull Collection tokens) { + this.tokens.addAll(tokens); + 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..a8f48c2ef 100644 --- a/src/main/java/com/google/firebase/messaging/Notification.java +++ b/src/main/java/com/google/firebase/messaging/Notification.java @@ -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..b179df2b2 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/SendResponse.java @@ -0,0 +1,79 @@ +/* + * Copyright 2019 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.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..9f75f5871 100644 --- a/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java +++ b/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java @@ -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++) { @@ -94,8 +99,11 @@ public static class Error { private 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 +124,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 6b5ac4ac2..9cde9d041 100644 --- a/src/main/java/com/google/firebase/messaging/WebpushConfig.java +++ b/src/main/java/com/google/firebase/messaging/WebpushConfig.java @@ -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; @@ -35,12 +36,16 @@ public class WebpushConfig { private final Map data; @Key("notification") - private final WebpushNotification 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; + 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 92876c562..d17380543 100644 --- a/src/main/java/com/google/firebase/messaging/WebpushNotification.java +++ b/src/main/java/com/google/firebase/messaging/WebpushNotification.java @@ -16,23 +16,28 @@ package com.google.firebase.messaging; +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.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.internal.NonNull; import com.google.firebase.internal.Nullable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * Represents the Webpush-specific notification options that can be included in a {@link Message}. - * Instances of this class are thread-safe and immutable. + * Instances of this class are thread-safe and immutable. Supports most standard options defined + * in the Web + * Notification specification. */ public class WebpushNotification { - @Key("title") - private final String title; - - @Key("body") - private final String body; - - @Key("icon") - private final String icon; + private final Map fields; /** * Creates a new notification with the given title and body. Overrides the options set via @@ -54,8 +59,347 @@ public WebpushNotification(String title, String body) { * @param icon URL to the notifications icon. */ public WebpushNotification(String title, String body, @Nullable String icon) { - this.title = title; - this.body = body; - this.icon = icon; + this(builder().setTitle(title).setBody(body).setIcon(icon)); + } + + private WebpushNotification(Builder builder) { + ImmutableMap.Builder fields = ImmutableMap.builder(); + if (!builder.actions.isEmpty()) { + fields.put("actions", ImmutableList.copyOf(builder.actions)); + } + addNonNullNonEmpty(fields, "badge", builder.badge); + addNonNullNonEmpty(fields, "body", builder.body); + addNonNull(fields, "data", builder.data); + addNonNullNonEmpty(fields, "dir", builder.direction != null ? builder.direction.value : null); + addNonNullNonEmpty(fields, "icon", builder.icon); + addNonNullNonEmpty(fields, "image", builder.image); + addNonNullNonEmpty(fields, "lang", builder.language); + addNonNull(fields, "renotify", builder.renotify); + addNonNull(fields, "requireInteraction", builder.requireInteraction); + addNonNull(fields, "silent", builder.silent); + addNonNullNonEmpty(fields, "tag", builder.tag); + addNonNull(fields, "timestamp", builder.timestampMillis); + addNonNullNonEmpty(fields, "title", builder.title); + addNonNull(fields, "vibrate", builder.vibrate); + fields.putAll(builder.customData); + this.fields = fields.build(); + } + + Map getFields() { + return fields; + } + + /** + * Creates a new {@link WebpushNotification.Builder}. + * + * @return A {@link WebpushNotification.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Different directions a notification can be displayed in. + */ + public enum Direction { + AUTO("auto"), + LEFT_TO_RIGHT("ltr"), + RIGHT_TO_LEFT("rtl"); + + final String value; + + Direction(String value) { + this.value = value; + } + } + + /** + * Represents an action available to users when the notification is presented. + */ + public static class Action { + @Key("action") + private final String action; + + @Key("title") + private final String title; + + @Key("icon") + private final String icon; + + /** + * Creates a new Action with the given action string and title. + * + * @param action Action string. + * @param title Title text. + */ + public Action(String action, String title) { + this(action, title, null); + } + + /** + * Creates a new Action with the given action string, title and icon URL. + * + * @param action Action string. + * @param title Title text. + * @param icon Icon URL or null. + */ + public Action(String action, String title, @Nullable String icon) { + checkArgument(!Strings.isNullOrEmpty(action)); + checkArgument(!Strings.isNullOrEmpty(title)); + this.action = action; + this.title = title; + this.icon = icon; + } + } + + public static class Builder { + + private final List actions = new ArrayList<>(); + private String badge; + private String body; + private Object data; + private Direction direction; + private String icon; + private String image; + private String language; + private Boolean renotify; + private Boolean requireInteraction; + private Boolean silent; + private String tag; + private Long timestampMillis; + private String title; + private List vibrate; + private final Map customData = new HashMap<>(); + + private Builder() {} + + /** + * Adds a notification action to the notification. + * + * @param action A non-null {@link Action}. + * @return This builder. + */ + public Builder addAction(@NonNull Action action) { + this.actions.add(action); + return this; + } + + /** + * Adds all the actions in the given list to the notification. + * + * @param actions A non-null list of actions. + * @return This builder. + */ + public Builder addAllActions(@NonNull List actions) { + this.actions.addAll(actions); + return this; + } + + /** + * Sets the URL of the image used to represent the notification when there is + * not enough space to display the notification itself. + * + * @param badge Badge URL. + * @return This builder. + */ + public Builder setBadge(String badge) { + this.badge = badge; + return this; + } + + /** + * Sets the body text of the notification. + * + * @param body Body text. + * @return This builder. + */ + public Builder setBody(String body) { + this.body = body; + return this; + } + + /** + * Sets any arbitrary data that should be associated with the notification. + * + * @param data A JSON-serializable object. + * @return This builder. + */ + public Builder setData(Object data) { + this.data = data; + return this; + } + + /** + * Sets the direction in which to display the notification. + * + * @param direction Direction enum value. + * @return This builder. + */ + public Builder setDirection(Direction direction) { + this.direction = direction; + return this; + } + + /** + * Sets the URL to the icon of the notification. + * + * @param icon Icon URL. + * @return This builder. + */ + public Builder setIcon(String icon) { + this.icon = icon; + return this; + } + + /** + * Sets the URL of an image to be displayed in the notification. + * + * @param image Image URL + * @return This builder. + */ + public Builder setImage(String image) { + this.image = image; + return this; + } + + /** + * Sets the language of the notification. + * + * @param language Notification language. + * @return This builder. + */ + public Builder setLanguage(String language) { + this.language = language; + return this; + } + + /** + * Sets whether the user should be notified after a new notification replaces an old one. + * + * @param renotify true to notify the user on replacement. + * @return This builder. + */ + public Builder setRenotify(boolean renotify) { + this.renotify = renotify; + return this; + } + + /** + * Sets whether a notification should remain active until the user clicks or dismisses it, + * rather than closing automatically. + * + * @param requireInteraction true to keep the notification active until user interaction. + * @return This builder. + */ + public Builder setRequireInteraction(boolean requireInteraction) { + this.requireInteraction = requireInteraction; + return this; + } + + /** + * Sets whether the notification should be silent. + * + * @param silent true to indicate that the notification should be silent. + * @return This builder. + */ + public Builder setSilent(boolean silent) { + this.silent = silent; + return this; + } + + /** + * Sets an identifying tag on the notification. + * + * @param tag A tag to be associated with the notification. + * @return This builder. + */ + public Builder setTag(String tag) { + this.tag = tag; + return this; + } + + /** + * Sets a timestamp value in milliseconds on the notification. + * + * @param timestampMillis A timestamp value as a number. + * @return This builder. + */ + public Builder setTimestampMillis(long timestampMillis) { + this.timestampMillis = timestampMillis; + return this; + } + + /** + * Sets the title text of the notification. + * + * @param title Title text. + * @return This builder. + */ + public Builder setTitle(String title) { + this.title = title; + return this; + } + + /** + * Sets a vibration pattern for the device's vibration hardware to emit + * when the notification fires. + * + * @param pattern An integer array representing a vibration pattern. + * @return This builder. + */ + public Builder setVibrate(int[] pattern) { + List list = new ArrayList<>(); + for (int value : pattern) { + list.add(value); + } + this.vibrate = ImmutableList.copyOf(list); + return this; + } + + /** + * Puts a custom key-value pair to the notification. + * + * @param key A non-null key. + * @param value A non-null, json-serializable value. + * @return This builder. + */ + public Builder putCustomData(@NonNull String key, @NonNull Object value) { + this.customData.put(key, value); + return this; + } + + /** + * Puts all the key-value pairs in the specified map to the notification. + * + * @param fields A non-null map. Map must not contain null keys or values. + * @return This builder. + */ + public Builder putAllCustomData(@NonNull Map fields) { + this.customData.putAll(fields); + return this; + } + + /** + * Creates a new {@link WebpushNotification} from the parameters set on this builder. + * + * @return A new {@link WebpushNotification} instance. + */ + public WebpushNotification build() { + return new WebpushNotification(this); + } + } + + private static void addNonNull( + ImmutableMap.Builder fields, String key, Object value) { + if (value != null) { + fields.put(key, value); + } + } + + private static void addNonNullNonEmpty( + ImmutableMap.Builder fields, String key, String value) { + if (!Strings.isNullOrEmpty(value)) { + fields.put(key, value); + } } } 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/database/logging/package-info.java b/src/main/java/com/google/firebase/messaging/internal/package-info.java similarity index 88% rename from src/main/java/com/google/firebase/database/logging/package-info.java rename to src/main/java/com/google/firebase/messaging/internal/package-info.java index 37bc5cf37..5ef722b72 100644 --- a/src/main/java/com/google/firebase/database/logging/package-info.java +++ b/src/main/java/com/google/firebase/messaging/internal/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2019 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,4 +17,4 @@ /** * @hide */ -package com.google.firebase.database.logging; +package com.google.firebase.messaging.internal; 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..dd7a00e15 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/AndroidApp.java @@ -0,0 +1,166 @@ +/* Copyright 2018 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.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..055994d51 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/AndroidAppMetadata.java @@ -0,0 +1,114 @@ +/* Copyright 2018 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.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..081b4467c --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/AndroidAppService.java @@ -0,0 +1,175 @@ +/* Copyright 2018 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.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..9e294e099 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagement.java @@ -0,0 +1,290 @@ +/* + * Copyright 2018 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.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..580ae76f6 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementException.java @@ -0,0 +1,41 @@ +/* Copyright 2018 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.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..2bf927008 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java @@ -0,0 +1,790 @@ +/* Copyright 2018 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.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..fb29f7b86 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/HttpHelper.java @@ -0,0 +1,126 @@ +/* + * Copyright 2018 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.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..afd54e711 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/IosApp.java @@ -0,0 +1,103 @@ +/* + * Copyright 2018 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.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..5de66f7ac --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/IosAppMetadata.java @@ -0,0 +1,112 @@ +/* Copyright 2018 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.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..c1e68faf8 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/IosAppService.java @@ -0,0 +1,115 @@ +/* Copyright 2018 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.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..3df12a94a --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/Scheduler.java @@ -0,0 +1,25 @@ +/* + * Copyright 2019 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.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..d587f8153 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/ShaCertificate.java @@ -0,0 +1,125 @@ +/* Copyright 2018 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.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..a367ee410 --- /dev/null +++ b/src/main/java/com/google/firebase/projectmanagement/ShaCertificateType.java @@ -0,0 +1,26 @@ +/* Copyright 2018 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.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