diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000000..31a1bf2c79 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "src/test/**" # Tests diff --git a/.github/workflows/build-authenticator.yml b/.github/workflows/build-authenticator.yml index 5e9197cc5d..a3ba414e1f 100644 --- a/.github/workflows/build-authenticator.yml +++ b/.github/workflows/build-authenticator.yml @@ -1,13 +1,289 @@ name: Build Authenticator on: + push: + branches: + - main workflow_dispatch: + inputs: + version-name: + description: "Optional. Version string to use, in X.Y.Z format. Overrides default in the project." + required: false + type: string + version-code: + description: "Optional. Build number to use. Overrides default of GitHub run number." + required: false + type: number + distribute-to-firebase: + description: "Optional. Distribute artifacts to Firebase." + required: false + default: false + type: boolean + publish-to-play-store: + description: "Optional. Deploy bundle artifact to Google Play Store" + required: false + default: false + type: boolean + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JAVA_VERSION: 17 jobs: - placeholder: - name: Placeholder Job + build: + name: Build Authenticator runs-on: ubuntu-24.04 steps: - - name: Placeholder Step - run: echo "placeholder workflow" + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2 + + - name: Cache Gradle files + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }} + restore-keys: | + ${{ runner.os }}-gradle-v2- + + - name: Cache build output + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: | + ${{ github.workspace }}/build-cache + key: ${{ runner.os }}-build-cache-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-build- + + - name: Configure JDK + uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0 + with: + distribution: "temurin" + java-version: ${{ env.JAVA_VERSION }} + + - name: Configure Ruby + uses: ruby/setup-ruby@28c4deda893d5a96a6b2d958c5b47fc18d65c9d3 # v1.213.0 + with: + bundler-cache: true + + - name: Install Fastlane + run: | + gem install bundler:2.2.27 + bundle config path vendor/bundle + bundle install --jobs 4 --retry 3 + + - name: Check Authenticator + run: bundle exec fastlane checkAuthenticator + + - name: Build Authenticator + run: bundle exec fastlane buildAuthenticatorDebug + + publish_playstore: + name: Publish Authenticator Play Store artifacts + needs: + - build + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + variant: ["aab", "apk"] + + steps: + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Configure Ruby + uses: ruby/setup-ruby@28c4deda893d5a96a6b2d958c5b47fc18d65c9d3 # v1.213.0 + with: + bundler-cache: true + + - name: Install Fastlane + run: | + gem install bundler:2.2.27 + bundle config path vendor/bundle + bundle install --jobs 4 --retry 3 + + - name: Log in to Azure + uses: Azure/login@cb79c773a3cfa27f31f25eb3f677781210c9ce3d # v1.6.1 + with: + creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + + - name: Retrieve secrets + env: + ACCOUNT_NAME: bitwardenci + CONTAINER_NAME: mobile + run: | + mkdir -p ${{ github.workspace }}/secrets + mkdir -p ${{ github.workspace }}/keystores + + az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + --name authenticator_apk-keystore.jks --file ${{ github.workspace }}/keystores/authenticator_apk-keystore.jks --output none + + az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + --name authenticator_aab-keystore.jks --file ${{ github.workspace }}/keystores/authenticator_aab-keystore.jks --output none + + az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + --name com.bitwarden.authenticator-google-services.json --file ${{ github.workspace }}/authenticator/src/google-services.json --output none + + az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + --name com.bitwarden.authenticator.dev-google-services.json --file ${{ github.workspace }}/authenticator/src/debug/google-services.json --output none + + - name: Download Firebase credentials + if : ${{ inputs.distribute-to-firebase || github.event_name == 'push' }} + env: + ACCOUNT_NAME: bitwardenci + CONTAINER_NAME: mobile + run: | + mkdir -p ${{ github.workspace }}/secrets + + az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + --name authenticator_play_firebase-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json --output none + + - name: Download Play Store credentials + if: ${{ inputs.publish-to-play-store }} + env: + ACCOUNT_NAME: bitwardenci + CONTAINER_NAME: mobile + run: | + mkdir -p ${{ github.workspace }}/secrets + + az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + --name authenticator_play_store-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_store-creds.json --output none + + - name: Verify Play Store credentials + if: ${{ inputs.publish-to-play-store }} + run: | + bundle exec fastlane run validate_play_store_json_key \ + json_key:${{ github.workspace }}/secrets/authenticator_play_store-creds.json }} + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2 + + - name: Cache Gradle files + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }} + restore-keys: | + ${{ runner.os }}-gradle-v2- + + - name: Cache build output + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: | + ${{ github.workspace }}/build-cache + key: ${{ runner.os }}-build-cache-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-build- + + - name: Configure JDK + uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0 + with: + distribution: "temurin" + java-version: ${{ env.JAVA_VERSION }} + + - name: Increment version + run: | + DEFAULT_VERSION_CODE=$GITHUB_RUN_NUMBER + VERSION_CODE="${{ inputs.version-code || '$DEFAULT_VERSION_CODE' }}" + bundle exec fastlane setAuthenticatorBuildVersionInfo \ + versionCode:$VERSION_CODE \ + versionName:${{ inputs.version-name || '' }} + + regex='versionName = "([^"]+)"' + if [[ "$(cat authenticator/build.gradle.kts)" =~ $regex ]]; then + VERSION_NAME="${BASH_REMATCH[1]}" + fi + echo "Version Name: ${VERSION_NAME}" >> $GITHUB_STEP_SUMMARY + echo "Version Number: $VERSION_CODE" >> $GITHUB_STEP_SUMMARY + + - name: Generate release Play Store bundle + if: ${{ matrix.variant == 'aab' }} + run: | + bundle exec fastlane bundleAuthenticatorRelease \ + storeFile:${{ github.workspace }}/keystores/authenticator_aab-keystore.jks \ + storePassword:'${{ secrets.AAB_KEYSTORE_STORE_PASSWORD }}' \ + keyAlias:authenticatorupload \ + keyPassword:'${{ secrets.AAB_KEYSTORE_KEY_PASSWORD }}' + + - name: Generate release Play Store APK + if: ${{ matrix.variant == 'apk' }} + run: | + bundle exec fastlane buildAuthenticatorRelease \ + storeFile:${{ github.workspace }}/keystores/authenticator_apk-keystore.jks \ + storePassword:'${{ secrets.APK_KEYSTORE_STORE_PASSWORD }}' \ + keyAlias:bitwardenauthenticator \ + keyPassword:'${{ secrets.APK_KEYSTORE_KEY_PASSWORD }}' + + - name: Upload release Play Store .aab artifact + if: ${{ matrix.variant == 'aab' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: com.bitwarden.authenticator.aab + path: authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab + if-no-files-found: error + + - name: Upload release .apk artifact + if: ${{ matrix.variant == 'apk' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: com.bitwarden.authenticator.apk + path: authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk + if-no-files-found: error + + - name: Create checksum file for Release AAB + if: ${{ matrix.variant == 'aab' }} + run: | + sha256sum "authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab" \ + > ./authenticator-android-aab-sha256.txt + + - name: Create checksum for release .apk artifact + if: ${{ matrix.variant == 'apk' }} + run: | + sha256sum "authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk" \ + > ./authenticator-android-apk-sha256.txt + + - name: Upload .apk SHA file for release + if: ${{ matrix.variant == 'apk' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: authenticator-android-apk-sha256.txt + path: ./authenticator-android-apk-sha256.txt + if-no-files-found: error + + - name: Upload .aab SHA file for release + if: ${{ matrix.variant == 'aab' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: authenticator-android-aab-sha256.txt + path: ./authenticator-android-aab-sha256.txt + if-no-files-found: error + + - name: Install Firebase app distribution plugin + if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }} + run: bundle exec fastlane add_plugin firebase_app_distribution + + - name: Publish release bundle to Firebase + if: ${{ matrix.variant == 'aab' && (inputs.distribute-to-firebase || github.event_name == 'push') }} + env: + FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json + run: | + bundle exec fastlane distributeAuthenticatorReleaseBundleToFirebase \ + serviceCredentialsFile:${{ env.FIREBASE_CREDS_PATH }} + + # Only publish bundles to Play Store when `publish-to-play-store` is true while building + # bundles + - name: Publish release bundle to Google Play Store + if: ${{ inputs.publish-to-play-store && matrix.variant == 'aab' }} + env: + PLAY_STORE_CREDS_FILE: ${{ github.workspace }}/secrets/authenticator_play_store-creds.json + run: | + bundle exec fastlane publishAuthenticatorReleaseToGooglePlayStore \ + serviceCredentialsFile:${{ env.PLAY_STORE_CREDS_FILE }} \ diff --git a/.github/workflows/crowdin-pull-authenticator.yml b/.github/workflows/crowdin-pull-authenticator.yml index 1cddf88c7e..424e280403 100644 --- a/.github/workflows/crowdin-pull-authenticator.yml +++ b/.github/workflows/crowdin-pull-authenticator.yml @@ -2,12 +2,55 @@ name: Crowdin Sync - Authenticator on: workflow_dispatch: + inputs: {} + schedule: + - cron: '0 0 * * 5' jobs: - placeholder: - name: Placeholder Job + crowdin-sync: + name: Autosync runs-on: ubuntu-24.04 - + env: + _CROWDIN_PROJECT_ID: "673718" steps: - - name: Placeholder Step - run: echo "placeholder workflow" + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Log in to Azure - CI Subscription + uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + with: + creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + + - name: Retrieve secrets + id: retrieve-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: "bitwarden-ci" + secrets: "github-gpg-private-key, github-gpg-private-key-passphrase" + + - name: Generate GH App token + uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1 + id: app-token + with: + app-id: ${{ secrets.BW_GHAPP_ID }} + private-key: ${{ secrets.BW_GHAPP_KEY }} + + - name: Download translations + uses: crowdin/github-action@d1632879d4d4da358f2d040f79fa094571c9a649 # v2.5.1 + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_TOKEN }} + with: + config: crowdin-bwa.yml + upload_sources: false + upload_translations: false + download_translations: true + github_user_name: "bitwarden-devops-bot" + github_user_email: "106330231+bitwarden-devops-bot@users.noreply.github.com" + commit_message: "Autosync the updated translations" + localization_branch_name: crowdin-auto-sync + create_pull_request: true + pull_request_title: "Autosync Crowdin Translations" + pull_request_body: "Autosync the updated translations" + gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }} + gpg_passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }} diff --git a/.github/workflows/crowdin-push-authenticator.yml b/.github/workflows/crowdin-push-authenticator.yml index ea26b6710d..01879da88d 100644 --- a/.github/workflows/crowdin-push-authenticator.yml +++ b/.github/workflows/crowdin-push-authenticator.yml @@ -2,12 +2,29 @@ name: Crowdin Push - Authenticator on: workflow_dispatch: + push: + branches: + - "main" +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JAVA_VERSION: 17 jobs: - placeholder: - name: Placeholder Job + crowdin-push: + name: Crowdin Push runs-on: ubuntu-24.04 - + env: + _CROWDIN_PROJECT_ID: "673718" steps: - - name: Placeholder Step - run: echo "placeholder workflow" + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Upload sources + uses: crowdin/github-action@d1632879d4d4da358f2d040f79fa094571c9a649 # v2.5.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_TOKEN }} + with: + config: crowdin-bwa.yml + upload_sources: true + upload_translations: false diff --git a/.github/workflows/scan-authenticator.yml b/.github/workflows/scan-authenticator.yml index 77573abd79..b9d73dcc35 100644 --- a/.github/workflows/scan-authenticator.yml +++ b/.github/workflows/scan-authenticator.yml @@ -2,12 +2,75 @@ name: Scan Authenticator on: workflow_dispatch: + push: + branches: + - "main" + - "rc" + - "hotfix-rc" + pull_request_target: + types: [opened, synchronize] jobs: - placeholder: - name: Placeholder Job + check-run: + name: Check PR run + uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + + sast: + name: SAST scan runs-on: ubuntu-24.04 + needs: check-run + permissions: + contents: read + pull-requests: write + security-events: write steps: - - name: Placeholder Step - run: echo "placeholder workflow" + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Scan with Checkmarx + uses: checkmarx/ast-github-action@184bf2f64f55d1c93fd6636d539edf274703e434 # 2.0.41 + env: + INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}" + with: + project_name: ${{ github.repository }} + cx_tenant: ${{ secrets.CHECKMARX_TENANT }} + base_uri: https://ast.checkmarx.net/ + cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }} + cx_client_secret: ${{ secrets.CHECKMARX_SECRET }} + additional_params: | + --report-format sarif \ + --filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \ + --output-path . ${{ env.INCREMENTAL }} + + - name: Upload Checkmarx results to GitHub + uses: github/codeql-action/upload-sarif@d68b2d4edb4189fd2a5366ac14e72027bd4b37dd # v3.28.2 + with: + sarif_file: cx_result.sarif + + quality: + name: Quality scan + runs-on: ubuntu-24.04 + needs: check-run + permissions: + contents: read + pull-requests: write + + steps: + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Scan with SonarCloud + uses: sonarsource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203 # v4.2.1 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.organization=${{ github.repository_owner }} + -Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }} + -Dsonar.pullrequest.key=${{ github.event.pull_request.number }} diff --git a/.github/workflows/test-authenticator.yml b/.github/workflows/test-authenticator.yml index e559286f4c..a389ba2d48 100644 --- a/.github/workflows/test-authenticator.yml +++ b/.github/workflows/test-authenticator.yml @@ -1,13 +1,82 @@ name: Test Authenticator on: - workflow_dispatch: + push: + branches: + - "main" + - "rc" + - "hotfix-rc" + pull_request_target: + types: [opened, synchronize] + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JAVA_VERSION: 17 jobs: - placeholder: - name: Placeholder Job + check-run: + name: Check PR run + uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + + test: + name: Test runs-on: ubuntu-24.04 + needs: check-run + permissions: + contents: read + packages: read + pull-requests: write steps: - - name: Placeholder Step - run: echo "placeholder workflow" + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2 + + - name: Cache Gradle files + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }} + restore-keys: | + ${{ runner.os }}-gradle-v2- + + - name: Cache build output + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: | + ${{ github.workspace }}/build-cache + key: ${{ runner.os }}-build-cache-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-build- + + - name: Configure Ruby + uses: ruby/setup-ruby@28c4deda893d5a96a6b2d958c5b47fc18d65c9d3 # v1.213.0 + with: + bundler-cache: true + + - name: Configure JDK + uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0 + with: + distribution: "temurin" + java-version: ${{ env.JAVA_VERSION }} + + - name: Install Fastlane + run: | + gem install bundler:2.2.27 + bundle config path vendor/bundle + bundle install --jobs 4 --retry 3 + + - name: Build and test Authenticator + run: | + bundle exec fastlane checkAuthenticator + + - name: Upload to codecov.io + uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2 + with: + files: authenticator/build/reports/kover/reportDebug.xml diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..2312dc587f --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/README-bwa.md b/README-bwa.md new file mode 100644 index 0000000000..7e1b0b09a6 --- /dev/null +++ b/README-bwa.md @@ -0,0 +1,61 @@ +[![Github Workflow build on main](https://github.com/bitwarden/authenticator-android/actions/workflows/build-authenticator.yml/badge.svg?branch=main)](https://github.com/bitwarden/authenticator-android/actions/workflows/build-authenticator.yml?query=branch:main) +[![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby) + +# Bitwarden Authenticator Android App + +Get it on Google Play + +Bitwarden Authenticator allows you easily store and generate two-factor authentication codes on your device. The Bitwarden Authenticator Android application is written in Kotlin. + + + +## Compatibility + +- **Minimum SDK**: 28 +- **Target SDK**: 34 +- **Device Types Supported**: Phone and Tablet +- **Orientations Supported**: Portrait and Landscape + +## Setup + + +1. Clone the repository: + + ```sh + $ git clone https://github.com/bitwarden/authenticator-android + ``` + +2. Create a `user.properties` file in the root directory of the project and add the following properties: + + - `gitHubToken`: A "classic" Github Personal Access Token (PAT) with the `read:packages` scope (ex: `gitHubToken=gph_xx...xx`). These can be generated by going to the [Github tokens page](https://github.com/settings/tokens). See [the Github Packages user documentation concerning authentication](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-gradle-registry#authenticating-to-github-packages) for more details. + +3. Setup the code style formatter: + + All code must follow the guidelines described in the [Code Style Guidelines document](docs/STYLE_AND_BEST_PRACTICES.md). To aid in adhering to these rules, all contributors should apply `docs/bitwarden-style.xml` as their code style scheme. In IntelliJ / Android Studio: + + - Navigate to `Preferences > Editor > Code Style`. + - Hit the `Manage` button next to `Scheme`. + - Select `Import`. + - Find the `bitwarden-style.xml` file in the project's `docs/` directory. + - Import "from" `BitwardenStyle` "to" `BitwardenStyle`. + - Hit `Apply` and `OK` to save the changes and exit Preferences. + + Note that in some cases you may need to restart Android Studio for the changes to take effect. + + All code should be formatted before submitting a pull request. This can be done manually but it can also be helpful to create a macro with a custom keyboard binding to auto-format when saving. In Android Studio on OS X: + + - Select `Edit > Macros > Start Macro Recording` + - Select `Code > Optimize Imports` + - Select `Code > Reformat Code` + - Select `File > Save All` + - Select `Edit > Macros > Stop Macro Recording` + + This can then be mapped to a set of keys by navigating to `Android Studio > Preferences` and editing the macro under `Keymap` (ex : shift + command + s). + + Please avoid mixing formatting and logical changes in the same commit/PR. When possible, fix any large formatting issues in a separate PR before opening one to make logical changes to the same code. This helps others focus on the meaningful code changes when reviewing the code. + +## Contribute + +Code contributions are welcome! Please commit any pull requests against the `main` branch. Learn more about how to contribute by reading the [Contributing Guidelines](https://contributing.bitwarden.com/contributing/). Check out the [Contributing Documentation](https://contributing.bitwarden.com/) for how to get started with your first contribution. + +Security audits and feedback are welcome. Please open an issue or email us privately if the report is sensitive in nature. You can read our security policy in the [`SECURITY.md`](SECURITY.md) file. diff --git a/authenticator/.gitignore b/authenticator/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/authenticator/.gitignore @@ -0,0 +1 @@ +/build diff --git a/authenticator/build.gradle.kts b/authenticator/build.gradle.kts new file mode 100644 index 0000000000..607803943b --- /dev/null +++ b/authenticator/build.gradle.kts @@ -0,0 +1,329 @@ +import com.android.build.gradle.internal.api.BaseVariantOutputImpl +import com.google.protobuf.gradle.proto +import dagger.hilt.android.plugin.util.capitalize +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.crashlytics) + alias(libs.plugins.detekt) + alias(libs.plugins.hilt) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose.compiler) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlinx.kover) + alias(libs.plugins.ksp) + alias(libs.plugins.google.protobuf) + alias(libs.plugins.google.services) + alias(libs.plugins.sonarqube) +} + +android { + namespace = "com.bitwarden.authenticator" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "com.bitwarden.authenticator" + minSdk = libs.versions.minSdkBwa.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + setProperty("archivesBaseName", "com.bitwarden.authenticator") + + ksp { + // The location in which the generated Room Database Schemas will be stored in the repo. + arg("room.schemaLocation", "$projectDir/schemas") + } + } + + androidResources { + @Suppress("UnstableApiUsage") + generateLocaleConfig = true + } + + signingConfigs { + getByName("debug") { + keyAlias = "androiddebugkey" + keyPassword = "android" + storeFile = file("../keystores/debug-bwa.keystore") + storePassword = "android" + } + } + + buildTypes { + debug { + applicationIdSuffix = ".dev" + manifestPlaceholders["targetBitwardenAppId"] = "com.x8bit.bitwarden.dev" + buildConfigField( + type = "com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType", + name = "AUTHENTICATOR_BRIDGE_CONNECTION_TYPE", + value = "com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType.DEV", + ) + signingConfig = signingConfigs.getByName("debug") + isDebuggable = true + isMinifyEnabled = false + buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "true") + } + + release { + manifestPlaceholders["targetBitwardenAppId"] = "com.x8bit.bitwarden" + buildConfigField( + type = "com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType", + name = "AUTHENTICATOR_BRIDGE_CONNECTION_TYPE", + value = "com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType.RELEASE", + ) + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false") + } + } + applicationVariants.all { + val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle" + outputs + .mapNotNull { it as? BaseVariantOutputImpl } + .forEach { output -> + // Set the APK output filename. + output.outputFileName = "$applicationId.apk" + + val variantName = name + val renameTaskName = "rename${variantName.capitalize()}AabFiles" + tasks.register(renameTaskName) { + group = "build" + description = "Renames the bundle files for $variantName variant" + doLast { + renameFile( + "$bundlesDir/$variantName/$namespace-${buildType.name}.aab", + "$applicationId.aab", + ) + } + } + // Force renaming task to execute after the variant is built. + tasks + .getByName("bundle${variantName.capitalize()}") + .finalizedBy(renameTaskName) + } + } + compileOptions { + sourceCompatibility(libs.versions.jvmTarget.get()) + targetCompatibility(libs.versions.jvmTarget.get()) + } + buildFeatures { + buildConfig = true + compose = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1,LICENSE*.md}" + } + } + sourceSets { + getByName("main") { + proto { + srcDir("src/main/proto") + } + } + } + lint { + disable += listOf( + "MissingTranslation", + "ExtraTranslation", + ) + } + @Suppress("UnstableApiUsage") + testOptions { + // Required for Robolectric + unitTests.isIncludeAndroidResources = true + unitTests.isReturnDefaultValues = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get())) + } +} + +dependencies { + + implementation(files("libs/authenticatorbridge-1.0.0-release.aar")) + + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.autofill) + implementation(libs.androidx.browser) + implementation(libs.androidx.biometrics) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.animation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.navigation.compose) + testImplementation(libs.testng) + ksp(libs.androidx.room.compiler) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.security.crypto) + implementation(libs.androidx.splashscreen) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.bitwarden.sdk) + implementation(libs.bumptech.glide) + implementation(platform(libs.google.firebase.bom)) + implementation(libs.google.firebase.cloud.messaging) + implementation(libs.google.firebase.crashlytics) + implementation(libs.google.hilt.android) + ksp(libs.google.hilt.compiler) + implementation(libs.google.guava) + implementation(libs.google.protobuf.javalite) + implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization) + implementation(libs.square.okhttp) + implementation(libs.square.okhttp.logging) + implementation(platform(libs.square.retrofit.bom)) + implementation(libs.square.retrofit) + implementation(libs.square.retrofit.kotlinx.serialization) + implementation(libs.zxing.zxing.core) + + // For now we are restricted to running Compose tests for debug builds only + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) + + testImplementation(libs.androidx.compose.ui.test) + testImplementation(libs.google.hilt.android.testing) + testImplementation(libs.junit.junit5) + testImplementation(libs.junit.vintage) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk.mockk) + testImplementation(libs.robolectric.robolectric) + testImplementation(libs.square.okhttp.mockwebserver) + testImplementation(libs.square.turbine) + + detektPlugins(libs.detekt.detekt.formatting) + detektPlugins(libs.detekt.detekt.rules) +} + +detekt { + autoCorrect = true + config.from(files("$rootDir/detekt-config.yml")) +} + +kover { + currentProject { + sources { + excludeJava = true + } + } + reports { + filters { + excludes { + androidGeneratedClasses() + annotatedBy( + // Compose previews + "androidx.compose.ui.tooling.preview.Preview", + // Manually excluded classes/files/etc. + "com.bitwarden.authenticator.data.platform.annotation.OmitFromCoverage", + ) + classes( + // Navigation helpers + "*.*NavigationKt*", + // Composable singletons + "*.*ComposableSingletons*", + // Generated classes related to interfaces with default values + "*.*DefaultImpls*", + // Databases + "*.database.*Database*", + "*.dao.*Dao*", + // Dagger Hilt + "dagger.hilt.*", + "hilt_aggregated_deps.*", + "*_Factory", + "*_Factory\$*", + "*_*Factory", + "*_*Factory\$*", + "*.Hilt_*", + "*_HiltModules", + "*_HiltModules\$*", + "*_Impl", + "*_Impl\$*", + "*_MembersInjector", + ) + packages( + // Dependency injection + "*.di", + // Models + "*.model", + // Custom UI components + "com.bitwarden.authenticator.ui.platform.components", + // Theme-related code + "com.bitwarden.authenticator.ui.platform.theme", + ) + } + } + } +} + +protobuf { + protoc { + artifact = libs.google.protobuf.protoc.get().toString() + } + generateProtoTasks { + this.all().forEach { task -> + task.builtins.create("java") { + option("lite") + } + } + } +} + +sonar { + properties { + property("sonar.projectKey", "bitwarden_authenticator-android") + property("sonar.organization", "bitwarden") + property("sonar.host.url", "https://sonarcloud.io") + property("sonar.sources", "authenticator/src/") + property("sonar.tests", "authenticator/src/") + property("sonar.test.inclusions", "authenticator/src/test/") + property("sonar.exclusions", "authenticator/src/test/") + } +} + +tasks { + withType { + useJUnitPlatform() + } + getByName("sonar") { + dependsOn("check") + } +} + +private fun renameFile(path: String, newName: String) { + val originalFile = File(path) + if (!originalFile.exists()) { + println("File $originalFile does not exist!") + return + } + + val newFile = File(originalFile.parentFile, newName) + if (originalFile.renameTo(newFile)) { + println("Renamed $originalFile to $newFile") + } else { + throw RuntimeException("Failed to rename $originalFile to $newFile") + } +} diff --git a/authenticator/libs/authenticatorbridge-1.0.0-release.aar b/authenticator/libs/authenticatorbridge-1.0.0-release.aar new file mode 100644 index 0000000000..58a7c494b0 Binary files /dev/null and b/authenticator/libs/authenticatorbridge-1.0.0-release.aar differ diff --git a/authenticator/proguard-rules.pro b/authenticator/proguard-rules.pro new file mode 100644 index 0000000000..73aed837ee --- /dev/null +++ b/authenticator/proguard-rules.pro @@ -0,0 +1,112 @@ +################################################################################ +# Bitwarden SDK +################################################################################ + +# We need to access the SDK using JNA and this makes it very easy to obfuscate away the SDK unless +# we keep it here. +-keep class com.bitwarden.** { *; } + +################################################################################ +# Bitwarden Models +################################################################################ + +# Keep all enums +-keepclassmembers enum * { *; } + +################################################################################ +# Credential Manager +################################################################################ + +-if class androidx.credentials.CredentialManager +-keep class androidx.credentials.playservices.** { + *; +} + +################################################################################ +# Firebase Crashlytics +################################################################################ + +# Keep file names and line numbers. +-keepattributes SourceFile,LineNumberTable + +# Keep custom exceptions. +-keep public class * extends java.lang.Exception + +################################################################################ +# kotlinx.serialization +################################################################################ + +-keepattributes *Annotation*, InnerClasses + +# kotlinx-serialization-json specific. +-keepclassmembers class kotlinx.serialization.json.** { + *** Companion; +} +-keepclasseswithmembers class kotlinx.serialization.json.** { + kotlinx.serialization.KSerializer serializer(...); +} + +################################################################################ +# Glide +################################################################################ + +-keep public class * implements com.bumptech.glide.module.GlideModule +-keep public class * extends com.bumptech.glide.module.AppGlideModule + +################################################################################ +# Google Protobuf generated files +################################################################################ + +-keep class * extends com.google.protobuf.GeneratedMessageLite { *; } + +################################################################################ +# JNA +################################################################################ + +# See https://github.com/java-native-access/jna/blob/fdb8695fb9b05fba467dadfe5735282f8bcc053d/www/FrequentlyAskedQuestions.md#jna-on-android +-dontwarn java.awt.* +-keep class com.sun.jna.* { *; } +-keepclassmembers class * extends com.sun.jna.* { public *; } + +# Keep annotated classes +-keep @com.sun.jna.* class * +-keepclassmembers class * { + @com.sun.jna.* *; +} + +################################################################################ +# Okhttp/Retrofit https://square.github.io/okhttp/ & https://square.github.io/retrofit/ +################################################################################ + +# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and +# EnclosingMethod is required to use InnerClasses. +-keepattributes Signature, InnerClasses, EnclosingMethod + +# Retrofit does reflection on method and parameter annotations. +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations + +# https://github.com/square/okhttp/blob/339732e3a1b78be5d792860109047f68a011b5eb/okhttp/src/jvmMain/resources/META-INF/proguard/okhttp3.pro#L11-L14 +-dontwarn okhttp3.internal.platform.** +-dontwarn org.bouncycastle.** +# Related to this issue on https://github.com/square/retrofit/issues/3880 +# Check https://github.com/square/retrofit/tags for new versions +-keep,allowobfuscation,allowshrinking class kotlin.Result +-keep,allowobfuscation,allowshrinking interface retrofit2.Call +-keep,allowobfuscation,allowshrinking class retrofit2.Response +# This solves this issue https://github.com/square/retrofit/issues/3880 +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation + +# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy +# and replaces all potential values with null. Explicitly keeping the interfaces prevents this. +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface <1> + +################################################################################ +# ZXing +################################################################################ + +# Suppress zxing missing class error due to circular references +-dontwarn com.google.zxing.BarcodeFormat +-dontwarn com.google.zxing.EncodeHintType +-dontwarn com.google.zxing.MultiFormatWriter +-dontwarn com.google.zxing.common.BitMatrix diff --git a/authenticator/schemas/com.bitwarden.authenticator.data.authenticator.datasource.disk.database.AuthenticatorDatabase/1.json b/authenticator/schemas/com.bitwarden.authenticator.data.authenticator.datasource.disk.database.AuthenticatorDatabase/1.json new file mode 100644 index 0000000000..0e0b7bf8ea --- /dev/null +++ b/authenticator/schemas/com.bitwarden.authenticator.data.authenticator.datasource.disk.database.AuthenticatorDatabase/1.json @@ -0,0 +1,82 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "480a4540e7704429515a28eb9c38ab14", + "entities": [ + { + "tableName": "items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `key` TEXT NOT NULL, `type` TEXT NOT NULL, `algorithm` TEXT NOT NULL, `period` INTEGER NOT NULL, `digits` INTEGER NOT NULL, `issuer` TEXT NOT NULL, `userId` TEXT, `accountName` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "algorithm", + "columnName": "algorithm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "period", + "columnName": "period", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "digits", + "columnName": "digits", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "issuer", + "columnName": "issuer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '480a4540e7704429515a28eb9c38ab14')" + ] + } +} \ No newline at end of file diff --git a/authenticator/schemas/com.bitwarden.authenticator.data.authenticator.datasource.disk.database.AuthenticatorDatabase/2.json b/authenticator/schemas/com.bitwarden.authenticator.data.authenticator.datasource.disk.database.AuthenticatorDatabase/2.json new file mode 100644 index 0000000000..34c107935a --- /dev/null +++ b/authenticator/schemas/com.bitwarden.authenticator.data.authenticator.datasource.disk.database.AuthenticatorDatabase/2.json @@ -0,0 +1,89 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "a4f979e34bbd67f2ddd263acdad12a38", + "entities": [ + { + "tableName": "items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `key` TEXT NOT NULL, `type` TEXT NOT NULL, `algorithm` TEXT NOT NULL, `period` INTEGER NOT NULL, `digits` INTEGER NOT NULL, `issuer` TEXT NOT NULL, `userId` TEXT, `accountName` TEXT, `favorite` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "algorithm", + "columnName": "algorithm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "period", + "columnName": "period", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "digits", + "columnName": "digits", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "issuer", + "columnName": "issuer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a4f979e34bbd67f2ddd263acdad12a38')" + ] + } +} \ No newline at end of file diff --git a/authenticator/src/debug/AndroidManifest.xml b/authenticator/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..5ba84656bd --- /dev/null +++ b/authenticator/src/debug/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + diff --git a/authenticator/src/debug/google-services.json b/authenticator/src/debug/google-services.json new file mode 100644 index 0000000000..cf2a9a8af3 --- /dev/null +++ b/authenticator/src/debug/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "867301491091", + "project_id": "bitwarden-authenticator", + "storage_bucket": "bitwarden-authenticator.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:867301491091:android:3ee369dedcd20f6551e866", + "android_client_info": { + "package_name": "com.bitwarden.authenticator.dev" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDDXnnBuWzuh8rlihiMWRPif_sqkGk3fxw" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/authenticator/src/main/AndroidManifest.xml b/authenticator/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..1613fc9d33 --- /dev/null +++ b/authenticator/src/main/AndroidManifest.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/AuthenticatorApplication.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/AuthenticatorApplication.kt new file mode 100644 index 0000000000..a3bad42e55 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/AuthenticatorApplication.kt @@ -0,0 +1,18 @@ +package com.bitwarden.authenticator + +import android.app.Application +import com.bitwarden.authenticator.data.platform.manager.CrashLogsManager +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject + +/** + * Custom application class. + */ +@HiltAndroidApp +class AuthenticatorApplication : Application() { + // Inject classes here that must be triggered on startup but are not otherwise consumed by + // other callers. + + @Inject + lateinit var crashLogsManager: CrashLogsManager +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt new file mode 100644 index 0000000000..7171045da2 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt @@ -0,0 +1,121 @@ +package com.bitwarden.authenticator + +import android.content.Intent +import android.os.Bundle +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.WindowManager +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import com.bitwarden.authenticator.data.platform.util.isSuspicious +import com.bitwarden.authenticator.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager +import com.bitwarden.authenticator.ui.platform.feature.debugmenu.navigateToDebugMenuScreen +import com.bitwarden.authenticator.ui.platform.feature.rootnav.RootNavScreen +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +/** + * Primary entry point for the application. + */ +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + + private val mainViewModel: MainViewModel by viewModels() + + @Inject + lateinit var debugLaunchManager: DebugMenuLaunchManager + + override fun onCreate(savedInstanceState: Bundle?) { + sanitizeIntent() + var shouldShowSplashScreen = true + installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen } + super.onCreate(savedInstanceState) + + if (savedInstanceState == null) { + mainViewModel.trySendAction( + MainAction.ReceiveFirstIntent( + intent = intent, + ), + ) + } + + setContent { + val state by mainViewModel.stateFlow.collectAsStateWithLifecycle() + val navController = rememberNavController() + observeViewModelEvents(navController) + AuthenticatorTheme( + theme = state.theme, + ) { + RootNavScreen( + navController = navController, + onSplashScreenRemoved = { shouldShowSplashScreen = false }, + onExitApplication = { finishAffinity() }, + ) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + sanitizeIntent() + mainViewModel.trySendAction( + MainAction.ReceiveNewIntent(intent = intent), + ) + } + + private fun sanitizeIntent() { + if (intent.isSuspicious) { + intent = Intent( + /* packageContext = */ this, + /* cls = */ MainActivity::class.java, + ) + } + } + + private fun observeViewModelEvents(navController: NavHostController) { + mainViewModel + .eventFlow + .onEach { event -> + when (event) { + is MainEvent.ScreenCaptureSettingChange -> { + handleScreenCaptureSettingChange(event) + } + + MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen() + } + } + .launchIn(lifecycleScope) + } + + override fun dispatchTouchEvent(event: MotionEvent): Boolean = debugLaunchManager + .actionOnInputEvent(event = event, action = ::sendOpenDebugMenuEvent) + .takeIf { it } + ?: super.dispatchTouchEvent(event) + + override fun dispatchKeyEvent(event: KeyEvent): Boolean = debugLaunchManager + .actionOnInputEvent(event = event, action = ::sendOpenDebugMenuEvent) + .takeIf { it } + ?: super.dispatchKeyEvent(event) + + private fun sendOpenDebugMenuEvent() { + mainViewModel.trySendAction(MainAction.OpenDebugMenu) + } + + private fun handleScreenCaptureSettingChange(event: MainEvent.ScreenCaptureSettingChange) { + if (event.isAllowed) { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/MainViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/MainViewModel.kt new file mode 100644 index 0000000000..d15cf30e4f --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/MainViewModel.kt @@ -0,0 +1,142 @@ +package com.bitwarden.authenticator + +import android.content.Intent +import android.os.Parcelable +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.data.platform.repository.ServerConfigRepository +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +/** + * A view model that helps launch actions for the [MainActivity]. + */ +@HiltViewModel +class MainViewModel @Inject constructor( + settingsRepository: SettingsRepository, + configRepository: ServerConfigRepository, +) : BaseViewModel( + MainState( + theme = settingsRepository.appTheme, + ), +) { + + init { + settingsRepository + .appThemeStateFlow + .onEach { trySendAction(MainAction.Internal.ThemeUpdate(it)) } + .launchIn(viewModelScope) + + settingsRepository + .isScreenCaptureAllowedStateFlow + .onEach { isAllowed -> + sendEvent(MainEvent.ScreenCaptureSettingChange(isAllowed)) + } + .launchIn(viewModelScope) + viewModelScope.launch { + configRepository.getServerConfig(forceRefresh = false) + } + } + + override fun handleAction(action: MainAction) { + when (action) { + is MainAction.Internal.ThemeUpdate -> handleThemeUpdated(action) + is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action) + is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action) + MainAction.OpenDebugMenu -> handleOpenDebugMenu() + } + } + + private fun handleOpenDebugMenu() { + sendEvent(MainEvent.NavigateToDebugMenu) + } + + private fun handleThemeUpdated(action: MainAction.Internal.ThemeUpdate) { + mutableStateFlow.update { it.copy(theme = action.theme) } + } + + private fun handleFirstIntentReceived(action: MainAction.ReceiveFirstIntent) { + handleIntent( + intent = action.intent, + isFirstIntent = true, + ) + } + + private fun handleNewIntentReceived(action: MainAction.ReceiveNewIntent) { + handleIntent( + intent = action.intent, + isFirstIntent = false, + ) + } + + private fun handleIntent( + intent: Intent, + isFirstIntent: Boolean, + ) { + // RFU + } +} + +/** + * Models state for the [MainActivity]. + */ +@Parcelize +data class MainState( + val theme: AppTheme, +) : Parcelable + +/** + * Models actions for the [MainActivity]. + */ +sealed class MainAction { + /** + * Receive first Intent by the application. + */ + data class ReceiveFirstIntent(val intent: Intent) : MainAction() + + /** + * Receive Intent by the application. + */ + data class ReceiveNewIntent(val intent: Intent) : MainAction() + + /** + * Receive event to open the debug menu. + */ + data object OpenDebugMenu : MainAction() + + /** + * Actions for internal use by the ViewModel. + */ + sealed class Internal : MainAction() { + + /** + * Indicates that the app theme has changed. + */ + data class ThemeUpdate( + val theme: AppTheme, + ) : Internal() + } +} + +/** + * Represents events that are emitted by the [MainViewModel]. + */ +sealed class MainEvent { + + /** + * Navigate to the debug menu. + */ + data object NavigateToDebugMenu : MainEvent() + + /** + * Event indicating a change in the screen capture setting. + */ + data class ScreenCaptureSettingChange(val isAllowed: Boolean) : MainEvent() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt new file mode 100644 index 0000000000..6ba4d84c01 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt @@ -0,0 +1,38 @@ +package com.bitwarden.authenticator.data.auth.datasource.disk + +/** + * Primary access point for disk information. + */ +interface AuthDiskSource { + + /** + * Retrieves the "last active time". + * + * This time is intended to be derived from a call to + * [SystemClock.elapsedRealtime()](https://developer.android.com/reference/android/os/SystemClock#elapsedRealtime()) + */ + fun getLastActiveTimeMillis(): Long? + + /** + * Stores the [lastActiveTimeMillis] . + * + * This time is intended to be derived from a call to + * [SystemClock.elapsedRealtime()](https://developer.android.com/reference/android/os/SystemClock#elapsedRealtime()) + */ + fun storeLastActiveTimeMillis(lastActiveTimeMillis: Long?) + + /** + * Gets the biometrics key. + */ + fun getUserBiometricUnlockKey(): String? + + /** + * Stores the biometrics key. + */ + fun storeUserBiometricUnlockKey(biometricsKey: String?) + + /** + * Stores the symmetric key data used for encrypting TOTP data. + */ + var authenticatorBridgeSymmetricSyncKey: ByteArray? +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt new file mode 100644 index 0000000000..2c25f8098f --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -0,0 +1,56 @@ +package com.bitwarden.authenticator.data.auth.datasource.disk + +import android.content.SharedPreferences +import com.bitwarden.authenticator.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY +import com.bitwarden.authenticator.data.platform.datasource.disk.BaseEncryptedDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.BaseEncryptedDiskSource.Companion.ENCRYPTED_BASE_KEY + +private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY = + "$ENCRYPTED_BASE_KEY:authenticatorSyncSymmetricKey" +private const val LAST_ACTIVE_TIME_KEY = "$BASE_KEY:lastActiveTime" +private const val BIOMETRICS_UNLOCK_KEY = "$ENCRYPTED_BASE_KEY:userKeyBiometricUnlock" + +/** + * Primary implementation of [AuthDiskSource]. + */ +class AuthDiskSourceImpl( + encryptedSharedPreferences: SharedPreferences, + sharedPreferences: SharedPreferences, +) : BaseEncryptedDiskSource( + encryptedSharedPreferences = encryptedSharedPreferences, + sharedPreferences = sharedPreferences, +), + AuthDiskSource { + + override fun getLastActiveTimeMillis(): Long? = + getLong(key = LAST_ACTIVE_TIME_KEY) + + override fun storeLastActiveTimeMillis( + lastActiveTimeMillis: Long?, + ) { + putLong( + key = LAST_ACTIVE_TIME_KEY, + value = lastActiveTimeMillis, + ) + } + + override fun getUserBiometricUnlockKey(): String? = + getEncryptedString(key = BIOMETRICS_UNLOCK_KEY) + + override fun storeUserBiometricUnlockKey( + biometricsKey: String?, + ) { + putEncryptedString( + key = BIOMETRICS_UNLOCK_KEY, + value = biometricsKey, + ) + } + + override var authenticatorBridgeSymmetricSyncKey: ByteArray? + set(value) { + val asString = value?.let { value.toString(Charsets.ISO_8859_1) } + putEncryptedString(AUTHENTICATOR_SYNC_SYMMETRIC_KEY, asString) + } + get() = getEncryptedString(AUTHENTICATOR_SYNC_SYMMETRIC_KEY) + ?.toByteArray(Charsets.ISO_8859_1) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/di/AuthDiskModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/di/AuthDiskModule.kt new file mode 100644 index 0000000000..be885dfe0d --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/di/AuthDiskModule.kt @@ -0,0 +1,31 @@ +package com.bitwarden.authenticator.data.auth.datasource.disk.di + +import android.content.SharedPreferences +import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource +import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSourceImpl +import com.bitwarden.authenticator.data.platform.datasource.di.EncryptedPreferences +import com.bitwarden.authenticator.data.platform.datasource.di.UnencryptedPreferences +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Provides persistence-related dependencies in the auth package. + */ +@Module +@InstallIn(SingletonComponent::class) +object AuthDiskModule { + + @Provides + @Singleton + fun provideAuthDiskSource( + @EncryptedPreferences encryptedSharedPreferences: SharedPreferences, + @UnencryptedPreferences sharedPreferences: SharedPreferences, + ): AuthDiskSource = + AuthDiskSourceImpl( + encryptedSharedPreferences = encryptedSharedPreferences, + sharedPreferences = sharedPreferences, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/model/EnvironmentUrlDataJson.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/model/EnvironmentUrlDataJson.kt new file mode 100644 index 0000000000..526b0a8dae --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/model/EnvironmentUrlDataJson.kt @@ -0,0 +1,54 @@ +package com.bitwarden.authenticator.data.auth.datasource.disk.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents URLs for various Bitwarden domains. + * + * @property base The overall base URL. + * @property api Separate base URL for the "/api" domain (if applicable). + * @property identity Separate base URL for the "/identity" domain (if applicable). + * @property icon Separate base URL for the icon domain (if applicable). + * @property notifications Separate base URL for the notifications domain (if applicable). + * @property webVault Separate base URL for the web vault domain (if applicable). + * @property events Separate base URL for the events domain (if applicable). + */ +@Serializable +data class EnvironmentUrlDataJson( + @SerialName("base") + val base: String, + + @SerialName("api") + val api: String? = null, + + @SerialName("identity") + val identity: String? = null, + + @SerialName("icons") + val icon: String? = null, + + @SerialName("notifications") + val notifications: String? = null, + + @SerialName("webVault") + val webVault: String? = null, + + @SerialName("events") + val events: String? = null, +) { + @Suppress("UndocumentedPublicClass") + companion object { + /** + * Default [EnvironmentUrlDataJson] for the US region. + */ + val DEFAULT_US: EnvironmentUrlDataJson = + EnvironmentUrlDataJson(base = "https://vault.bitwarden.com") + + /** + * Default [EnvironmentUrlDataJson] for the EU region. + */ + val DEFAULT_EU: EnvironmentUrlDataJson = + EnvironmentUrlDataJson(base = "https://vault.bitwarden.eu") + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepository.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepository.kt new file mode 100644 index 0000000000..e65ceb2098 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepository.kt @@ -0,0 +1,12 @@ +package com.bitwarden.authenticator.data.auth.repository + +/** + * Provides and API for modifying authentication state. + */ +interface AuthRepository { + + /** + * Updates the "last active time" for the current user. + */ + fun updateLastActiveTime() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepositoryImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000000..a40ef11792 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepositoryImpl.kt @@ -0,0 +1,23 @@ +package com.bitwarden.authenticator.data.auth.repository + +import android.os.SystemClock +import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource +import javax.inject.Inject + +/** + * Default implementation of [AuthRepository]. + */ +class AuthRepositoryImpl @Inject constructor( + private val authDiskSource: AuthDiskSource, + private val elapsedRealtimeMillisProvider: () -> Long = { SystemClock.elapsedRealtime() }, +) : AuthRepository { + + /** + * Updates the "last active time" for the current user. + */ + override fun updateLastActiveTime() { + authDiskSource.storeLastActiveTimeMillis( + lastActiveTimeMillis = elapsedRealtimeMillisProvider(), + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/di/AuthRepositoryModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/di/AuthRepositoryModule.kt new file mode 100644 index 0000000000..96bfb403a1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/di/AuthRepositoryModule.kt @@ -0,0 +1,24 @@ +package com.bitwarden.authenticator.data.auth.repository.di + +import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource +import com.bitwarden.authenticator.data.auth.repository.AuthRepository +import com.bitwarden.authenticator.data.auth.repository.AuthRepositoryImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * Provides repositories in the auth package. + */ +@Module +@InstallIn(SingletonComponent::class) +object AuthRepositoryModule { + + @Provides + fun provideAuthRepository( + authDiskSource: AuthDiskSource, + ): AuthRepository = AuthRepositoryImpl( + authDiskSource = authDiskSource, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/AuthenticatorDiskSource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/AuthenticatorDiskSource.kt new file mode 100644 index 0000000000..c768879952 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/AuthenticatorDiskSource.kt @@ -0,0 +1,25 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.disk + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import kotlinx.coroutines.flow.Flow + +/** + * Primary access point for disk information related to authenticator data. + */ +interface AuthenticatorDiskSource { + + /** + * Saves an authenticator item to the data source. + */ + suspend fun saveItem(vararg authenticatorItem: AuthenticatorItemEntity) + + /** + * Retrieves all authenticator items from the data source. + */ + fun getItems(): Flow> + + /** + * Deletes an authenticator item from the data source with the given [itemId]. + */ + suspend fun deleteItem(itemId: String) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/AuthenticatorDiskSourceImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/AuthenticatorDiskSourceImpl.kt new file mode 100644 index 0000000000..031aad8344 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/AuthenticatorDiskSourceImpl.kt @@ -0,0 +1,31 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.disk + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.dao.ItemDao +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.merge +import javax.inject.Inject + +/** + * Default implementation of [AuthenticatorDiskSource]. + */ +class AuthenticatorDiskSourceImpl @Inject constructor( + private val itemDao: ItemDao, +) : AuthenticatorDiskSource { + + private val forceItemsFlow = bufferedMutableSharedFlow>() + + override suspend fun saveItem(vararg authenticatorItem: AuthenticatorItemEntity) { + itemDao.insert(*authenticatorItem) + } + + override fun getItems(): Flow> = merge( + forceItemsFlow, + itemDao.getAllItems(), + ) + + override suspend fun deleteItem(itemId: String) { + itemDao.deleteItem(itemId) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/convertor/AuthenticatorItemAlgorithmConverter.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/convertor/AuthenticatorItemAlgorithmConverter.kt new file mode 100644 index 0000000000..a4208a52ef --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/convertor/AuthenticatorItemAlgorithmConverter.kt @@ -0,0 +1,26 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.disk.convertor + +import androidx.room.ProvidedTypeConverter +import androidx.room.TypeConverter +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm + +/** + * A [TypeConverter] to convert [AuthenticatorItemAlgorithm] to and from a [String]. + */ +@ProvidedTypeConverter +class AuthenticatorItemAlgorithmConverter { + + /** + * A [TypeConverter] to convert an [AuthenticatorItemAlgorithm] to a [String]. + */ + @TypeConverter + fun toString(item: AuthenticatorItemAlgorithm): String = item.name + + /** + * A [TypeConverter] to convert a [String] to an [AuthenticatorItemAlgorithm]. + */ + @TypeConverter + fun fromString(itemName: String) = AuthenticatorItemAlgorithm + .entries + .find { it.name == itemName } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/convertor/AuthenticatorItemTypeConverter.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/convertor/AuthenticatorItemTypeConverter.kt new file mode 100644 index 0000000000..dba98d4f9e --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/convertor/AuthenticatorItemTypeConverter.kt @@ -0,0 +1,26 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.disk.convertor + +import androidx.room.ProvidedTypeConverter +import androidx.room.TypeConverter +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType + +/** + * A [TypeConverter] to convert [AuthenticatorItemType] to and from a [String]. + */ +@ProvidedTypeConverter +class AuthenticatorItemTypeConverter { + + /** + * A [TypeConverter] to convert an [AuthenticatorItemType] to a [String]. + */ + @TypeConverter + fun toString(item: AuthenticatorItemType): String = item.name + + /** + * A [TypeConverter] to convert a [String] to an [AuthenticatorItemType]. + */ + @TypeConverter + fun fromString(itemName: String) = AuthenticatorItemType + .entries + .find { it.name == itemName } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/dao/ItemDao.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/dao/ItemDao.kt new file mode 100644 index 0000000000..305f820202 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/dao/ItemDao.kt @@ -0,0 +1,35 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.disk.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import kotlinx.coroutines.flow.Flow + +/** + * Provides methods for inserting, reading, and deleting authentication items from the database + * using [AuthenticatorItemEntity]. + */ +@Dao +interface ItemDao { + + /** + * Inserts a single authenticator item into the database. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(vararg item: AuthenticatorItemEntity) + + /** + * Read all authenticator items from the database. + */ + @Query("SELECT * FROM items") + fun getAllItems(): Flow> + + /** + * Deletes the specified authenticator item with the given [itemId]. This will return the number + * of rows deleted by this query. + */ + @Query("DELETE FROM items WHERE id = :itemId") + suspend fun deleteItem(itemId: String): Int +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/database/AuthenticatorDatabase.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/database/AuthenticatorDatabase.kt new file mode 100644 index 0000000000..c5a7a41555 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/database/AuthenticatorDatabase.kt @@ -0,0 +1,35 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.disk.database + +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.bitwarden.authenticator.data.authenticator.datasource.disk.convertor.AuthenticatorItemAlgorithmConverter +import com.bitwarden.authenticator.data.authenticator.datasource.disk.convertor.AuthenticatorItemTypeConverter +import com.bitwarden.authenticator.data.authenticator.datasource.disk.dao.ItemDao +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity + +/** + * Room database for storing any persisted data. + */ +@Database( + entities = [ + AuthenticatorItemEntity::class, + ], + autoMigrations = [ + AutoMigration(from = 1, to = 2), + ], + version = 2, + exportSchema = true, +) +@TypeConverters( + AuthenticatorItemTypeConverter::class, + AuthenticatorItemAlgorithmConverter::class, +) +abstract class AuthenticatorDatabase : RoomDatabase() { + + /** + * Provide the DAO for accessing authenticator item data. + */ + abstract fun itemDao(): ItemDao +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/di/AuthenticatorDiskModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/di/AuthenticatorDiskModule.kt new file mode 100644 index 0000000000..0deba43630 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/di/AuthenticatorDiskModule.kt @@ -0,0 +1,46 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.disk.di + +import android.app.Application +import androidx.room.Room +import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource +import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSourceImpl +import com.bitwarden.authenticator.data.authenticator.datasource.disk.convertor.AuthenticatorItemAlgorithmConverter +import com.bitwarden.authenticator.data.authenticator.datasource.disk.convertor.AuthenticatorItemTypeConverter +import com.bitwarden.authenticator.data.authenticator.datasource.disk.dao.ItemDao +import com.bitwarden.authenticator.data.authenticator.datasource.disk.database.AuthenticatorDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Provides persistence related dependencies in the authenticator package. + */ +@Module +@InstallIn(SingletonComponent::class) +object AuthenticatorDiskModule { + + @Provides + @Singleton + fun provideAuthenticatorDatabase(app: Application): AuthenticatorDatabase = + Room + .databaseBuilder( + context = app, + klass = AuthenticatorDatabase::class.java, + name = "authenticator_database", + ) + .fallbackToDestructiveMigration() + .addTypeConverter(AuthenticatorItemTypeConverter()) + .addTypeConverter(AuthenticatorItemAlgorithmConverter()) + .build() + + @Provides + @Singleton + fun provideItemDao(database: AuthenticatorDatabase) = database.itemDao() + + @Provides + @Singleton + fun provideAuthenticatorDiskSource(itemDao: ItemDao): AuthenticatorDiskSource = + AuthenticatorDiskSourceImpl(itemDao = itemDao) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemAlgorithm.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemAlgorithm.kt new file mode 100644 index 0000000000..fcf3fad153 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemAlgorithm.kt @@ -0,0 +1,34 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.disk.entity + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm.entries + +/** + * Enum class representing SHA algorithms an authenticator item may be hashed with. + */ +enum class AuthenticatorItemAlgorithm { + + /** + * Authenticator item verification code uses SHA1 hash. + */ + SHA1, + + /** + * Authenticator item verification code uses SHA256 hash. + */ + SHA256, + + /** + * Authenticator item verification code uses SHA512 hash. + */ + SHA512, + ; + + @Suppress("UndocumentedPublicClass") + companion object { + /** + * Returns a [AuthenticatorItemAlgorithm] with a name matching [value], or null. + */ + fun fromStringOrNull(value: String): AuthenticatorItemAlgorithm? = + entries.find { it.name.equals(value, ignoreCase = true) } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemEntity.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemEntity.kt new file mode 100644 index 0000000000..d761faed53 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemEntity.kt @@ -0,0 +1,79 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.disk.entity + +import android.net.Uri +import androidx.core.text.htmlEncode +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Entity representing an authenticator item in the database. + */ +@Entity(tableName = "items") +data class AuthenticatorItemEntity( + @PrimaryKey(autoGenerate = false) + @ColumnInfo(name = "id") + val id: String, + + @ColumnInfo(name = "key") + val key: String, + + @ColumnInfo(name = "type") + val type: AuthenticatorItemType = AuthenticatorItemType.TOTP, + + @ColumnInfo(name = "algorithm") + val algorithm: AuthenticatorItemAlgorithm = AuthenticatorItemAlgorithm.SHA1, + + @ColumnInfo(name = "period") + val period: Int = 30, + + @ColumnInfo(name = "digits") + val digits: Int = 6, + + @ColumnInfo(name = "issuer") + val issuer: String, + + @ColumnInfo(name = "userId") + val userId: String? = null, + + @ColumnInfo(name = "accountName") + val accountName: String? = null, + + @ColumnInfo(name = "favorite", defaultValue = "0") + val favorite: Boolean, +) { + /** + * Returns the OTP data in a string formatted to match the Google Authenticator specification, + * https://github.com/google/google-authenticator/wiki/Key-Uri-Format + */ + fun toOtpAuthUriString(): String { + return when (type) { + AuthenticatorItemType.TOTP -> { + val label = if (accountName.isNullOrBlank()) { + issuer + } else { + "$issuer:$accountName" + } + Uri.Builder() + .scheme("otpauth") + .authority("totp") + .appendPath(label.htmlEncode()) + .appendQueryParameter("secret", key) + .appendQueryParameter("algorithm", algorithm.name) + .appendQueryParameter("digits", digits.toString()) + .appendQueryParameter("period", period.toString()) + .appendQueryParameter("issuer", issuer) + .build() + .toString() + } + + AuthenticatorItemType.STEAM -> { + if (key.startsWith("steam://")) { + key + } else { + "steam://$key" + } + } + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemType.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemType.kt new file mode 100644 index 0000000000..f6b877a7b1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemType.kt @@ -0,0 +1,30 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.disk.entity + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType.entries + +/** + * Enum representing the supported "type" options for authenticator items. + */ +enum class AuthenticatorItemType { + + /** + * A time-based one time password. + */ + TOTP, + + /** + * Steam's implementation of a one time password. + */ + STEAM, + ; + + @Suppress("UndocumentedPublicClass") + companion object { + + /** + * Returns the [AuthenticatorItemType] matching [value], or null. + */ + fun fromStringOrNull(value: String): AuthenticatorItemType? = + entries.find { it.name.equals(value, ignoreCase = true) } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/sdk/AuthenticatorSdkSource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/sdk/AuthenticatorSdkSource.kt new file mode 100644 index 0000000000..6500c87451 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/sdk/AuthenticatorSdkSource.kt @@ -0,0 +1,23 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.sdk + +import com.bitwarden.core.DateTime +import com.bitwarden.vault.TotpResponse + +/** + * Source of authenticator information from the Bitwarden SDK. + */ +interface AuthenticatorSdkSource { + + /** + * Generate a verification code and the period using the totp code. + */ + suspend fun generateTotp( + totp: String, + time: DateTime, + ): Result + + /** + * Generate a random key for seeding biometrics. + */ + suspend fun generateBiometricsKey(): Result +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/sdk/AuthenticatorSdkSourceImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/sdk/AuthenticatorSdkSourceImpl.kt new file mode 100644 index 0000000000..d6991fd9cc --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/sdk/AuthenticatorSdkSourceImpl.kt @@ -0,0 +1,50 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.sdk + +import com.bitwarden.authenticator.data.platform.manager.SdkClientManager +import com.bitwarden.core.DateTime +import com.bitwarden.generators.PasswordGeneratorRequest +import com.bitwarden.sdk.Client +import com.bitwarden.vault.TotpResponse +import javax.inject.Inject + +/** + * Default implementation of [AuthenticatorSdkSource]. + */ +class AuthenticatorSdkSourceImpl @Inject constructor( + private val sdkClientManager: SdkClientManager, +) : AuthenticatorSdkSource { + + override suspend fun generateTotp( + totp: String, + time: DateTime, + ): Result = runCatching { + getClient() + .vault() + .generateTotp( + key = totp, + time = time, + ) + } + + override suspend fun generateBiometricsKey(): Result = + runCatching { + getClient() + .generators() + .password( + PasswordGeneratorRequest( + lowercase = true, + uppercase = true, + numbers = true, + special = true, + length = 7.toUByte(), + avoidAmbiguous = true, + minLowercase = null, + minUppercase = null, + minNumber = null, + minSpecial = null, + ), + ) + } + + private suspend fun getClient(): Client = sdkClientManager.getOrCreateClient() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/sdk/di/AuthenticatorSdkModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/sdk/di/AuthenticatorSdkModule.kt new file mode 100644 index 0000000000..0557fd33a3 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/sdk/di/AuthenticatorSdkModule.kt @@ -0,0 +1,24 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.sdk.di + +import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource +import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSourceImpl +import com.bitwarden.authenticator.data.platform.manager.SdkClientManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Provides SDK-related dependencies for the authenticator package. + */ +@Module +@InstallIn(SingletonComponent::class) +object AuthenticatorSdkModule { + + @Provides + @Singleton + fun provideAuthenticatorSdkSource( + sdkClientManager: SdkClientManager, + ): AuthenticatorSdkSource = AuthenticatorSdkSourceImpl(sdkClientManager) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/FileManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/FileManager.kt new file mode 100644 index 0000000000..a02dff64dd --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/FileManager.kt @@ -0,0 +1,19 @@ +package com.bitwarden.authenticator.data.authenticator.manager + +import android.net.Uri + +/** + * Manages reading and writing files. + */ +interface FileManager { + + /** + * Writes the given [dataString] to disk at the provided [fileUri] + */ + suspend fun stringToUri(fileUri: Uri, dataString: String): Boolean + + /** + * Reads the [fileUri] into memory. A successful result will contain the raw [ByteArray]. + */ + suspend fun uriToByteArray(fileUri: Uri): Result +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/FileManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/FileManagerImpl.kt new file mode 100644 index 0000000000..821c5ebf64 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/FileManagerImpl.kt @@ -0,0 +1,58 @@ +package com.bitwarden.authenticator.data.authenticator.manager + +import android.content.Context +import android.net.Uri +import com.bitwarden.authenticator.data.platform.manager.DispatcherManager +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream + +/** + * The buffer size to be used when reading from an input stream. + */ +private const val BUFFER_SIZE: Int = 1024 + +/** + * Manages reading and writing files. + */ +class FileManagerImpl( + private val context: Context, + private val dispatcherManager: DispatcherManager, +) : FileManager { + + override suspend fun stringToUri(fileUri: Uri, dataString: String): Boolean { + @Suppress("TooGenericExceptionCaught") + return try { + withContext(dispatcherManager.io) { + context + .contentResolver + .openOutputStream(fileUri) + ?.use { outputStream -> + outputStream.write(dataString.toByteArray()) + } + } + true + } catch (exception: RuntimeException) { + false + } + } + + override suspend fun uriToByteArray(fileUri: Uri): Result = + runCatching { + withContext(dispatcherManager.io) { + context + .contentResolver + .openInputStream(fileUri) + ?.use { inputStream -> + ByteArrayOutputStream().use { outputStream -> + val buffer = ByteArray(BUFFER_SIZE) + var length: Int + while (inputStream.read(buffer).also { length = it } != -1) { + outputStream.write(buffer, 0, length) + } + outputStream.toByteArray() + } + } + ?: throw IllegalStateException("Stream has crashed") + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/TotpCodeManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/TotpCodeManager.kt new file mode 100644 index 0000000000..1d7dfa2389 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/TotpCodeManager.kt @@ -0,0 +1,43 @@ +package com.bitwarden.authenticator.data.authenticator.manager + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem +import kotlinx.coroutines.flow.Flow + +/** + * Manages the flows for getting verification codes. + */ +interface TotpCodeManager { + + /** + * Flow for getting a DataState with multiple verification code items. + */ + fun getTotpCodesFlow( + itemList: List, + ): Flow> + + @Suppress("UndocumentedPublicClass") + companion object { + const val ALGORITHM_PARAM = "algorithm" + const val DIGITS_PARAM = "digits" + const val PERIOD_PARAM = "period" + const val SECRET_PARAM = "secret" + const val ISSUER_PARAM = "issuer" + + /** + * URI query parameter containing export data from Google Authenticator. + */ + const val DATA_PARAM = "data" + const val TOTP_CODE_PREFIX = "otpauth://totp" + const val STEAM_CODE_PREFIX = "steam://" + const val GOOGLE_EXPORT_PREFIX = "otpauth-migration://" + const val TOTP_DIGITS_DEFAULT = 6 + const val TOTP_DIGITS_MIN = 5 + const val TOTP_DIGITS_MAX = 10 + const val STEAM_DIGITS_DEFAULT = 5 + const val PERIOD_SECONDS_DEFAULT = 30 + val TOTP_DIGITS_RANGE = TOTP_DIGITS_MIN..TOTP_DIGITS_MAX + val ALGORITHM_DEFAULT = AuthenticatorItemAlgorithm.SHA1 + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/TotpCodeManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/TotpCodeManagerImpl.kt new file mode 100644 index 0000000000..984ab3ae57 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/TotpCodeManagerImpl.kt @@ -0,0 +1,92 @@ +package com.bitwarden.authenticator.data.authenticator.manager + +import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem +import com.bitwarden.core.DateTime +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.isActive +import java.time.Clock +import java.util.UUID +import javax.inject.Inject + +private const val ONE_SECOND_MILLISECOND = 1000L + +/** + * Primary implementation of [TotpCodeManager]. + */ +class TotpCodeManagerImpl @Inject constructor( + private val authenticatorSdkSource: AuthenticatorSdkSource, + private val clock: Clock, +) : TotpCodeManager { + + override fun getTotpCodesFlow( + itemList: List, + ): Flow> { + if (itemList.isEmpty()) { + return flowOf(emptyList()) + } + val flows = itemList.map { it.toFlowOfVerificationCodes() } + return combine(flows) { it.toList() } + } + + private fun AuthenticatorItem.toFlowOfVerificationCodes(): Flow { + val otpUri = this.otpUri + return flow { + var item: VerificationCodeItem? = null + while (currentCoroutineContext().isActive) { + val time = (clock.millis() / ONE_SECOND_MILLISECOND).toInt() + if (item == null || item.isExpired(clock)) { + // If the item is expired or we haven't generated our first item, + // generate a new code using the SDK: + item = authenticatorSdkSource + .generateTotp(otpUri, DateTime.now()) + .getOrNull() + ?.let { response -> + VerificationCodeItem( + code = response.code, + periodSeconds = response.period.toInt(), + timeLeftSeconds = response.period.toInt() - + time % response.period.toInt(), + issueTime = clock.millis(), + id = when (source) { + is AuthenticatorItem.Source.Local -> source.cipherId + is AuthenticatorItem.Source.Shared -> UUID.randomUUID() + .toString() + }, + issuer = issuer, + label = label, + source = source, + ) + } + ?: run { + // We are assuming that our otp URIs can generate a valid code. + // If they can't, we'll just silently omit that code from the list. + currentCoroutineContext().cancel() + return@flow + } + } else { + // Item is not expired, just update time left: + item = item.copy( + timeLeftSeconds = item.periodSeconds - (time % item.periodSeconds), + ) + } + // Emit item + emit(item) + // Wait one second before heading to the top of the loop: + delay(ONE_SECOND_MILLISECOND) + } + } + } +} + +private fun VerificationCodeItem.isExpired(clock: Clock): Boolean { + val timeExpired = issueTime + (timeLeftSeconds * ONE_SECOND_MILLISECOND) + return timeExpired < clock.millis() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/di/AuthenticatorManagerModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/di/AuthenticatorManagerModule.kt new file mode 100644 index 0000000000..2ee61b4b69 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/di/AuthenticatorManagerModule.kt @@ -0,0 +1,45 @@ +package com.bitwarden.authenticator.data.authenticator.manager.di + +import android.content.Context +import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource +import com.bitwarden.authenticator.data.authenticator.manager.FileManager +import com.bitwarden.authenticator.data.authenticator.manager.FileManagerImpl +import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager +import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManagerImpl +import com.bitwarden.authenticator.data.platform.manager.DispatcherManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.time.Clock +import javax.inject.Singleton + +/** + * Provides managers in the authenticator package. + */ +@Module +@InstallIn(SingletonComponent::class) +object AuthenticatorManagerModule { + + @Provides + @Singleton + fun providerFileManager( + @ApplicationContext context: Context, + dispatcherManager: DispatcherManager, + ): FileManager = FileManagerImpl( + context = context, + dispatcherManager = dispatcherManager, + ) + + @Provides + @Singleton + fun provideTotpCodeManager( + authenticatorSdkSource: AuthenticatorSdkSource, + dispatcherManager: DispatcherManager, + clock: Clock, + ): TotpCodeManager = TotpCodeManagerImpl( + authenticatorSdkSource = authenticatorSdkSource, + clock = clock, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/ExportJsonData.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/ExportJsonData.kt new file mode 100644 index 0000000000..06304c2486 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/ExportJsonData.kt @@ -0,0 +1,45 @@ +package com.bitwarden.authenticator.data.authenticator.manager.model + +import kotlinx.serialization.Serializable + +/** + * Models exported authenticator data in JSON format. + * + * This model is loosely based off of Bitwarden's exported unencrypted vault data. + */ +@Serializable +data class ExportJsonData( + val encrypted: Boolean, + val items: List, +) { + + /** + * Represents a single exported authenticator item. + * + * This model is loosely based off of Bitwarden's exported Cipher JSON. + */ + @Serializable + data class ExportItem( + val id: String, + val name: String, + val folderId: String?, + val organizationId: String?, + val collectionIds: List?, + val notes: String?, + val type: Int, + val login: ItemLoginData?, + val favorite: Boolean, + ) { + /** + * Represents the login specific data of an exported item. + * + * This model is loosely based off of Bitwarden's Cipher.Login JSON. + * + * @property totp OTP secret used to generate a verification code. + */ + @Serializable + data class ItemLoginData( + val totp: String?, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/VerificationCodeItem.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/VerificationCodeItem.kt new file mode 100644 index 0000000000..32e00d10e8 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/VerificationCodeItem.kt @@ -0,0 +1,37 @@ +package com.bitwarden.authenticator.data.authenticator.manager.model + +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem + +/** + * Models the items returned by the TotpCodeManager which are then used to display rows + * of verification items. + * + * @property code The verification code for the item. + * @property periodSeconds The time span where the code is valid in seconds. + * @property timeLeftSeconds The seconds remaining until a new code is required. + * @property issueTime The time the verification code was issued. + * @property id The cipher id of the item. + * @property username The username associated with the item. + */ +data class VerificationCodeItem( + val code: String, + val periodSeconds: Int, + val timeLeftSeconds: Int, + val issueTime: Long, + val id: String, + val issuer: String?, + val label: String?, + val source: AuthenticatorItem.Source, +) { + /** + * The composite label of the authenticator item. Used for constructing an OTPAuth URI. + * ``` + * label = issuer (“:” / “%3A”) *”%20” username + * ``` + */ + val otpAuthUriLabel = if (issuer != null) { + issuer + label?.let { ":$it" }.orEmpty() + } else { + label.orEmpty() + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepository.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepository.kt new file mode 100644 index 0000000000..80db0f401c --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepository.kt @@ -0,0 +1,93 @@ +package com.bitwarden.authenticator.data.authenticator.repository + +import android.net.Uri +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult +import com.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult +import com.bitwarden.authenticator.data.authenticator.repository.model.ExportDataResult +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportDataResult +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat +import com.bitwarden.authenticator.data.platform.repository.model.DataState +import com.bitwarden.authenticator.ui.platform.feature.settings.export.model.ExportVaultFormat +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +/** + * Provides and API for managing authenticator data. + */ +interface AuthenticatorRepository { + + /** + * Flow that represents the TOTP code result. + */ + val totpCodeFlow: Flow + + /** + * Flow that represents all ciphers for the active user. + * + * Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself + * must be collected in order to trigger state changes. + */ + val ciphersStateFlow: StateFlow>> + + /** + * Flow that represents the data for a specific vault item as found by ID. This may emit `null` + * if the item cannot be found. + */ + fun getItemStateFlow(itemId: String): StateFlow> + + /** + * State flow that represents the state of verification codes and accounts shared from the + * main Bitwarden app. + */ + val sharedCodesStateFlow: StateFlow + + /** + * Flow that represents the data for the TOTP verification codes for ciphers items. + * This may emit an empty list if any issues arise during code generation. + */ + fun getLocalVerificationCodesFlow(): StateFlow>> + + /** + * Emits the totp code result flow to listeners. + */ + fun emitTotpCodeResult(totpCodeResult: TotpCodeResult) + + /** + * Attempt to create a cipher. + */ + suspend fun createItem(item: AuthenticatorItemEntity): CreateItemResult + + /** + * Attempt to add provided [items]. + */ + suspend fun addItems(vararg items: AuthenticatorItemEntity): CreateItemResult + + /** + * Attempt to delete a cipher. + */ + suspend fun hardDeleteItem(itemId: String): DeleteItemResult + + /** + * Attempt to get the user's data for export. + */ + suspend fun exportVaultData(format: ExportVaultFormat, fileUri: Uri): ExportDataResult + + /** + * Attempt to read the user's data from a file + */ + suspend fun importVaultData( + format: ImportFileFormat, + fileData: IntentManager.FileData, + ): ImportDataResult + + /** + * Flow that emits `Unit` each time an account is synced from the main Bitwarden app for + * the first time. + */ + val firstTimeAccountSyncFlow: Flow +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryImpl.kt new file mode 100644 index 0000000000..1003119f7d --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryImpl.kt @@ -0,0 +1,386 @@ +package com.bitwarden.authenticator.data.authenticator.repository + +import android.net.Uri +import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.manager.FileManager +import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager +import com.bitwarden.authenticator.data.authenticator.manager.model.ExportJsonData +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorData +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem +import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult +import com.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult +import com.bitwarden.authenticator.data.authenticator.repository.model.ExportDataResult +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult +import com.bitwarden.authenticator.data.authenticator.repository.util.sortAlphabetically +import com.bitwarden.authenticator.data.authenticator.repository.util.toAuthenticatorItems +import com.bitwarden.authenticator.data.platform.manager.DispatcherManager +import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager +import com.bitwarden.authenticator.data.platform.manager.imports.ImportManager +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportDataResult +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.data.platform.repository.model.DataState +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.data.platform.repository.util.map +import com.bitwarden.authenticator.ui.platform.feature.settings.export.model.ExportVaultFormat +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import javax.inject.Inject + +/** + * A "stop timeout delay" in milliseconds used to let a shared coroutine continue to run for the + * specified period of time after it no longer has subscribers. + */ +private const val STOP_TIMEOUT_DELAY_MS: Long = 5_000L + +/** + * Default implementation of [AuthenticatorRepository]. + */ +@Suppress("TooManyFunctions", "LongParameterList") +class AuthenticatorRepositoryImpl @Inject constructor( + private val authenticatorBridgeManager: AuthenticatorBridgeManager, + private val authenticatorDiskSource: AuthenticatorDiskSource, + private val featureFlagManager: FeatureFlagManager, + private val totpCodeManager: TotpCodeManager, + private val fileManager: FileManager, + private val importManager: ImportManager, + private val settingRepository: SettingsRepository, + dispatcherManager: DispatcherManager, +) : AuthenticatorRepository { + + private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) + + private val mutableCiphersStateFlow = + MutableStateFlow>>(DataState.Loading) + + private val mutableTotpCodeResultFlow = + bufferedMutableSharedFlow() + + private val firstTimeAccountSyncChannel: Channel = + Channel(capacity = Channel.UNLIMITED) + + override val totpCodeFlow: Flow + get() = mutableTotpCodeResultFlow.asSharedFlow() + + private val authenticatorDataFlow: StateFlow> = + ciphersStateFlow.map { cipherDataState -> + when (cipherDataState) { + is DataState.Error -> { + DataState.Error( + cipherDataState.error, + AuthenticatorData(cipherDataState.data.orEmpty()), + ) + } + + is DataState.Loaded -> { + DataState.Loaded(AuthenticatorData(items = cipherDataState.data)) + } + + DataState.Loading -> { + DataState.Loading + } + + is DataState.NoNetwork -> { + DataState.NoNetwork(AuthenticatorData(items = cipherDataState.data.orEmpty())) + } + + is DataState.Pending -> { + DataState.Pending(AuthenticatorData(items = cipherDataState.data)) + } + } + }.stateIn( + scope = unconfinedScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = STOP_TIMEOUT_DELAY_MS), + initialValue = DataState.Loading, + ) + + override val ciphersStateFlow: StateFlow>> + get() = mutableCiphersStateFlow.asStateFlow() + + init { + authenticatorDiskSource + .getItems() + .onStart { + mutableCiphersStateFlow.value = DataState.Loading + } + .onEach { + mutableCiphersStateFlow.value = DataState.Loaded(it.sortAlphabetically()) + } + .launchIn(unconfinedScope) + + authenticatorBridgeManager + .accountSyncStateFlow + .onEach { emitFirstTimeSyncIfNeeded(it) } + .launchIn(unconfinedScope) + } + + override fun getItemStateFlow(itemId: String): StateFlow> = + authenticatorDataFlow + .map { dataState -> + dataState.map { authenticatorData -> + authenticatorData + .items + .find { it.id == itemId } + } + } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_DELAY_MS), + initialValue = DataState.Loading, + ) + + @OptIn(ExperimentalCoroutinesApi::class) + override val sharedCodesStateFlow: StateFlow by lazy { + if (!featureFlagManager.getFeatureFlag(FlagKey.PasswordManagerSync)) { + MutableStateFlow(SharedVerificationCodesState.FeatureNotEnabled) + } else { + authenticatorBridgeManager + .accountSyncStateFlow + .flatMapLatest { accountSyncState -> + when (accountSyncState) { + AccountSyncState.AppNotInstalled -> + MutableStateFlow(SharedVerificationCodesState.AppNotInstalled) + + AccountSyncState.SyncNotEnabled -> + MutableStateFlow(SharedVerificationCodesState.SyncNotEnabled) + + AccountSyncState.Error -> + MutableStateFlow(SharedVerificationCodesState.Error) + + AccountSyncState.Loading -> + MutableStateFlow(SharedVerificationCodesState.Loading) + + AccountSyncState.OsVersionNotSupported -> MutableStateFlow( + SharedVerificationCodesState.OsVersionNotSupported, + ) + + is AccountSyncState.Success -> { + val verificationCodesList = + accountSyncState.accounts.toAuthenticatorItems() + totpCodeManager + .getTotpCodesFlow(verificationCodesList) + .map { SharedVerificationCodesState.Success(it) } + } + } + } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.WhileSubscribed(), + initialValue = SharedVerificationCodesState.Loading, + ) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun getLocalVerificationCodesFlow(): StateFlow>> { + return authenticatorDataFlow + .map { dataState -> + dataState + .map { authenticatorData -> + authenticatorData.items + .map { entity -> + AuthenticatorItem( + source = AuthenticatorItem.Source.Local( + cipherId = entity.id, + isFavorite = entity.favorite, + ), + otpUri = entity.toOtpAuthUriString(), + issuer = entity.issuer, + label = entity.accountName, + ) + } + } + } + .flatMapLatest { authenticatorItems -> + when (authenticatorItems) { + is DataState.Error -> flowOf(DataState.Error(authenticatorItems.error)) + is DataState.NoNetwork -> flowOf(DataState.NoNetwork()) + DataState.Loading -> flowOf(DataState.Loading) + is DataState.Loaded -> totpCodeManager.getTotpCodesFlow(authenticatorItems.data) + .map { DataState.Loaded(it) } + + is DataState.Pending -> totpCodeManager + .getTotpCodesFlow(authenticatorItems.data) + .map { DataState.Loaded(it) } + } + } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_DELAY_MS), + initialValue = DataState.Loading, + ) + } + + override fun emitTotpCodeResult(totpCodeResult: TotpCodeResult) { + mutableTotpCodeResultFlow.tryEmit(totpCodeResult) + } + + @Suppress("TooGenericExceptionCaught") + override suspend fun createItem(item: AuthenticatorItemEntity): CreateItemResult { + return try { + authenticatorDiskSource.saveItem(item) + CreateItemResult.Success + } catch (e: Exception) { + CreateItemResult.Error + } + } + + @Suppress("TooGenericExceptionCaught") + override suspend fun addItems(vararg items: AuthenticatorItemEntity): CreateItemResult { + return try { + authenticatorDiskSource.saveItem(*items) + CreateItemResult.Success + } catch (e: Exception) { + CreateItemResult.Error + } + } + + @Suppress("TooGenericExceptionCaught") + override suspend fun hardDeleteItem(itemId: String): DeleteItemResult { + return try { + authenticatorDiskSource.deleteItem(itemId) + DeleteItemResult.Success + } catch (e: Exception) { + DeleteItemResult.Error + } + } + + override suspend fun exportVaultData( + format: ExportVaultFormat, + fileUri: Uri, + ): ExportDataResult { + return when (format) { + ExportVaultFormat.JSON -> encodeVaultDataToJson(fileUri) + ExportVaultFormat.CSV -> encodeVaultDataToCsv(fileUri) + } + } + + override suspend fun importVaultData( + format: ImportFileFormat, + fileData: IntentManager.FileData, + ): ImportDataResult = fileManager.uriToByteArray(fileData.uri) + .map { + importManager + .import( + importFileFormat = format, + byteArray = it, + ) + } + .fold( + onSuccess = { it }, + onFailure = { ImportDataResult.Error() }, + ) + + override val firstTimeAccountSyncFlow: Flow + get() = firstTimeAccountSyncChannel.receiveAsFlow() + + private suspend fun encodeVaultDataToCsv(fileUri: Uri): ExportDataResult { + val headerLine = + "folder,favorite,type,name,login_uri,login_totp" + val dataLines = authenticatorDiskSource + .getItems() + .firstOrNull() + .orEmpty() + .joinToString("\n") { it.toCsvFormat() } + + val csvString = "$headerLine\n$dataLines" + + return if (fileManager.stringToUri(fileUri = fileUri, dataString = csvString)) { + ExportDataResult.Success + } else { + ExportDataResult.Error + } + } + + private fun AuthenticatorItemEntity.toCsvFormat() = + ",,1,$issuer,,${toOtpAuthUriString()},$issuer,$period,$digits" + + private suspend fun encodeVaultDataToJson(fileUri: Uri): ExportDataResult { + val dataString: String = Json.encodeToString( + ExportJsonData( + encrypted = false, + items = authenticatorDiskSource + .getItems() + .firstOrNull() + .orEmpty() + .map { it.toExportJsonItem() }, + ), + ) + + return if ( + fileManager.stringToUri( + fileUri = fileUri, + dataString = dataString, + ) + ) { + ExportDataResult.Success + } else { + ExportDataResult.Error + } + } + + private fun AuthenticatorItemEntity.toExportJsonItem() = ExportJsonData.ExportItem( + id = id, + folderId = null, + organizationId = null, + collectionIds = null, + name = issuer, + notes = null, + type = 1, + login = ExportJsonData.ExportItem.ItemLoginData( + totp = toOtpAuthUriString(), + ), + favorite = false, + ) + + private fun emitFirstTimeSyncIfNeeded(state: AccountSyncState) { + when (state) { + AccountSyncState.AppNotInstalled, + AccountSyncState.Error, + AccountSyncState.Loading, + AccountSyncState.OsVersionNotSupported, + AccountSyncState.SyncNotEnabled, + -> Unit + + is AccountSyncState.Success -> { + val previouslySyncedAccounts = settingRepository.previouslySyncedBitwardenAccountIds + val fistTimeSyncedAccounts = state + .accounts + .map { it.userId } + .filterNot { previouslySyncedAccounts.contains(it) } + // If there are fist time synced accounts, emit to the first time sync channel + // and store the new account IDs: + if (fistTimeSyncedAccounts.isNotEmpty()) { + firstTimeAccountSyncChannel.trySend(Unit) + settingRepository.previouslySyncedBitwardenAccountIds = + previouslySyncedAccounts + fistTimeSyncedAccounts + } + } + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/di/AuthenticatorBridgeModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/di/AuthenticatorBridgeModule.kt new file mode 100644 index 0000000000..645f4afc3e --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/di/AuthenticatorBridgeModule.kt @@ -0,0 +1,66 @@ +package com.bitwarden.authenticator.data.authenticator.repository.di + +import android.content.Context +import com.bitwarden.authenticator.BuildConfig +import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource +import com.bitwarden.authenticator.data.authenticator.repository.util.SymmetricKeyStorageProviderImpl +import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticatorbridge.factory.AuthenticatorBridgeFactory +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState +import com.bitwarden.authenticatorbridge.provider.SymmetricKeyStorageProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Singleton + +/** + * Provides repositories in the authenticator package. + */ +@Module +@InstallIn(SingletonComponent::class) +object AuthenticatorBridgeModule { + + @Provides + @Singleton + fun provideAuthenticatorBridgeFactory( + @ApplicationContext + context: Context, + ): AuthenticatorBridgeFactory = AuthenticatorBridgeFactory(context) + + @Provides + @Singleton + fun provideAuthenticatorBridgeManager( + factory: AuthenticatorBridgeFactory, + symmetricKeyStorageProvider: SymmetricKeyStorageProvider, + featureFlagManager: FeatureFlagManager, + ): AuthenticatorBridgeManager = + if (featureFlagManager.getFeatureFlag(FlagKey.PasswordManagerSync)) { + factory.getAuthenticatorBridgeManager( + connectionType = BuildConfig.AUTHENTICATOR_BRIDGE_CONNECTION_TYPE, + symmetricKeyStorageProvider = symmetricKeyStorageProvider, + ) + } else { + // If feature flag is not enabled, return no-op bridge manager so we never + // connect to bridge service: + object : AuthenticatorBridgeManager { + override val accountSyncStateFlow: StateFlow + get() = MutableStateFlow(AccountSyncState.Loading) + + override fun startAddTotpLoginItemFlow(totpUri: String): Boolean = false + } + } + + @Provides + fun providesSymmetricKeyStorageProvider( + authDiskSource: AuthDiskSource, + ): SymmetricKeyStorageProvider = + SymmetricKeyStorageProviderImpl( + authDiskSource = authDiskSource, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/di/AuthenticatorRepositoryModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/di/AuthenticatorRepositoryModule.kt new file mode 100644 index 0000000000..8ea96f15e3 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/di/AuthenticatorRepositoryModule.kt @@ -0,0 +1,47 @@ +package com.bitwarden.authenticator.data.authenticator.repository.di + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource +import com.bitwarden.authenticator.data.authenticator.manager.FileManager +import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepositoryImpl +import com.bitwarden.authenticator.data.platform.manager.DispatcherManager +import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager +import com.bitwarden.authenticator.data.platform.manager.imports.ImportManager +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Provides repositories in the authenticator package. + */ +@Module +@InstallIn(SingletonComponent::class) +object AuthenticatorRepositoryModule { + + @Provides + @Singleton + fun provideAuthenticatorRepository( + authenticatorBridgeManager: AuthenticatorBridgeManager, + authenticatorDiskSource: AuthenticatorDiskSource, + featureFlagManager: FeatureFlagManager, + dispatcherManager: DispatcherManager, + fileManager: FileManager, + importManager: ImportManager, + totpCodeManager: TotpCodeManager, + settingsRepository: SettingsRepository, + ): AuthenticatorRepository = AuthenticatorRepositoryImpl( + authenticatorBridgeManager = authenticatorBridgeManager, + authenticatorDiskSource = authenticatorDiskSource, + featureFlagManager = featureFlagManager, + dispatcherManager = dispatcherManager, + fileManager = fileManager, + importManager = importManager, + totpCodeManager = totpCodeManager, + settingRepository = settingsRepository, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/AuthenticatorData.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/AuthenticatorData.kt new file mode 100644 index 0000000000..55959fe3a2 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/AuthenticatorData.kt @@ -0,0 +1,12 @@ +package com.bitwarden.authenticator.data.authenticator.repository.model + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity + +/** + * Represents decrypted authenticator data. + * + * @property items List of decrypted authenticator items. + */ +data class AuthenticatorData( + val items: List, +) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/AuthenticatorItem.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/AuthenticatorItem.kt new file mode 100644 index 0000000000..1410e16141 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/AuthenticatorItem.kt @@ -0,0 +1,51 @@ +package com.bitwarden.authenticator.data.authenticator.repository.model + +/** + * Represents all the information required to generate TOTP verification codes, including both + * local codes and codes shared from the main Bitwarden app. + * + * @param source Distinguishes between local and shared items. + * @param otpUri OTP URI. + * @param issuer The issuer of the codes. + * @param label The label of the item. + */ +data class AuthenticatorItem( + val source: Source, + val otpUri: String, + val issuer: String?, + val label: String?, +) { + + /** + * Contains data about where the source of truth for a [AuthenticatorItem] is. + */ + sealed class Source { + + /** + * The item is from the local Authenticator app database. + * + * @param cipherId Local cipher ID. + * @param isFavorite Whether or not the user has marked the item as a favorite. + */ + data class Local( + val cipherId: String, + val isFavorite: Boolean, + ) : Source() + + /** + * The item is shared from the main Bitwarden app. + * + * @param userId User ID from the main Bitwarden app. Used to group authenticator items + * by account. + * @param nameOfUser Username from the main Bitwarden app. + * @param email Email of the user. + * @param environmentLabel Label for the Bitwarden environment, like "bitwaren.com" + */ + data class Shared( + val userId: String, + val nameOfUser: String?, + val email: String, + val environmentLabel: String, + ) : Source() + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/CreateItemResult.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/CreateItemResult.kt new file mode 100644 index 0000000000..b5fd645553 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/CreateItemResult.kt @@ -0,0 +1,17 @@ +package com.bitwarden.authenticator.data.authenticator.repository.model + +/** + * Models result of creating a cipher. + */ +sealed class CreateItemResult { + + /** + * Cipher created successfully. + */ + data object Success : CreateItemResult() + + /** + * Generic error while creating cipher. + */ + data object Error : CreateItemResult() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/DeleteItemResult.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/DeleteItemResult.kt new file mode 100644 index 0000000000..26547e5a25 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/DeleteItemResult.kt @@ -0,0 +1,17 @@ +package com.bitwarden.authenticator.data.authenticator.repository.model + +/** + * Models result of deleting a cipher. + */ +sealed class DeleteItemResult { + + /** + * Cipher deleted successfully. + */ + data object Success : DeleteItemResult() + + /** + * Generic error while deleting a cipher. + */ + data object Error : DeleteItemResult() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/ExportDataResult.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/ExportDataResult.kt new file mode 100644 index 0000000000..55f35ee16f --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/ExportDataResult.kt @@ -0,0 +1,17 @@ +package com.bitwarden.authenticator.data.authenticator.repository.model + +/** + * Represents the result of a data export operation. + */ +sealed class ExportDataResult { + + /** + * Data has been successfully exported. + */ + data object Success : ExportDataResult() + + /** + * Data could not be exported. + */ + data object Error : ExportDataResult() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/SharedVerificationCodesState.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/SharedVerificationCodesState.kt new file mode 100644 index 0000000000..661c13a2e2 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/SharedVerificationCodesState.kt @@ -0,0 +1,46 @@ +package com.bitwarden.authenticator.data.authenticator.repository.model + +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem + +/** + * Represents the state of verification codes shared from the main Bitwarden app. + */ +sealed class SharedVerificationCodesState { + + /** + * The Bitwarden app is not installed and therefore accounts cannot be synced. + */ + data object AppNotInstalled : SharedVerificationCodesState() + + /** + * Something went wrong syncing accounts. + */ + data object Error : SharedVerificationCodesState() + + /** + * The feature flag for authenticator sync is not enabled. + */ + data object FeatureNotEnabled : SharedVerificationCodesState() + + /** + * State is loading. + */ + data object Loading : SharedVerificationCodesState() + + /** + * OS version can't support account syncing. + */ + data object OsVersionNotSupported : SharedVerificationCodesState() + + /** + * Successfully synced items. + */ + data class Success( + val items: List, + ) : SharedVerificationCodesState() + + /** + * The user needs to enable authenticator syncing from the bitwarden app. + */ + data object SyncNotEnabled : SharedVerificationCodesState() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/TotpCodeResult.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/TotpCodeResult.kt new file mode 100644 index 0000000000..0cd9ea8486 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/TotpCodeResult.kt @@ -0,0 +1,22 @@ +package com.bitwarden.authenticator.data.authenticator.repository.model + +/** + * Models result of the user adding a totp code. + */ +sealed class TotpCodeResult { + + /** + * Code containing an OTP URI has been successfully scanned. + */ + data class TotpCodeScan(val code: String) : TotpCodeResult() + + /** + * Code containing exported data from Google Authenticator was scanned. + */ + data class GoogleExportScan(val data: String) : TotpCodeResult() + + /** + * There was an error scanning the code. + */ + data object CodeScanningError : TotpCodeResult() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/UpdateItemRequest.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/UpdateItemRequest.kt new file mode 100644 index 0000000000..36e8c860c6 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/UpdateItemRequest.kt @@ -0,0 +1,38 @@ +package com.bitwarden.authenticator.data.authenticator.repository.model + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType + +/** + * Models a request to modify an existing authenticator item. + * + * @property key Key used to generate verification codes for the authenticator item. + * @property accountName Required account or username associated with the item. + * @property type Type of authenticator item. + * @property algorithm Hashing algorithm applied to the authenticator item verification code. + * @property period Time, in seconds, the authenticator item verification code is valid. + * @property digits Number of digits contained in the verification code for this authenticator item. + * @property issuer Entity that provided the authenticator item. + */ +data class UpdateItemRequest( + val key: String, + val accountName: String?, + val type: AuthenticatorItemType, + val algorithm: AuthenticatorItemAlgorithm, + val period: Int, + val digits: Int, + val issuer: String, + val favorite: Boolean, +) { + /** + * The composite label of the authenticator item. Derived from combining [issuer] and [accountName] + * ``` + * label = accountName /issuer (“:” / “%3A”) *”%20” accountName + * ``` + */ + val label = if (accountName.isNullOrBlank()) { + issuer + } else { + "$issuer:$accountName" + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/UpdateItemResult.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/UpdateItemResult.kt new file mode 100644 index 0000000000..edf6c5b736 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/UpdateItemResult.kt @@ -0,0 +1,18 @@ +package com.bitwarden.authenticator.data.authenticator.repository.model + +/** + * Models result of updating an authenticator item. + */ +sealed class UpdateItemResult { + + /** + * Item updated successfully. + */ + data object Success : UpdateItemResult() + + /** + * Generic error while updating an item. The optional [errorMessage] may be displayed directly + * in the UI when present. + */ + data class Error(val errorMessage: String?) : UpdateItemResult() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/AuthenticatorItemEntityExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/AuthenticatorItemEntityExtensions.kt new file mode 100644 index 0000000000..2ca760242a --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/AuthenticatorItemEntityExtensions.kt @@ -0,0 +1,17 @@ +package com.bitwarden.authenticator.data.authenticator.repository.util + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.platform.util.SpecialCharWithPrecedenceComparator + +/** + * Sorts the data in alphabetical order by name. Using lexicographical sorting but giving + * precedence to special characters over letters and digits. + */ +@JvmName("toAlphabeticallySortedCipherList") +fun List.sortAlphabetically(): List { + return this.sortedWith( + comparator = { cipher1, cipher2 -> + SpecialCharWithPrecedenceComparator.compare(cipher1.issuer, cipher2.issuer) + }, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/SharedAccountDataExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/SharedAccountDataExtensions.kt new file mode 100644 index 0000000000..9e5844881c --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/SharedAccountDataExtensions.kt @@ -0,0 +1,35 @@ +package com.bitwarden.authenticator.data.authenticator.repository.util + +import android.net.Uri +import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem +import com.bitwarden.authenticatorbridge.model.SharedAccountData + +/** + * Convert a list of [SharedAccountData.Account] to a list of [AuthenticatorItem]. + */ +fun List.toAuthenticatorItems(): List = + flatMap { sharedAccount -> + sharedAccount.totpUris.mapNotNull { totpUriString -> + runCatching { + val uri = Uri.parse(totpUriString) + val issuer = uri.getQueryParameter(TotpCodeManager.ISSUER_PARAM) + val label = uri.pathSegments + .firstOrNull() + ?.removePrefix("$issuer:") + + AuthenticatorItem( + source = AuthenticatorItem.Source.Shared( + userId = sharedAccount.userId, + nameOfUser = sharedAccount.name, + email = sharedAccount.email, + environmentLabel = sharedAccount.environmentLabel, + ), + otpUri = totpUriString, + issuer = issuer, + label = label, + ) + } + .getOrNull() + } + } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/SharedVerificationCodesStateExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/SharedVerificationCodesStateExtensions.kt new file mode 100644 index 0000000000..8e52d3e203 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/SharedVerificationCodesStateExtensions.kt @@ -0,0 +1,38 @@ +package com.bitwarden.authenticator.data.authenticator.repository.util + +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState + +/** + * Whether or not the user has enabled sync with Bitwarden and the two apps are successfully + * syncing. This is useful to know when to show certain sync UI and also when to support + * moving codes to Bitwarden. + */ +val SharedVerificationCodesState.isSyncWithBitwardenEnabled: Boolean + get() = when (this) { + SharedVerificationCodesState.AppNotInstalled, + SharedVerificationCodesState.Error, + SharedVerificationCodesState.FeatureNotEnabled, + SharedVerificationCodesState.Loading, + SharedVerificationCodesState.OsVersionNotSupported, + SharedVerificationCodesState.SyncNotEnabled, + -> false + + is SharedVerificationCodesState.Success -> true + } + +/** + * Get a list of shared items, or empty if there are no shared items. + */ +val SharedVerificationCodesState.itemsOrEmpty: List + get() = when (this) { + SharedVerificationCodesState.AppNotInstalled, + SharedVerificationCodesState.Error, + SharedVerificationCodesState.FeatureNotEnabled, + SharedVerificationCodesState.Loading, + SharedVerificationCodesState.OsVersionNotSupported, + SharedVerificationCodesState.SyncNotEnabled, + -> emptyList() + + is SharedVerificationCodesState.Success -> this.items + } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/SymmetricKeyStorageProviderImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/SymmetricKeyStorageProviderImpl.kt new file mode 100644 index 0000000000..1efc07d35a --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/SymmetricKeyStorageProviderImpl.kt @@ -0,0 +1,22 @@ +package com.bitwarden.authenticator.data.authenticator.repository.util + +import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource +import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyData +import com.bitwarden.authenticatorbridge.provider.SymmetricKeyStorageProvider +import com.bitwarden.authenticatorbridge.util.toSymmetricEncryptionKeyData + +/** + * Implementation of [SymmetricKeyStorageProvider] that stores symmetric key data in encrypted + * shared preferences. + */ +class SymmetricKeyStorageProviderImpl( + private val authDiskSource: AuthDiskSource, +) : SymmetricKeyStorageProvider { + + override var symmetricKey: SymmetricEncryptionKeyData? + get() = authDiskSource.authenticatorBridgeSymmetricSyncKey?.toSymmetricEncryptionKeyData() + set(value) { + authDiskSource.authenticatorBridgeSymmetricSyncKey = + value?.symmetricEncryptionKey?.byteArray + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/annotation/OmitFromCoverage.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/annotation/OmitFromCoverage.kt new file mode 100644 index 0000000000..e335d4bdfb --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/annotation/OmitFromCoverage.kt @@ -0,0 +1,13 @@ +package com.bitwarden.authenticator.data.platform.annotation + +/** + * Used to omit the annotated class from test coverage reporting. This should be used sparingly and + * is intended for non-testable classes that are placed in packages along with testable ones. + */ +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FILE, + AnnotationTarget.FUNCTION, +) +@Retention(AnnotationRetention.BINARY) +annotation class OmitFromCoverage diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/di/EncryptedPreferences.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/di/EncryptedPreferences.kt new file mode 100644 index 0000000000..64e6549891 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/di/EncryptedPreferences.kt @@ -0,0 +1,11 @@ +package com.bitwarden.authenticator.data.platform.datasource.di + +import android.content.SharedPreferences +import javax.inject.Qualifier + +/** + * Used to denote an instance of [SharedPreferences] that encrypts its data. + */ +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class EncryptedPreferences diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/di/PreferenceModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/di/PreferenceModule.kt new file mode 100644 index 0000000000..78bb6dbbb0 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/di/PreferenceModule.kt @@ -0,0 +1,48 @@ +package com.bitwarden.authenticator.data.platform.datasource.di + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Provides dependencies related to encryption / decryption / secure generation. + */ +@Module +@InstallIn(SingletonComponent::class) +object PreferenceModule { + + @Provides + @Singleton + @UnencryptedPreferences + fun provideUnencryptedSharedPreferences( + application: Application, + ): SharedPreferences = + application.getSharedPreferences( + "${application.packageName}_preferences", + Context.MODE_PRIVATE, + ) + + @Provides + @Singleton + @EncryptedPreferences + fun provideEncryptedSharedPreferences( + application: Application, + ): SharedPreferences = + EncryptedSharedPreferences + .create( + application, + "${application.packageName}_encrypted_preferences", + MasterKey.Builder(application) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/di/UnencryptedPreferences.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/di/UnencryptedPreferences.kt new file mode 100644 index 0000000000..9de8579b5e --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/di/UnencryptedPreferences.kt @@ -0,0 +1,11 @@ +package com.bitwarden.authenticator.data.platform.datasource.di + +import android.content.SharedPreferences +import javax.inject.Qualifier + +/** + * Used to denote an instance of [SharedPreferences] that does not encrypt its data. + */ +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class UnencryptedPreferences diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/BaseDiskSource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/BaseDiskSource.kt new file mode 100644 index 0000000000..d5d7b6323c --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/BaseDiskSource.kt @@ -0,0 +1,140 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import android.content.SharedPreferences +import androidx.core.content.edit + +/** + * Base class for simplifying interactions with [SharedPreferences]. + */ +@Suppress("UnnecessaryAbstractClass", "TooManyFunctions") +abstract class BaseDiskSource( + private val sharedPreferences: SharedPreferences, +) { + /** + * Gets the [Boolean] for the given [key] from [SharedPreferences], or return the [default] + * value if that key is not present. + */ + protected fun getBoolean( + key: String, + default: Boolean? = null, + ): Boolean? = + if (sharedPreferences.contains(key)) { + sharedPreferences.getBoolean(key, false) + } else { + // Make sure we can return a null value as a default if necessary + default + } + + /** + * Puts the [value] in [SharedPreferences] for the given [key] (or removes the key when the + * value is `null`). + */ + protected fun putBoolean( + key: String, + value: Boolean?, + ): Unit = + sharedPreferences.edit { + if (value != null) { + putBoolean(key, value) + } else { + remove(key) + } + } + + /** + * Gets the [Int] for the given [key] from [SharedPreferences], or return the [default] value + * if that key is not present. + */ + protected fun getInt( + key: String, + default: Int? = null, + ): Int? = + if (sharedPreferences.contains(key)) { + sharedPreferences.getInt(key, 0) + } else { + // Make sure we can return a null value as a default if necessary + default + } + + /** + * Puts the [value] in [SharedPreferences] for the given [key] (or removes the key when the + * value is `null`). + */ + protected fun putInt( + key: String, + value: Int?, + ): Unit = + sharedPreferences.edit { + if (value != null) { + putInt(key, value) + } else { + remove(key) + } + } + + /** + * Gets the [Long] for the given [key] from [SharedPreferences], or return the [default] value + * if that key is not present. + */ + protected fun getLong( + key: String, + default: Long? = null, + ): Long? = + if (sharedPreferences.contains(key)) { + sharedPreferences.getLong(key, 0) + } else { + // Make sure we can return a null value as a default if necessary + default + } + + /** + * Puts the [value] in [SharedPreferences] for the given [key] (or removes the key when the + * value is `null`). + */ + protected fun putLong( + key: String, + value: Long?, + ): Unit = + sharedPreferences.edit { + if (value != null) { + putLong(key, value) + } else { + remove(key) + } + } + + protected fun getString( + key: String, + default: String? = null, + ): String? = sharedPreferences.getString(key, default) + + protected fun putString( + key: String, + value: String?, + ): Unit = sharedPreferences.edit { putString(key, value) } + + protected fun removeWithPrefix(prefix: String) { + sharedPreferences + .all + .keys + .filter { it.startsWith(prefix) } + .forEach { sharedPreferences.edit { remove(it) } } + } + + protected fun putStringSet( + key: String, + value: Set?, + ): Unit = sharedPreferences.edit { + putStringSet(key, value) + } + + protected fun getStringSet( + key: String, + default: Set?, + ): Set? = sharedPreferences.getStringSet(key, default) + + @Suppress("UndocumentedPublicClass") + companion object { + const val BASE_KEY: String = "bwPreferencesStorage" + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/BaseEncryptedDiskSource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/BaseEncryptedDiskSource.kt new file mode 100644 index 0000000000..bcb18feae3 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/BaseEncryptedDiskSource.kt @@ -0,0 +1,32 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.security.crypto.EncryptedSharedPreferences + +/** + * Base class for simplifying interactions with [SharedPreferences] and + * [EncryptedSharedPreferences]. + */ +@Suppress("UnnecessaryAbstractClass") +abstract class BaseEncryptedDiskSource( + sharedPreferences: SharedPreferences, + private val encryptedSharedPreferences: SharedPreferences, +) : BaseDiskSource( + sharedPreferences = sharedPreferences, +) { + protected fun getEncryptedString( + key: String, + default: String? = null, + ): String? = encryptedSharedPreferences.getString(key, default) + + protected fun putEncryptedString( + key: String, + value: String?, + ): Unit = encryptedSharedPreferences.edit { putString(key, value) } + + @Suppress("UndocumentedPublicClass") + companion object { + const val ENCRYPTED_BASE_KEY: String = "bwSecureStorage" + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSource.kt new file mode 100644 index 0000000000..04a03173f5 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSource.kt @@ -0,0 +1,21 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import kotlinx.coroutines.flow.Flow + +/** + * Primary access point for server configuration-related disk information. + */ +interface ConfigDiskSource { + + /** + * The currently persisted [ServerConfig] (or `null` if not set). + */ + var serverConfig: ServerConfig? + + /** + * Emits updates that track [ServerConfig]. This will replay the last known value, + * if any. + */ + val serverConfigFlow: Flow +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSourceImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSourceImpl.kt new file mode 100644 index 0000000000..52e46bc3d8 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSourceImpl.kt @@ -0,0 +1,38 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import android.content.SharedPreferences +import com.bitwarden.authenticator.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.data.platform.util.decodeFromStringOrNull +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onSubscription +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private const val SERVER_CONFIGURATIONS = "$BASE_KEY:serverConfigurations" + +/** + * Primary implementation of [ConfigDiskSource]. + */ +class ConfigDiskSourceImpl( + sharedPreferences: SharedPreferences, + private val json: Json, +) : BaseDiskSource(sharedPreferences = sharedPreferences), + ConfigDiskSource { + + override var serverConfig: ServerConfig? + get() = getString(key = SERVER_CONFIGURATIONS)?.let { json.decodeFromStringOrNull(it) } + set(value) { + putString( + key = SERVER_CONFIGURATIONS, + value = value?.let { json.encodeToString(it) }, + ) + mutableServerConfigFlow.tryEmit(value) + } + + override val serverConfigFlow: Flow + get() = mutableServerConfigFlow.onSubscription { emit(serverConfig) } + + private val mutableServerConfigFlow = bufferedMutableSharedFlow(replay = 1) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagDiskSource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagDiskSource.kt new file mode 100644 index 0000000000..93b9e6273c --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagDiskSource.kt @@ -0,0 +1,20 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import com.bitwarden.authenticator.data.platform.datasource.disk.model.FeatureFlagsConfiguration +import kotlinx.coroutines.flow.Flow + +/** + * Primary access point for feature flag configuration. + */ +interface FeatureFlagDiskSource { + + /** + * The currently persisted [FeatureFlagsConfiguration]. + */ + var featureFlagsConfiguration: FeatureFlagsConfiguration? + + /** + * Emits updates to track [FeatureFlagsConfiguration]. This will replay the last known value. + */ + val featureFlagsConfigurationFlow: Flow +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagDiskSourceImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagDiskSourceImpl.kt new file mode 100644 index 0000000000..97203518a1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagDiskSourceImpl.kt @@ -0,0 +1,42 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import android.content.SharedPreferences +import com.bitwarden.authenticator.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY +import com.bitwarden.authenticator.data.platform.datasource.disk.model.FeatureFlagsConfiguration +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.data.platform.util.decodeFromStringOrNull +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onSubscription +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private const val KEY_FEATURE_FLAGS = "$BASE_KEY:featureFlags" + +/** + * Primary implementation of [FeatureFlagDiskSource]. + */ +class FeatureFlagDiskSourceImpl( + sharedPreferences: SharedPreferences, + private val json: Json, +) : BaseDiskSource(sharedPreferences = sharedPreferences), + FeatureFlagDiskSource { + + private val mutableFeatureFlagsConfigurationFlow = + bufferedMutableSharedFlow(replay = 1) + + override val featureFlagsConfigurationFlow: Flow + get() = mutableFeatureFlagsConfigurationFlow.onSubscription { + emit(featureFlagsConfiguration) + } + + override var featureFlagsConfiguration: FeatureFlagsConfiguration? + get() = getString(key = KEY_FEATURE_FLAGS) + ?.let { json.decodeFromStringOrNull(it) } + set(value) { + putString( + key = KEY_FEATURE_FLAGS, + value = value.let { json.encodeToString(it) }, + ) + mutableFeatureFlagsConfigurationFlow.tryEmit(value) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagOverrideDiskSource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagOverrideDiskSource.kt new file mode 100644 index 0000000000..3b33f6f707 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagOverrideDiskSource.kt @@ -0,0 +1,19 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey + +/** + * Disk data source for saved feature flag overrides. + */ +interface FeatureFlagOverrideDiskSource { + + /** + * Save a feature flag [FlagKey] to disk. + */ + fun saveFeatureFlag(key: FlagKey, value: T) + + /** + * Get a feature flag value based on the associated [FlagKey] from disk. + */ + fun getFeatureFlag(key: FlagKey): T? +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagOverrideDiskSourceImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagOverrideDiskSourceImpl.kt new file mode 100644 index 0000000000..57868e89a1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagOverrideDiskSourceImpl.kt @@ -0,0 +1,37 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import android.content.SharedPreferences +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey + +/** + * Default implementation of the [FeatureFlagOverrideDiskSource] + */ +class FeatureFlagOverrideDiskSourceImpl( + sharedPreferences: SharedPreferences, +) : FeatureFlagOverrideDiskSource, BaseDiskSource(sharedPreferences) { + + override fun saveFeatureFlag(key: FlagKey, value: T) { + when (key.defaultValue) { + is Boolean -> putBoolean(key.keyName, value as Boolean) + is String -> putString(key.keyName, value as String) + is Int -> putInt(key.keyName, value as Int) + else -> Unit + } + } + + @Suppress("UNCHECKED_CAST") + override fun getFeatureFlag(key: FlagKey): T? { + return try { + when (key.defaultValue) { + is Boolean -> getBoolean(key.keyName) as? T + is String -> getString(key.keyName) as? T + is Int -> getInt(key.keyName) as? T + else -> null + } + } catch (castException: ClassCastException) { + null + } catch (numberFormatException: NumberFormatException) { + null + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt new file mode 100644 index 0000000000..cf8f746f10 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt @@ -0,0 +1,125 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import kotlinx.coroutines.flow.Flow + +/** + * Primary access point for general settings-related disk information. + */ +interface SettingsDiskSource { + + /** + * The currently persisted app language (or `null` if not set). + */ + var appLanguage: AppLanguage? + + /** + * The currently persisted app theme (or `null` if not set). + */ + var appTheme: AppTheme + + /** + * Emits updates that track [appTheme]. + */ + val appThemeFlow: Flow + + /** + * The currently persisted default save option. + */ + var defaultSaveOption: DefaultSaveOption + + /** + * Flow that emits changes to [defaultSaveOption] + */ + val defaultSaveOptionFlow: Flow + + /** + * The currently persisted biometric integrity source for the system. + */ + var systemBiometricIntegritySource: String? + + /** + * Tracks whether user has seen the Welcome tutorial. + */ + var hasSeenWelcomeTutorial: Boolean + + /** + * A set of Bitwarden account IDs that have previously been synced. + */ + var previouslySyncedBitwardenAccountIds: Set + + /** + * Emits update that track [hasSeenWelcomeTutorial] + */ + val hasSeenWelcomeTutorialFlow: Flow + + /** + * The current setting for if crash logging is enabled. + */ + var isCrashLoggingEnabled: Boolean? + + /** + * The current setting for if crash logging is enabled. + */ + val isCrashLoggingEnabledFlow: Flow + + /** + * Whether or not the user has previously dismissed the download Bitwarden action card. + */ + var hasUserDismissedDownloadBitwardenCard: Boolean? + + /** + * Whether or not the user has previously dismissed the sync with Bitwarden action card. + */ + var hasUserDismissedSyncWithBitwardenCard: Boolean? + + /** + * Stores the threshold at which users are alerted that an items validity period is nearing + * expiration. + */ + fun storeAlertThresholdSeconds(thresholdSeconds: Int) + + /** + * Gets the threshold at which users are alerted that an items validity period is nearing + * expiration. + */ + fun getAlertThresholdSeconds(): Int + + /** + * Emits updates that track the threshold at which users are alerted that an items validity + * period is nearing expiration. + */ + fun getAlertThresholdSecondsFlow(): Flow + + /** + * Retrieves the biometric integrity validity for the given [systemBioIntegrityState]. + */ + fun getAccountBiometricIntegrityValidity( + systemBioIntegrityState: String, + ): Boolean? + + /** + * Stores the biometric integrity validity for the given [systemBioIntegrityState]. + */ + fun storeAccountBiometricIntegrityValidity( + systemBioIntegrityState: String, + value: Boolean?, + ) + + /** + * Gets whether or not the user has enabled screen capture. + */ + fun getScreenCaptureAllowed(): Boolean? + + /** + * Emits updates that track [getScreenCaptureAllowed]. + */ + fun getScreenCaptureAllowedFlow(): Flow + + /** + * Stores whether or not [isScreenCaptureAllowed]. + */ + fun storeScreenCaptureAllowed(isScreenCaptureAllowed: Boolean?) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt new file mode 100644 index 0000000000..ab6ecb4309 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -0,0 +1,200 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import android.content.SharedPreferences +import com.bitwarden.authenticator.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onSubscription + +private const val APP_THEME_KEY = "$BASE_KEY:theme" +private const val APP_LANGUAGE_KEY = "$BASE_KEY:appLocale" +private const val DEFAULT_SAVE_OPTION_KEY = "$BASE_KEY:defaultSaveOption" +private const val SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY = "$BASE_KEY:biometricIntegritySource" +private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "$BASE_KEY:accountBiometricIntegrityValid" +private const val ALERT_THRESHOLD_SECONDS_KEY = "$BASE_KEY:alertThresholdSeconds" +private const val FIRST_LAUNCH_KEY = "$BASE_KEY:hasSeenWelcomeTutorial" +private const val CRASH_LOGGING_ENABLED_KEY = "$BASE_KEY:crashLoggingEnabled" +private const val SCREEN_CAPTURE_ALLOW_KEY = "screenCaptureAllowed" +private const val HAS_USER_DISMISSED_DOWNLOAD_BITWARDEN_KEY = + "$BASE_KEY:hasUserDismissedDownloadBitwardenCard" +private const val HAS_USER_DISMISSED_SYNC_WITH_BITWARDEN_KEY = + "$BASE_KEY:hasUserDismissedSyncWithBitwardenCard" +private const val PREVIOUSLY_SYNCED_BITWARDEN_ACCOUNT_IDS_KEY = + "$BASE_KEY:previouslySyncedBitwardenAccountIds" +private const val DEFAULT_ALERT_THRESHOLD_SECONDS = 7 + +/** + * Primary implementation of [SettingsDiskSource]. + */ +class SettingsDiskSourceImpl( + sharedPreferences: SharedPreferences, +) : BaseDiskSource(sharedPreferences = sharedPreferences), + SettingsDiskSource { + private val mutableAppThemeFlow = + bufferedMutableSharedFlow(replay = 1) + + private val mutableScreenCaptureAllowedFlow = + bufferedMutableSharedFlow() + + private val mutableAlertThresholdSecondsFlow = + bufferedMutableSharedFlow() + + private val mutableIsCrashLoggingEnabledFlow = + bufferedMutableSharedFlow() + + private val mutableDefaultSaveOptionFlow = + bufferedMutableSharedFlow() + + override var appLanguage: AppLanguage? + get() = getString(key = APP_LANGUAGE_KEY) + ?.let { storedValue -> + AppLanguage.entries.firstOrNull { storedValue == it.localeName } + } + set(value) { + putString( + key = APP_LANGUAGE_KEY, + value = value?.localeName, + ) + } + + private val mutableFirstLaunchFlow = + bufferedMutableSharedFlow() + + override var appTheme: AppTheme + get() = getString(key = APP_THEME_KEY) + ?.let { storedValue -> + AppTheme.entries.firstOrNull { storedValue == it.value } + } + ?: AppTheme.DEFAULT + set(newValue) { + putString( + key = APP_THEME_KEY, + value = newValue.value, + ) + mutableAppThemeFlow.tryEmit(appTheme) + } + + override val appThemeFlow: Flow + get() = mutableAppThemeFlow + .onSubscription { emit(appTheme) } + + override var defaultSaveOption: DefaultSaveOption + get() = getString(key = DEFAULT_SAVE_OPTION_KEY) + ?.let { storedValue -> + DefaultSaveOption.entries.firstOrNull { storedValue == it.value } + } + ?: DefaultSaveOption.NONE + set(newValue) { + putString( + key = DEFAULT_SAVE_OPTION_KEY, + value = newValue.value, + ) + mutableDefaultSaveOptionFlow.tryEmit(newValue) + } + override val defaultSaveOptionFlow: Flow + get() = mutableDefaultSaveOptionFlow + .onSubscription { emit(defaultSaveOption) } + + override var systemBiometricIntegritySource: String? + get() = getString(key = SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY) + set(value) { + putString(key = SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY, value = value) + } + + override var hasSeenWelcomeTutorial: Boolean + get() = getBoolean(key = FIRST_LAUNCH_KEY) ?: false + set(value) { + putBoolean(key = FIRST_LAUNCH_KEY, value) + mutableFirstLaunchFlow.tryEmit(hasSeenWelcomeTutorial) + } + + override var previouslySyncedBitwardenAccountIds: Set + get() = getStringSet( + key = PREVIOUSLY_SYNCED_BITWARDEN_ACCOUNT_IDS_KEY, + default = emptySet(), + ) ?: emptySet() + set(value) { + putStringSet( + key = PREVIOUSLY_SYNCED_BITWARDEN_ACCOUNT_IDS_KEY, + value = value, + ) + } + + override val hasSeenWelcomeTutorialFlow: Flow + get() = mutableFirstLaunchFlow.onSubscription { emit(hasSeenWelcomeTutorial) } + + override var isCrashLoggingEnabled: Boolean? + get() = getBoolean(key = CRASH_LOGGING_ENABLED_KEY) + set(value) { + putBoolean(key = CRASH_LOGGING_ENABLED_KEY, value = value) + mutableIsCrashLoggingEnabledFlow.tryEmit(value) + } + + override val isCrashLoggingEnabledFlow: Flow + get() = mutableIsCrashLoggingEnabledFlow + .onSubscription { emit(getBoolean(CRASH_LOGGING_ENABLED_KEY)) } + + override var hasUserDismissedDownloadBitwardenCard: Boolean? + get() = getBoolean(HAS_USER_DISMISSED_DOWNLOAD_BITWARDEN_KEY, null) + set(value) { + putBoolean(HAS_USER_DISMISSED_DOWNLOAD_BITWARDEN_KEY, value) + } + + override var hasUserDismissedSyncWithBitwardenCard: Boolean? + get() = getBoolean(HAS_USER_DISMISSED_SYNC_WITH_BITWARDEN_KEY, null) + set(value) { + putBoolean(HAS_USER_DISMISSED_SYNC_WITH_BITWARDEN_KEY, value) + } + + override fun storeAlertThresholdSeconds(thresholdSeconds: Int) { + putInt( + ALERT_THRESHOLD_SECONDS_KEY, + thresholdSeconds, + ) + mutableAlertThresholdSecondsFlow.tryEmit(thresholdSeconds) + } + + override fun getAlertThresholdSeconds() = + getInt(ALERT_THRESHOLD_SECONDS_KEY, default = DEFAULT_ALERT_THRESHOLD_SECONDS) + ?: DEFAULT_ALERT_THRESHOLD_SECONDS + + override fun getAlertThresholdSecondsFlow(): Flow = mutableAlertThresholdSecondsFlow + .onSubscription { emit(getAlertThresholdSeconds()) } + + override fun getAccountBiometricIntegrityValidity( + systemBioIntegrityState: String, + ): Boolean? = + getBoolean( + key = "${ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY}_$systemBioIntegrityState", + ) + + override fun storeAccountBiometricIntegrityValidity( + systemBioIntegrityState: String, + value: Boolean?, + ) { + putBoolean( + key = "${ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY}_$systemBioIntegrityState", + value = value, + ) + } + + override fun getScreenCaptureAllowed(): Boolean? { + return getBoolean(key = SCREEN_CAPTURE_ALLOW_KEY) + } + + override fun getScreenCaptureAllowedFlow(): Flow = mutableScreenCaptureAllowedFlow + .onSubscription { emit(getScreenCaptureAllowed()) } + + override fun storeScreenCaptureAllowed( + isScreenCaptureAllowed: Boolean?, + ) { + putBoolean( + key = SCREEN_CAPTURE_ALLOW_KEY, + value = isScreenCaptureAllowed, + ) + mutableScreenCaptureAllowedFlow.tryEmit(isScreenCaptureAllowed) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/di/PlatformDiskModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/di/PlatformDiskModule.kt new file mode 100644 index 0000000000..702060b96c --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/di/PlatformDiskModule.kt @@ -0,0 +1,63 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk.di + +import android.content.SharedPreferences +import com.bitwarden.authenticator.data.platform.datasource.di.UnencryptedPreferences +import com.bitwarden.authenticator.data.platform.datasource.disk.ConfigDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.ConfigDiskSourceImpl +import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagDiskSourceImpl +import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagOverrideDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagOverrideDiskSourceImpl +import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSourceImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +/** + * Provides persistence-related dependencies in the platform package. + */ +object PlatformDiskModule { + + @Provides + @Singleton + fun provideConfigDiskSource( + @UnencryptedPreferences sharedPreferences: SharedPreferences, + json: Json, + ): ConfigDiskSource = + ConfigDiskSourceImpl( + sharedPreferences = sharedPreferences, + json = json, + ) + + @Provides + @Singleton + fun provideSettingsDiskSource( + @UnencryptedPreferences sharedPreferences: SharedPreferences, + ): SettingsDiskSource = + SettingsDiskSourceImpl(sharedPreferences = sharedPreferences) + + @Provides + @Singleton + fun provideFeatureFlagDiskSource( + @UnencryptedPreferences sharedPreferences: SharedPreferences, + json: Json, + ): FeatureFlagDiskSource = + FeatureFlagDiskSourceImpl( + sharedPreferences = sharedPreferences, + json = json, + ) + + @Provides + @Singleton + fun provideFeatureFlagOverrideDiskSource( + @UnencryptedPreferences sharedPreferences: SharedPreferences, + ): FeatureFlagOverrideDiskSource = FeatureFlagOverrideDiskSourceImpl( + sharedPreferences = sharedPreferences, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/model/FeatureFlagsConfiguration.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/model/FeatureFlagsConfiguration.kt new file mode 100644 index 0000000000..e4f87de408 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/model/FeatureFlagsConfiguration.kt @@ -0,0 +1,14 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonPrimitive + +/** + * Models the state of feature flags. + */ +@Serializable +data class FeatureFlagsConfiguration( + @SerialName("featureFlags") + val featureFlags: Map, +) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/model/ServerConfig.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/model/ServerConfig.kt new file mode 100644 index 0000000000..7e6e2de1a7 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/model/ServerConfig.kt @@ -0,0 +1,22 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk.model + +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * A higher-level wrapper around [ConfigResponseJson] that provides a timestamp + * to check if a sync is necessary + * + * @property lastSync The [Long] of the last sync. + * @property serverData The raw [ConfigResponseJson] that contains specific data of the + * server configuration + */ +@Serializable +data class ServerConfig( + @SerialName("lastSync") + val lastSync: Long, + + @SerialName("serverData") + val serverData: ConfigResponseJson, +) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/api/ConfigApi.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/api/ConfigApi.kt new file mode 100644 index 0000000000..f3e3d2aed2 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/api/ConfigApi.kt @@ -0,0 +1,13 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.api + +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson +import retrofit2.http.GET + +/** + * This interface defines the API service for fetching configuration data. + */ +interface ConfigApi { + + @GET("config") + suspend fun getConfig(): Result +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/core/ResultCall.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/core/ResultCall.kt new file mode 100644 index 0000000000..07609e356e --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/core/ResultCall.kt @@ -0,0 +1,98 @@ +@file:OmitFromCoverage + +package com.bitwarden.authenticator.data.platform.datasource.network.core + +import com.bitwarden.authenticator.data.platform.annotation.OmitFromCoverage +import com.bitwarden.authenticator.data.platform.util.asFailure +import com.bitwarden.authenticator.data.platform.util.asSuccess +import okhttp3.Request +import okio.IOException +import okio.Timeout +import retrofit2.Call +import retrofit2.Callback +import retrofit2.HttpException +import retrofit2.Response +import java.lang.reflect.Type + +/** + * The integer code value for a "No Content" response. + */ +private const val NO_CONTENT_RESPONSE_CODE: Int = 204 + +/** + * A [Call] for wrapping a network request into a [Result]. + */ +@Suppress("TooManyFunctions") +class ResultCall( + private val backingCall: Call, + private val successType: Type, +) : Call> { + override fun cancel(): Unit = backingCall.cancel() + + override fun clone(): Call> = ResultCall(backingCall, successType) + + override fun enqueue(callback: Callback>): Unit = backingCall.enqueue( + object : Callback { + override fun onResponse(call: Call, response: Response) { + callback.onResponse(this@ResultCall, Response.success(response.toResult())) + } + + override fun onFailure(call: Call, t: Throwable) { + callback.onResponse(this@ResultCall, Response.success(t.toFailure())) + } + }, + ) + + @Suppress("TooGenericExceptionCaught") + override fun execute(): Response> = + try { + Response.success( + backingCall + .execute() + .toResult(), + ) + } catch (ioException: IOException) { + Response.success(ioException.toFailure()) + } catch (runtimeException: RuntimeException) { + Response.success(runtimeException.toFailure()) + } + + override fun isCanceled(): Boolean = backingCall.isCanceled + + override fun isExecuted(): Boolean = backingCall.isExecuted + + override fun request(): Request = backingCall.request() + + override fun timeout(): Timeout = backingCall.timeout() + + /** + * Synchronously send the request and return its response as a [Result]. + */ + fun executeForResult(): Result = requireNotNull(execute().body()) + + private fun Throwable.toFailure(): Result = + this + .also { + // We rebuild the URL without query params, we do not want to log those + val url = backingCall.request().url.toUrl().run { "$protocol://$authority$path" } + } + .asFailure() + + private fun Response.toResult(): Result = + if (!this.isSuccessful) { + HttpException(this).toFailure() + } else { + val body = this.body() + @Suppress("UNCHECKED_CAST") + when { + // We got a nonnull T as the body, just return it. + body != null -> body.asSuccess() + // We expected the body to be null since the successType is Unit, just return Unit. + successType == Unit::class.java -> (Unit as T).asSuccess() + // We allow null for 204's, just return null. + this.code() == NO_CONTENT_RESPONSE_CODE -> (null as T).asSuccess() + // All other null bodies result in an error. + else -> IllegalStateException("Unexpected null body!").toFailure() + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/core/ResultCallAdapter.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/core/ResultCallAdapter.kt new file mode 100644 index 0000000000..801c68a857 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/core/ResultCallAdapter.kt @@ -0,0 +1,16 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.core + +import retrofit2.Call +import retrofit2.CallAdapter +import java.lang.reflect.Type + +/** + * A [CallAdapter] for wrapping network requests into [kotlin.Result]. + */ +class ResultCallAdapter( + private val successType: Type, +) : CallAdapter>> { + + override fun responseType(): Type = successType + override fun adapt(call: Call): Call> = ResultCall(call, successType) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/core/ResultCallAdapterFactory.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/core/ResultCallAdapterFactory.kt new file mode 100644 index 0000000000..5ef2a41a56 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/core/ResultCallAdapterFactory.kt @@ -0,0 +1,32 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.core + +import retrofit2.Call +import retrofit2.CallAdapter +import retrofit2.Retrofit +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +/** + * A [CallAdapter.Factory] for wrapping network requests into [kotlin.Result]. + */ +class ResultCallAdapterFactory : CallAdapter.Factory() { + override fun get( + returnType: Type, + annotations: Array, + retrofit: Retrofit, + ): CallAdapter<*, *>? { + check(returnType is ParameterizedType) { "$returnType must be parameterized" } + val containerType = getParameterUpperBound(0, returnType) + + if (getRawType(containerType) != Result::class.java) return null + check(containerType is ParameterizedType) { "$containerType must be parameterized" } + + val requestType = getParameterUpperBound(0, containerType) + + return if (getRawType(returnType) == Call::class.java) { + ResultCallAdapter(successType = requestType) + } else { + null + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt new file mode 100644 index 0000000000..e4404e9eec --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt @@ -0,0 +1,68 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.di + +import com.bitwarden.authenticator.data.platform.datasource.network.interceptor.BaseUrlInterceptors +import com.bitwarden.authenticator.data.platform.datasource.network.interceptor.HeadersInterceptor +import com.bitwarden.authenticator.data.platform.datasource.network.retrofit.Retrofits +import com.bitwarden.authenticator.data.platform.datasource.network.retrofit.RetrofitsImpl +import com.bitwarden.authenticator.data.platform.datasource.network.serializer.ZonedDateTimeSerializer +import com.bitwarden.authenticator.data.platform.datasource.network.service.ConfigService +import com.bitwarden.authenticator.data.platform.datasource.network.service.ConfigServiceImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual +import retrofit2.create +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +/** + * This class provides network-related functionality for the application. + * It initializes and configures the networking components. + */ +object PlatformNetworkModule { + @Provides + @Singleton + fun providesConfigService( + retrofits: Retrofits, + ): ConfigService = ConfigServiceImpl(retrofits.unauthenticatedApiRetrofit.create()) + + @Provides + @Singleton + fun providesHeadersInterceptor(): HeadersInterceptor = HeadersInterceptor() + + @Provides + @Singleton + fun provideRetrofits( + baseUrlInterceptors: BaseUrlInterceptors, + headersInterceptor: HeadersInterceptor, + json: Json, + ): Retrofits = + RetrofitsImpl( + baseUrlInterceptors = baseUrlInterceptors, + headersInterceptor = headersInterceptor, + json = json, + ) + + @Provides + @Singleton + fun providesJson(): Json = Json { + + // If there are keys returned by the server not modeled by a serializable class, + // ignore them. + // This makes additive server changes non-breaking. + ignoreUnknownKeys = true + + // We allow for nullable values to have keys missing in the JSON response. + explicitNulls = false + serializersModule = SerializersModule { + contextual(ZonedDateTimeSerializer()) + } + + // Respect model default property values. + coerceInputValues = true + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/BaseUrlInterceptor.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/BaseUrlInterceptor.kt new file mode 100644 index 0000000000..9b368ed793 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/BaseUrlInterceptor.kt @@ -0,0 +1,58 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.interceptor + +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Interceptor +import okhttp3.Response + +/** + * A [Interceptor] that optionally takes the current base URL of a request and replaces it with + * the currently set [baseUrl] + */ +class BaseUrlInterceptor : Interceptor { + + /** + * The base URL to use as an override, or `null` if no override should be performed. + */ + var baseUrl: String? = null + set(value) { + field = value + baseHttpUrl = baseUrl?.let { requireNotNull(it.toHttpUrlOrNull()) } + } + + private var baseHttpUrl: HttpUrl? = null + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + // If no base URL is set, we can simply skip + val base = baseHttpUrl ?: return chain.proceed(request) + + // Update the base URL used. + return chain.proceed( + request + .newBuilder() + .url( + request + .url + .replaceBaseUrlWith(base), + ) + .build(), + ) + } +} + +/** + * Given a [HttpUrl], replaces the existing base URL with the given [baseUrl]. + */ +private fun HttpUrl.replaceBaseUrlWith( + baseUrl: HttpUrl, +) = baseUrl + .newBuilder() + .addEncodedPathSegments( + this + .encodedPathSegments + .joinToString(separator = "/"), + ) + .encodedQuery(this.encodedQuery) + .build() diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/BaseUrlInterceptors.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/BaseUrlInterceptors.kt new file mode 100644 index 0000000000..30d0156cbe --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/BaseUrlInterceptors.kt @@ -0,0 +1,33 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.interceptor + +import com.bitwarden.authenticator.data.platform.repository.model.Environment +import com.bitwarden.authenticator.data.platform.repository.util.baseApiUrl +import javax.inject.Inject +import javax.inject.Singleton + +/** + * An overall container for various [BaseUrlInterceptor] implementations for different API groups. + */ +@Singleton +class BaseUrlInterceptors @Inject constructor() { + var environment: Environment = Environment.Us + set(value) { + field = value + updateBaseUrls(environment = value) + } + + /** + * An interceptor for "/api" calls. + */ + val apiInterceptor: BaseUrlInterceptor = BaseUrlInterceptor() + + init { + // Ensure all interceptors begin with a default value + environment = Environment.Us + } + + private fun updateBaseUrls(environment: Environment) { + val environmentUrlData = environment.environmentUrlData + apiInterceptor.baseUrl = environmentUrlData.baseApiUrl + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/HeadersInterceptor.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/HeadersInterceptor.kt new file mode 100644 index 0000000000..dd7640ed82 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/HeadersInterceptor.kt @@ -0,0 +1,27 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.interceptor + +import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_KEY_CLIENT_NAME +import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_KEY_CLIENT_VERSION +import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_KEY_DEVICE_TYPE +import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_KEY_USER_AGENT +import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_VALUE_CLIENT_NAME +import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_VALUE_CLIENT_VERSION +import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_VALUE_DEVICE_TYPE +import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_VALUE_USER_AGENT +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Interceptor responsible for adding various headers to all API requests. + */ +class HeadersInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response = chain.proceed( + chain.request() + .newBuilder() + .header(HEADER_KEY_USER_AGENT, HEADER_VALUE_USER_AGENT) + .header(HEADER_KEY_CLIENT_NAME, HEADER_VALUE_CLIENT_NAME) + .header(HEADER_KEY_CLIENT_VERSION, HEADER_VALUE_CLIENT_VERSION) + .header(HEADER_KEY_DEVICE_TYPE, HEADER_VALUE_DEVICE_TYPE) + .build(), + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/model/ConfigResponseJson.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/model/ConfigResponseJson.kt new file mode 100644 index 0000000000..a5c493ef37 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/model/ConfigResponseJson.kt @@ -0,0 +1,82 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonPrimitive + +/** + * Represents the response model for configuration data fetched from the server. + * + * @property type The object type, typically "config". + * @property version The version of the configuration data. + * @property gitHash The Git hash associated with the configuration data. + * @property server The server information (nullable). + * @property environment The environment information containing URLs (vault, api, identity, etc.). + * @property featureStates A map containing various feature states. + */ +@Serializable +data class ConfigResponseJson( + @SerialName("object") + val type: String?, + + @SerialName("version") + val version: String?, + + @SerialName("gitHash") + val gitHash: String?, + + @SerialName("server") + val server: ServerJson?, + + @SerialName("environment") + val environment: EnvironmentJson?, + + @SerialName("featureStates") + val featureStates: Map?, +) { + /** + * Represents a server in the configuration response. + * + * @param name The name of the server. + * @param url The URL of the server. + */ + @Serializable + data class ServerJson( + @SerialName("name") + val name: String?, + + @SerialName("url") + val url: String?, + ) + + /** + * Represents the environment details in the configuration response. + * + * @param cloudRegion The cloud region associated with the environment. + * @param vaultUrl The URL of the vault service in the environment. + * @param apiUrl The URL of the API service in the environment. + * @param identityUrl The URL of the identity service in the environment. + * @param notificationsUrl The URL of the notifications service in the environment. + * @param ssoUrl The URL of the single sign-on (SSO) service in the environment. + */ + @Serializable + data class EnvironmentJson( + @SerialName("cloudRegion") + val cloudRegion: String?, + + @SerialName("vault") + val vaultUrl: String?, + + @SerialName("api") + val apiUrl: String?, + + @SerialName("identity") + val identityUrl: String?, + + @SerialName("notifications") + val notificationsUrl: String?, + + @SerialName("sso") + val ssoUrl: String?, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/retrofit/Retrofits.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/retrofit/Retrofits.kt new file mode 100644 index 0000000000..a9685ea885 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/retrofit/Retrofits.kt @@ -0,0 +1,30 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.retrofit + +import com.bitwarden.authenticator.data.platform.datasource.network.interceptor.BaseUrlInterceptors +import retrofit2.Retrofit +import retrofit2.http.Url + +/** + * A collection of various [Retrofit] instances that serve different purposes. + */ +interface Retrofits { + + /** + * Allows access to "/api" calls that do not require authentication. + * + * The base URL can be dynamically determined via the [BaseUrlInterceptors]. + */ + val unauthenticatedApiRetrofit: Retrofit + + /** + * Allows access to static API calls (ex: external APIs). + * + * @param isAuthenticated Indicates if the [Retrofit] instance should use authentication. + * @param baseUrl The static base url associated with this retrofit instance. This can be + * overridden with the [Url] annotation. + */ + fun createStaticRetrofit( + isAuthenticated: Boolean = false, + baseUrl: String = "https://api.bitwarden.com", + ): Retrofit +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/retrofit/RetrofitsImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/retrofit/RetrofitsImpl.kt new file mode 100644 index 0000000000..8dff34effc --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/retrofit/RetrofitsImpl.kt @@ -0,0 +1,81 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.retrofit + +import com.bitwarden.authenticator.data.platform.datasource.network.core.ResultCallAdapterFactory +import com.bitwarden.authenticator.data.platform.datasource.network.interceptor.BaseUrlInterceptor +import com.bitwarden.authenticator.data.platform.datasource.network.interceptor.BaseUrlInterceptors +import com.bitwarden.authenticator.data.platform.datasource.network.interceptor.HeadersInterceptor +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory + +/** + * Primary implementation of [Retrofits]. + */ +class RetrofitsImpl( + baseUrlInterceptors: BaseUrlInterceptors, + headersInterceptor: HeadersInterceptor, + json: Json, +) : Retrofits { + + //region Unauthenticated Retrofits + + override val unauthenticatedApiRetrofit: Retrofit by lazy { + createUnauthenticatedRetrofit( + baseUrlInterceptor = baseUrlInterceptors.apiInterceptor, + ) + } + + //endregion Unauthenticated Retrofits + + //region Static Retrofit + + override fun createStaticRetrofit(isAuthenticated: Boolean, baseUrl: String): Retrofit { + return baseRetrofitBuilder + .baseUrl(baseUrl) + .client( + baseOkHttpClient + .newBuilder() + .build(), + ) + .build() + } + + //endregion Static Retrofit + + //region Helper properties and functions + + private val baseOkHttpClient: OkHttpClient = + OkHttpClient.Builder() + .addInterceptor(headersInterceptor) + .build() + + private val baseRetrofit: Retrofit by lazy { + baseRetrofitBuilder + .baseUrl("https://api.bitwarden.com") + .build() + } + + private val baseRetrofitBuilder: Retrofit.Builder by lazy { + Retrofit.Builder() + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .addCallAdapterFactory(ResultCallAdapterFactory()) + .client(baseOkHttpClient) + } + + private fun createUnauthenticatedRetrofit( + baseUrlInterceptor: BaseUrlInterceptor, + ): Retrofit = + baseRetrofit + .newBuilder() + .client( + baseOkHttpClient + .newBuilder() + .addInterceptor(baseUrlInterceptor) + .build(), + ) + .build() + + //endregion Helper properties and functions +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/serializer/ZonedDateTimeSerializer.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/serializer/ZonedDateTimeSerializer.kt new file mode 100644 index 0000000000..384177b991 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/serializer/ZonedDateTimeSerializer.kt @@ -0,0 +1,33 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.serializer + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +/** + * Used to serialize and deserialize [ZonedDateTime]. + */ +class ZonedDateTimeSerializer : KSerializer { + private val dateTimeFormatterDeserialization = DateTimeFormatter + .ofPattern("yyyy-MM-dd'T'HH:mm:ss[.][:][SSSSSSS][SSSSSS][SSSSS][SSSS][SSS][SS][S]X") + + private val dateTimeFormatterSerialization = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX") + + override val descriptor: SerialDescriptor + get() = PrimitiveSerialDescriptor(serialName = "ZonedDateTime", kind = PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): ZonedDateTime = + decoder.decodeString().let { dateString -> + ZonedDateTime.parse(dateString, dateTimeFormatterDeserialization) + } + + override fun serialize(encoder: Encoder, value: ZonedDateTime) { + encoder.encodeString(dateTimeFormatterSerialization.format(value)) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigService.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigService.kt new file mode 100644 index 0000000000..4417910b71 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigService.kt @@ -0,0 +1,14 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.service + +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson + +/** + * Provides an API for querying for app configurations. + */ +interface ConfigService { + + /** + * Fetch app configuration. + */ + suspend fun getConfig(): Result +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigServiceImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigServiceImpl.kt new file mode 100644 index 0000000000..47ec85a70e --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigServiceImpl.kt @@ -0,0 +1,11 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.service + +import com.bitwarden.authenticator.data.platform.datasource.network.api.ConfigApi +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson + +/** + * Default implementation of [ConfigService] for querying for app configurations. + */ +class ConfigServiceImpl(private val configApi: ConfigApi) : ConfigService { + override suspend fun getConfig(): Result = configApi.getConfig() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/util/HeaderUtils.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/util/HeaderUtils.kt new file mode 100644 index 0000000000..9b44cdf68d --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/util/HeaderUtils.kt @@ -0,0 +1,56 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.util + +import android.os.Build +import com.bitwarden.authenticator.BuildConfig + +/** + * The bearer prefix used for the 'authorization' headers value. + */ +const val HEADER_VALUE_BEARER_PREFIX: String = "Bearer " + +/** + * The key used for the 'authorization' headers. + */ +const val HEADER_KEY_AUTHORIZATION: String = "Authorization" + +/** + * The key used for the 'user-agent' headers. + */ +const val HEADER_KEY_USER_AGENT: String = "User-Agent" + +/** + * The value used for the 'user-agent' headers. + */ +@Suppress("MaxLineLength") +val HEADER_VALUE_USER_AGENT: String = + "Bitwarden_Mobile/${BuildConfig.VERSION_NAME} (${BuildConfig.BUILD_TYPE}) (Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT}; Model ${Build.MODEL})" + +/** + * The key used for the 'bitwarden-client-name' headers. + */ +const val HEADER_KEY_CLIENT_NAME: String = "Bitwarden-Client-Name" + +/** + * The value used for the 'bitwarden-client-name' headers. + */ +const val HEADER_VALUE_CLIENT_NAME: String = "mobile" + +/** + * The key used for the 'bitwarden-client-version' headers. + */ +const val HEADER_KEY_CLIENT_VERSION: String = "Bitwarden-Client-Version" + +/** + * The value used for the 'bitwarden-client-version' headers. + */ +const val HEADER_VALUE_CLIENT_VERSION: String = BuildConfig.VERSION_NAME + +/** + * The key used for the 'device-type' headers. + */ +const val HEADER_KEY_DEVICE_TYPE: String = "Device-Type" + +/** + * The value used for the 'device-type' headers. + */ +const val HEADER_VALUE_DEVICE_TYPE: String = "0" diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BiometricsEncryptionManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BiometricsEncryptionManager.kt new file mode 100644 index 0000000000..492c2297b1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BiometricsEncryptionManager.kt @@ -0,0 +1,19 @@ +package com.bitwarden.authenticator.data.platform.manager + +/** + * Responsible for managing Android keystore encryption and decryption. + */ +interface BiometricsEncryptionManager { + /** + * Sets up biometrics to ensure future integrity checks work properly. If this method has never + * been called [isBiometricIntegrityValid] will return false. + */ + fun setupBiometrics() + + /** + * Checks to verify that the biometrics integrity is still valid. This returns `true` if the + * biometrics data has not change since the app setup biometrics, `false` will be returned if + * it has changed. + */ + fun isBiometricIntegrityValid(): Boolean +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BiometricsEncryptionManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BiometricsEncryptionManagerImpl.kt new file mode 100644 index 0000000000..eca5f498a1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BiometricsEncryptionManagerImpl.kt @@ -0,0 +1,107 @@ +package com.bitwarden.authenticator.data.platform.manager + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import com.bitwarden.authenticator.BuildConfig +import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource +import java.security.InvalidKeyException +import java.security.KeyStore +import java.security.UnrecoverableKeyException +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.KeyGenerator + +/** + * Default implementation of [BiometricsEncryptionManager] for managing Android keystore encryption + * and decryption. + */ +class BiometricsEncryptionManagerImpl( + private val settingsDiskSource: SettingsDiskSource, +) : BiometricsEncryptionManager { + private val keystore = KeyStore + .getInstance(ENCRYPTION_KEYSTORE_NAME) + .also { it.load(null) } + + private val keyGenParameterSpec: KeyGenParameterSpec + get() = KeyGenParameterSpec + .Builder( + ENCRYPTION_KEY_NAME, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_CBC) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) + .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(true) + .build() + + override fun setupBiometrics() { + createIntegrityValues() + } + + override fun isBiometricIntegrityValid(): Boolean = + isSystemBiometricIntegrityValid() && isAccountBiometricIntegrityValid() + + private fun isAccountBiometricIntegrityValid(): Boolean { + val systemBioIntegrityState = settingsDiskSource + .systemBiometricIntegritySource + ?: return false + return settingsDiskSource + .getAccountBiometricIntegrityValidity( + systemBioIntegrityState = systemBioIntegrityState, + ) + ?: false + } + + private fun isSystemBiometricIntegrityValid(): Boolean = + try { + keystore.load(null) + keystore + .getKey(ENCRYPTION_KEY_NAME, null) + ?.let { Cipher.getInstance(CIPHER_TRANSFORMATION).init(Cipher.ENCRYPT_MODE, it) } + true + } catch (e: KeyPermanentlyInvalidatedException) { + // Biometric has changed + settingsDiskSource.systemBiometricIntegritySource = null + false + } catch (e: UnrecoverableKeyException) { + // Biometric was disabled and re-enabled + settingsDiskSource.systemBiometricIntegritySource = null + false + } catch (e: InvalidKeyException) { + // Fallback for old bitwarden users without a key + createIntegrityValues() + true + } + + @Suppress("TooGenericExceptionCaught") + private fun createIntegrityValues() { + val systemBiometricIntegritySource = settingsDiskSource + .systemBiometricIntegritySource + ?: UUID.randomUUID().toString() + settingsDiskSource.systemBiometricIntegritySource = systemBiometricIntegritySource + settingsDiskSource.storeAccountBiometricIntegrityValidity( + systemBioIntegrityState = systemBiometricIntegritySource, + value = true, + ) + + try { + val keyGen = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ENCRYPTION_KEYSTORE_NAME, + ) + keyGen.init(keyGenParameterSpec) + keyGen.generateKey() + } catch (e: Exception) { + // Catch silently to allow biometrics to function on devices that are in + // a state where key generation is not functioning + } + } +} + +private const val ENCRYPTION_KEYSTORE_NAME: String = "AndroidKeyStore" +private const val ENCRYPTION_KEY_NAME: String = "${BuildConfig.APPLICATION_ID}.biometric_integrity" +private const val CIPHER_TRANSFORMATION = + KeyProperties.KEY_ALGORITHM_AES + "/" + + KeyProperties.BLOCK_MODE_CBC + "/" + + KeyProperties.ENCRYPTION_PADDING_PKCS7 diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BitwardenEncodingManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BitwardenEncodingManager.kt new file mode 100644 index 0000000000..6d1fd320e9 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BitwardenEncodingManager.kt @@ -0,0 +1,22 @@ +package com.bitwarden.authenticator.data.platform.manager + +/** + * An interface for encoding and decoding data. + */ +interface BitwardenEncodingManager { + + /** + * Decodes '%'-escaped octets in the given string. + */ + fun uriDecode(value: String): String + + /** + * Decodes the specified [value], and returns the resulting [ByteArray]. + */ + fun base64Decode(value: String): ByteArray + + /** + * Encodes the specified [byteArray], and returns the encoded String. + */ + fun base32Encode(byteArray: ByteArray): String +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BitwardenEncodingManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BitwardenEncodingManagerImpl.kt new file mode 100644 index 0000000000..76bad481da --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BitwardenEncodingManagerImpl.kt @@ -0,0 +1,16 @@ +package com.bitwarden.authenticator.data.platform.manager + +import android.net.Uri +import com.google.common.io.BaseEncoding + +/** + * Default implementation of [BitwardenEncodingManager]. + */ +class BitwardenEncodingManagerImpl : BitwardenEncodingManager { + override fun uriDecode(value: String): String = Uri.decode(value) + + override fun base64Decode(value: String): ByteArray = BaseEncoding.base64().decode(value) + + override fun base32Encode(byteArray: ByteArray): String = + BaseEncoding.base32().encode(byteArray) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/CrashLogsManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/CrashLogsManager.kt new file mode 100644 index 0000000000..704b13eec1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/CrashLogsManager.kt @@ -0,0 +1,12 @@ +package com.bitwarden.authenticator.data.platform.manager + +/** + * Implementations of this interface provide a way to enable or disable the collection of crash + * logs, giving control over whether crash logs are generated and stored. + */ +interface CrashLogsManager { + /** + * Gets or sets whether the collection of crash logs is enabled. + */ + var isEnabled: Boolean +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/CrashLogsManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/CrashLogsManagerImpl.kt new file mode 100644 index 0000000000..e09dcea3d7 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/CrashLogsManagerImpl.kt @@ -0,0 +1,24 @@ +package com.bitwarden.authenticator.data.platform.manager + +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase + +/** + * CrashLogsManager implementation for standard flavor builds. + */ +class CrashLogsManagerImpl( + private val settingsRepository: SettingsRepository, +) : CrashLogsManager { + + override var isEnabled: Boolean + get() = settingsRepository.isCrashLoggingEnabled + set(value) { + settingsRepository.isCrashLoggingEnabled = value + Firebase.crashlytics.setCrashlyticsCollectionEnabled(value) + } + + init { + isEnabled = settingsRepository.isCrashLoggingEnabled + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/DebugMenuFeatureFlagManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/DebugMenuFeatureFlagManagerImpl.kt new file mode 100644 index 0000000000..f4a6ca483c --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/DebugMenuFeatureFlagManagerImpl.kt @@ -0,0 +1,37 @@ +package com.bitwarden.authenticator.data.platform.manager + +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * The [FeatureFlagManager] implementation for the debug menu. This manager uses the + * values returned from the [debugMenuRepository] if they are available. otherwise it will use + * the default [FeatureFlagManager]. + */ +class DebugMenuFeatureFlagManagerImpl( + private val defaultFeatureFlagManager: FeatureFlagManager, + private val debugMenuRepository: DebugMenuRepository, +) : FeatureFlagManager by defaultFeatureFlagManager { + + override fun getFeatureFlagFlow(key: FlagKey): Flow { + return debugMenuRepository.featureFlagOverridesUpdatedFlow.map { _ -> + debugMenuRepository + .getFeatureFlag(key) + ?: defaultFeatureFlagManager.getFeatureFlag(key = key) + } + } + + override suspend fun getFeatureFlag(key: FlagKey, forceRefresh: Boolean): T { + return debugMenuRepository + .getFeatureFlag(key) + ?: defaultFeatureFlagManager.getFeatureFlag(key = key, forceRefresh = forceRefresh) + } + + override fun getFeatureFlag(key: FlagKey): T { + return debugMenuRepository + .getFeatureFlag(key) + ?: defaultFeatureFlagManager.getFeatureFlag(key = key) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/DispatcherManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/DispatcherManager.kt new file mode 100644 index 0000000000..13306d075c --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/DispatcherManager.kt @@ -0,0 +1,29 @@ +package com.bitwarden.authenticator.data.platform.manager + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.MainCoroutineDispatcher + +/** + * An interface for accessing the [CoroutineDispatcher]s. + */ +interface DispatcherManager { + /** + * The default [CoroutineDispatcher] for the app. + */ + val default: CoroutineDispatcher + + /** + * The [MainCoroutineDispatcher] for the app. + */ + val main: MainCoroutineDispatcher + + /** + * The IO [CoroutineDispatcher] for the app. + */ + val io: CoroutineDispatcher + + /** + * The unconfined [CoroutineDispatcher] for the app. + */ + val unconfined: CoroutineDispatcher +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/DispatcherManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/DispatcherManagerImpl.kt new file mode 100644 index 0000000000..4cc80f4fe9 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/DispatcherManagerImpl.kt @@ -0,0 +1,18 @@ +package com.bitwarden.authenticator.data.platform.manager + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainCoroutineDispatcher + +/** + * Primary implementation of [DispatcherManager]. + */ +class DispatcherManagerImpl : DispatcherManager { + override val default: CoroutineDispatcher = Dispatchers.Default + + override val main: MainCoroutineDispatcher = Dispatchers.Main + + override val io: CoroutineDispatcher = Dispatchers.IO + + override val unconfined: CoroutineDispatcher = Dispatchers.Unconfined +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManager.kt new file mode 100644 index 0000000000..051fbf6a40 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManager.kt @@ -0,0 +1,34 @@ +package com.bitwarden.authenticator.data.platform.manager + +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import kotlinx.coroutines.flow.Flow + +/** + * Manages the available feature flags for the Bitwarden application. + */ +interface FeatureFlagManager { + + /** + * Returns a flow emitting the value of flag [key] which is of generic type [T]. + * If the value of the flag cannot be retrieved, the default value of [key] will be returned + */ + fun getFeatureFlagFlow(key: FlagKey): Flow + + /** + * Get value for feature flag with [key] and returns it as generic type [T]. + * If no value is found the given [key] its default value will be returned. + * Cached flags can be invalidated with [forceRefresh] + */ + suspend fun getFeatureFlag( + key: FlagKey, + forceRefresh: Boolean, + ): T + + /** + * Gets the value for feature flag with [key] and returns it as generic type [T]. + * If no value is found the given [key] its [FlagKey.defaultValue] will be returned. + */ + fun getFeatureFlag( + key: FlagKey, + ): T +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManagerImpl.kt new file mode 100644 index 0000000000..6fc865c131 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManagerImpl.kt @@ -0,0 +1,66 @@ +package com.bitwarden.authenticator.data.platform.manager + +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.data.platform.repository.ServerConfigRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Primary implementation of [FeatureFlagManager]. + */ +class FeatureFlagManagerImpl( + private val serverConfigRepository: ServerConfigRepository, +) : FeatureFlagManager { + + override fun getFeatureFlagFlow(key: FlagKey): Flow = + serverConfigRepository + .serverConfigStateFlow + .map { serverConfig -> + serverConfig.getFlagValueOrDefault(key = key) + } + + override suspend fun getFeatureFlag( + key: FlagKey, + forceRefresh: Boolean, + ): T = + serverConfigRepository + .getServerConfig(forceRefresh = forceRefresh) + .getFlagValueOrDefault(key = key) + + override fun getFeatureFlag(key: FlagKey): T = + serverConfigRepository + .serverConfigStateFlow + .value + .getFlagValueOrDefault(key = key) +} + +/** + * Extract the value of a [FlagKey] from the [ServerConfig]. If there is an issue with retrieving + * or if the value is null, the default value will be returned. + */ +fun ServerConfig?.getFlagValueOrDefault(key: FlagKey): T { + val defaultValue = key.defaultValue + if (!key.isRemotelyConfigured) return key.defaultValue + return this + ?.serverData + ?.featureStates + ?.get(key.keyName) + ?.let { + try { + // Suppressed since we are checking the type before doing the cast + @Suppress("UNCHECKED_CAST") + when (defaultValue::class) { + Boolean::class -> it.content.toBoolean() as T + String::class -> it.content as T + Int::class -> it.content.toInt() as T + else -> defaultValue + } + } catch (ex: ClassCastException) { + defaultValue + } catch (ex: NumberFormatException) { + defaultValue + } + } + ?: defaultValue +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/SdkClientManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/SdkClientManager.kt new file mode 100644 index 0000000000..ea706df722 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/SdkClientManager.kt @@ -0,0 +1,20 @@ +package com.bitwarden.authenticator.data.platform.manager + +import com.bitwarden.sdk.Client + +/** + * Manages the creation, caching, and destruction of SDK [Client] instances on a per-user basis. + */ +interface SdkClientManager { + + /** + * Returns the cached [Client] instance, otherwise creates and caches + * a new one and returns it. + */ + suspend fun getOrCreateClient(): Client + + /** + * Clears any resources from the [Client] and removes it from the internal cache. + */ + fun destroyClient() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/SdkClientManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/SdkClientManagerImpl.kt new file mode 100644 index 0000000000..348f357040 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/SdkClientManagerImpl.kt @@ -0,0 +1,18 @@ +package com.bitwarden.authenticator.data.platform.manager + +import com.bitwarden.sdk.Client + +/** + * Primary implementation of [SdkClientManager]. + */ +class SdkClientManagerImpl( + private val clientProvider: suspend () -> Client = { Client(null) }, +) : SdkClientManager { + private var client: Client? = null + + override suspend fun getOrCreateClient(): Client = client ?: clientProvider.invoke() + + override fun destroyClient() { + client = null + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/clipboard/BitwardenClipboardManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/clipboard/BitwardenClipboardManager.kt new file mode 100644 index 0000000000..06d63cf1ac --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/clipboard/BitwardenClipboardManager.kt @@ -0,0 +1,42 @@ +package com.bitwarden.authenticator.data.platform.manager.clipboard + +import androidx.compose.ui.text.AnnotatedString +import com.bitwarden.authenticator.ui.platform.base.util.Text + +/** + * Wrapper class for using the clipboard. + */ +interface BitwardenClipboardManager { + + /** + * Places the given [text] into the device's clipboard. Setting the data to [isSensitive] will + * obfuscate the displayed data on the default popup (true by default). A toast will be + * displayed on devices that do not have a default popup (pre-API 32) and will not be displayed + * on newer APIs. If a toast is displayed, it will be formatted as "[text] copied" or if a + * [toastDescriptorOverride] is provided, it will be formatted as + * "[toastDescriptorOverride] copied". + */ + fun setText( + text: AnnotatedString, + isSensitive: Boolean = true, + toastDescriptorOverride: String? = null, + ) + + /** + * See [setText] for more details. + */ + fun setText( + text: String, + isSensitive: Boolean = true, + toastDescriptorOverride: String? = null, + ) + + /** + * See [setText] for more details. + */ + fun setText( + text: Text, + isSensitive: Boolean = true, + toastDescriptorOverride: String? = null, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/clipboard/BitwardenClipboardManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/clipboard/BitwardenClipboardManagerImpl.kt new file mode 100644 index 0000000000..d7d2bbe8e6 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/clipboard/BitwardenClipboardManagerImpl.kt @@ -0,0 +1,56 @@ +package com.bitwarden.authenticator.data.platform.manager.clipboard + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.widget.Toast +import androidx.compose.ui.text.AnnotatedString +import androidx.core.content.getSystemService +import androidx.core.os.persistableBundleOf +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.toAnnotatedString + +/** + * Default implementation of the [BitwardenClipboardManager] interface. + */ +class BitwardenClipboardManagerImpl( + private val context: Context, +) : BitwardenClipboardManager { + private val clipboardManager: ClipboardManager = requireNotNull(context.getSystemService()) + + override fun setText( + text: AnnotatedString, + isSensitive: Boolean, + toastDescriptorOverride: String?, + ) { + clipboardManager.setPrimaryClip( + ClipData + .newPlainText("", text) + .apply { + description.extras = persistableBundleOf( + "android.content.extra.IS_SENSITIVE" to isSensitive, + ) + }, + ) + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + val descriptor = toastDescriptorOverride ?: text + Toast + .makeText( + context, + context.resources.getString(R.string.value_has_been_copied, descriptor), + Toast.LENGTH_SHORT, + ) + .show() + } + } + + override fun setText(text: String, isSensitive: Boolean, toastDescriptorOverride: String?) { + setText(text.toAnnotatedString(), isSensitive, toastDescriptorOverride) + } + + override fun setText(text: Text, isSensitive: Boolean, toastDescriptorOverride: String?) { + setText(text.toString(context.resources), isSensitive, toastDescriptorOverride) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/clipboard/ClearClipboardWorker.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/clipboard/ClearClipboardWorker.kt new file mode 100644 index 0000000000..7158036319 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/clipboard/ClearClipboardWorker.kt @@ -0,0 +1,22 @@ +package com.bitwarden.data.platform.manager.clipboard + +import android.content.ClipboardManager +import android.content.Context +import android.content.Context.CLIPBOARD_SERVICE +import androidx.work.Worker +import androidx.work.WorkerParameters + +/** + * A worker to clear the clipboard manager. + */ +class ClearClipboardWorker(appContext: Context, workerParams: WorkerParameters) : + Worker(appContext, workerParams) { + + private val clipboardManager = + appContext.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + + override fun doWork(): Result { + clipboardManager.clearPrimaryClip() + return Result.success() + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/di/PlatformManagerModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/di/PlatformManagerModule.kt new file mode 100644 index 0000000000..4b7f429104 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/di/PlatformManagerModule.kt @@ -0,0 +1,99 @@ +package com.bitwarden.authenticator.data.platform.manager.di + +import android.content.Context +import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManagerImpl +import com.bitwarden.authenticator.data.platform.manager.BitwardenEncodingManager +import com.bitwarden.authenticator.data.platform.manager.BitwardenEncodingManagerImpl +import com.bitwarden.authenticator.data.platform.manager.CrashLogsManager +import com.bitwarden.authenticator.data.platform.manager.CrashLogsManagerImpl +import com.bitwarden.authenticator.data.platform.manager.DebugMenuFeatureFlagManagerImpl +import com.bitwarden.authenticator.data.platform.manager.DispatcherManager +import com.bitwarden.authenticator.data.platform.manager.DispatcherManagerImpl +import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager +import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManagerImpl +import com.bitwarden.authenticator.data.platform.manager.SdkClientManager +import com.bitwarden.authenticator.data.platform.manager.SdkClientManagerImpl +import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager +import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManagerImpl +import com.bitwarden.authenticator.data.platform.manager.imports.ImportManager +import com.bitwarden.authenticator.data.platform.manager.imports.ImportManagerImpl +import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository +import com.bitwarden.authenticator.data.platform.repository.ServerConfigRepository +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.time.Clock +import javax.inject.Singleton + +/** + * Provides managers in the platform package. + */ +@Module +@InstallIn(SingletonComponent::class) +object PlatformManagerModule { + + @Provides + @Singleton + fun provideBitwardenClipboardManager( + @ApplicationContext context: Context, + ): BitwardenClipboardManager = BitwardenClipboardManagerImpl(context) + + @Provides + @Singleton + fun provideBitwardenDispatchers(): DispatcherManager = DispatcherManagerImpl() + + @Provides + @Singleton + fun provideSdkClientManager(): SdkClientManager = SdkClientManagerImpl() + + @Provides + @Singleton + fun provideClock(): Clock = Clock.systemDefaultZone() + + @Provides + @Singleton + fun provideBiometricsEncryptionManager( + settingsDiskSource: SettingsDiskSource, + ): BiometricsEncryptionManager = BiometricsEncryptionManagerImpl(settingsDiskSource) + + @Provides + @Singleton + fun provideCrashLogsManager(settingsRepository: SettingsRepository): CrashLogsManager = + CrashLogsManagerImpl( + settingsRepository = settingsRepository, + ) + + @Provides + @Singleton + fun provideImportManager( + authenticatorDiskSource: AuthenticatorDiskSource, + ): ImportManager = ImportManagerImpl(authenticatorDiskSource) + + @Provides + @Singleton + fun provideEncodingManager(): BitwardenEncodingManager = BitwardenEncodingManagerImpl() + + @Provides + @Singleton + fun providesFeatureFlagManager( + debugMenuRepository: DebugMenuRepository, + serverConfigRepository: ServerConfigRepository, + ): FeatureFlagManager = if (debugMenuRepository.isDebugMenuEnabled) { + DebugMenuFeatureFlagManagerImpl( + debugMenuRepository = debugMenuRepository, + defaultFeatureFlagManager = FeatureFlagManagerImpl( + serverConfigRepository = serverConfigRepository, + ), + ) + } else { + FeatureFlagManagerImpl( + serverConfigRepository = serverConfigRepository, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/ImportManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/ImportManager.kt new file mode 100644 index 0000000000..6a58e1b624 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/ImportManager.kt @@ -0,0 +1,18 @@ +package com.bitwarden.authenticator.data.platform.manager.imports + +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportDataResult +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat + +/** + * Responsible for managing import of files from various authenticator exports. + */ +interface ImportManager { + + /** + * Imports the selected file. + */ + suspend fun import( + importFileFormat: ImportFileFormat, + byteArray: ByteArray, + ): ImportDataResult +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/ImportManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/ImportManagerImpl.kt new file mode 100644 index 0000000000..634c0df3f1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/ImportManagerImpl.kt @@ -0,0 +1,53 @@ +package com.bitwarden.authenticator.data.platform.manager.imports + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource +import com.bitwarden.authenticator.data.platform.manager.imports.model.ExportParseResult +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportDataResult +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat +import com.bitwarden.authenticator.data.platform.manager.imports.parsers.AegisExportParser +import com.bitwarden.authenticator.data.platform.manager.imports.parsers.BitwardenExportParser +import com.bitwarden.authenticator.data.platform.manager.imports.parsers.ExportParser +import com.bitwarden.authenticator.data.platform.manager.imports.parsers.LastPassExportParser +import com.bitwarden.authenticator.data.platform.manager.imports.parsers.TwoFasExportParser + +/** + * Default implementation of [ImportManager] for managing importing files exported by various + * authenticator applications. + */ +class ImportManagerImpl( + private val authenticatorDiskSource: AuthenticatorDiskSource, +) : ImportManager { + override suspend fun import( + importFileFormat: ImportFileFormat, + byteArray: ByteArray, + ): ImportDataResult { + val parser = createParser(importFileFormat) + return processParseResult(parser.parseForResult(byteArray)) + } + + private fun createParser( + importFileFormat: ImportFileFormat, + ): ExportParser = when (importFileFormat) { + ImportFileFormat.BITWARDEN_JSON -> BitwardenExportParser(importFileFormat) + ImportFileFormat.TWO_FAS_JSON -> TwoFasExportParser() + ImportFileFormat.LAST_PASS_JSON -> LastPassExportParser() + ImportFileFormat.AEGIS -> AegisExportParser() + } + + private suspend fun processParseResult( + parseResult: ExportParseResult, + ): ImportDataResult = when (parseResult) { + is ExportParseResult.Error -> { + ImportDataResult.Error( + title = parseResult.title, + message = parseResult.message, + ) + } + + is ExportParseResult.Success -> { + val items = parseResult.items.toTypedArray() + authenticatorDiskSource.saveItem(*items) + ImportDataResult.Success + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/AegisJsonExport.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/AegisJsonExport.kt new file mode 100644 index 0000000000..fcaf15c04c --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/AegisJsonExport.kt @@ -0,0 +1,60 @@ +package com.bitwarden.authenticator.data.platform.manager.imports.model + +import kotlinx.serialization.Serializable + +/** + * Models the Aegis JSON export file. + */ +@Serializable +data class AegisJsonExport( + val version: Int, + val db: Database, +) { + + /** + * Models the Aegis database in JSON format. + */ + @Serializable + data class Database( + val version: Int, + val entries: List, + val groups: List, + ) { + + /** + * Models an Aegis database entry. + */ + @Serializable + data class Entry( + val type: String, + val uuid: String, + val name: String, + val issuer: String, + val note: String, + val favorite: Boolean, + val info: Info, + val groups: List, + ) { + + /** + * Models key information for an [Entry]. + */ + @Serializable + data class Info( + val secret: String, + val algo: String, + val digits: Int, + val period: Int, + ) + } + + /** + * Models a collection that can be associated with an [Entry]. + */ + @Serializable + data class Group( + val uuid: String, + val name: String, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/ExportParseResult.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/ExportParseResult.kt new file mode 100644 index 0000000000..70eef9a922 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/ExportParseResult.kt @@ -0,0 +1,27 @@ +package com.bitwarden.authenticator.data.platform.manager.imports.model + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.ui.platform.base.util.Text + +/** + * Represents the result of parsing an export file. + */ +sealed class ExportParseResult { + + /** + * Indicates the selected file has been successfully parsed. + */ + data class Success(val items: List) : ExportParseResult() + + /** + * Represents an error that occurred while parsing the selected file. + * Provides user-friendly messages to display to the user. + * + * @property title A user-friendly title summarizing the error. + * @property message A detailed message describing the error. + */ + data class Error( + val title: Text? = null, + val message: Text? = null, + ) : ExportParseResult() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/ImportDataResult.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/ImportDataResult.kt new file mode 100644 index 0000000000..0e73000064 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/ImportDataResult.kt @@ -0,0 +1,24 @@ +package com.bitwarden.authenticator.data.platform.manager.imports.model + +import com.bitwarden.authenticator.ui.platform.base.util.Text + +/** + * Represents the result of a data import operation. + */ +sealed class ImportDataResult { + /** + * Indicates import was successful. + */ + data object Success : ImportDataResult() + + /** + * Indicates import was not successful. + * + * @property title An optional [Text] providing a brief title of the reason the import failed. + * @property message An optional [Text] containing an explanation of why the import failed. + */ + data class Error( + val title: Text? = null, + val message: Text? = null, + ) : ImportDataResult() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/ImportFileFormat.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/ImportFileFormat.kt new file mode 100644 index 0000000000..d391e432f2 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/ImportFileFormat.kt @@ -0,0 +1,13 @@ +package com.bitwarden.authenticator.data.platform.manager.imports.model + +/** + * Represents the file formats a user can select to import their vault. + */ +enum class ImportFileFormat( + val mimeType: String, +) { + BITWARDEN_JSON("application/json"), + TWO_FAS_JSON("*/*"), + LAST_PASS_JSON("application/json"), + AEGIS("application/json"), +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/LastPassJsonExport.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/LastPassJsonExport.kt new file mode 100644 index 0000000000..0fc0afe3dd --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/LastPassJsonExport.kt @@ -0,0 +1,69 @@ +package com.bitwarden.authenticator.data.platform.manager.imports.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Models the JSON export file from LastPass. + */ +@Serializable +data class LastPassJsonExport( + val deviceId: String, + val deviceSecret: String, + val localDeviceId: String, + val deviceName: String, + val version: Int, + val accounts: List, + val folders: List, +) { + /** + * Models an account contained within a [LastPassJsonExport]. + */ + @Serializable + data class Account( + @SerialName("accountID") + val accountId: String, + val issuerName: String, + val originalIssuerName: String, + val userName: String, + val originalUserName: String, + val pushNotification: Boolean, + val secret: String, + val timeStep: Int, + val digits: Int, + val creationTimestamp: Long, + val isFavorite: Boolean, + val algorithm: String, + val folderData: FolderData?, + val backupInfo: BackupInfo?, + ) { + /** + * Models metadata for a [Folder]. + */ + @Serializable + data class FolderData( + val folderId: String, + val position: Int, + ) + + /** + * Models backup file information for an [Account]. + */ + @Serializable + data class BackupInfo( + val creationDate: String, + val deviceOs: String, + val appVersion: String, + ) + } + + /** + * Models a collection of [Account] objects. + */ + @Serializable + data class Folder( + val id: Int, + val name: String, + val isOpened: Boolean, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/TwoFasJsonExport.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/TwoFasJsonExport.kt new file mode 100644 index 0000000000..476bb26326 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/TwoFasJsonExport.kt @@ -0,0 +1,100 @@ +package com.bitwarden.authenticator.data.platform.manager.imports.model + +import kotlinx.serialization.Serializable + +/** + * Models the JSON export file from 2FAS. + */ +@Serializable +data class TwoFasJsonExport( + val schemaVersion: Int?, + val appVersionCode: Int?, + val appOrigin: String?, + val services: List, + val servicesEncrypted: String?, + val groups: List?, +) { + /** + * Models a service account contained within a [TwoFasJsonExport]. + */ + @Serializable + data class Service( + val otp: Otp, + val order: Order?, + val updatedAt: Long?, + val name: String?, + val icon: Icon?, + val secret: String, + val badge: Badge?, + val serviceTypeId: String?, + ) { + /** + * Models OTP auth data for a 2fas [Service]. + */ + @Serializable + data class Otp( + val counter: Int?, + val period: Int?, + val digits: Int?, + val account: String?, + val source: String?, + val tokenType: String?, + val algorithm: String?, + val link: String?, + val issuer: String?, + ) + + /** + * Models ordinal information for a 2fas [Service]. + */ + @Serializable + data class Order( + val position: Int?, + ) + + /** + * Models the icon for a 2fas [Service]. + */ + @Serializable + data class Icon( + val iconCollection: IconCollection?, + val label: Label?, + val selected: String?, + ) { + /** + * Models a collection that can be associated to a 2fas [Icon]. + */ + @Serializable + data class IconCollection( + val id: String?, + ) + + /** + * Models label data for a 2fas [Icon]. + */ + @Serializable + data class Label( + val backgroundColor: String?, + val text: String?, + ) + } + + /** + * Models badge data about a 2fas [Service]. + */ + @Serializable + data class Badge( + val color: String, + ) + } + + /** + * Models a collection of 2fas [Service] objects. + */ + @Serializable + data class Group( + val id: String, + val name: String, + val isExpanded: Boolean?, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/AegisExportParser.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/AegisExportParser.kt new file mode 100644 index 0000000000..bba1cc37c1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/AegisExportParser.kt @@ -0,0 +1,75 @@ +package com.bitwarden.authenticator.data.platform.manager.imports.parsers + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.data.platform.manager.imports.model.AegisJsonExport +import com.bitwarden.authenticator.data.platform.manager.imports.model.ExportParseResult +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import java.io.ByteArrayInputStream +import java.util.UUID + +/** + * Implementation of [ExportParser] responsible for parsing exports from the Aegis application. + */ +class AegisExportParser : ExportParser() { + @OptIn(ExperimentalSerializationApi::class) + override fun parse(byteArray: ByteArray): ExportParseResult { + val importJson = Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + } + + val exportData = importJson.decodeFromStream( + ByteArrayInputStream(byteArray), + ) + + return ExportParseResult.Success( + items = exportData.db.entries + .toAuthenticatorItemEntities(), + ) + } + + private fun List.toAuthenticatorItemEntities() = + map { it.toAuthenticatorItemEntity() } + + @Suppress("MaxLineLength") + private fun AegisJsonExport.Database.Entry.toAuthenticatorItemEntity(): AuthenticatorItemEntity { + + // Aegis only supports TOTP codes. + val type = AuthenticatorItemType.fromStringOrNull(type) + ?: throw IllegalArgumentException("Unsupported OTP type") + + val algorithmEnum = AuthenticatorItemAlgorithm + .fromStringOrNull(info.algo) + ?: throw IllegalArgumentException("Unsupported algorithm.") + + val issuer = issuer + .takeUnless { it.isEmpty() } + // If issuer is not provided we fallback to the account name. + ?: name + .split(":") + .first() + val accountName = name + .split(":") + .last() + // If the account name matches the derived issuer we ignore it to prevent redundancy. + .takeUnless { it == issuer } + + return AuthenticatorItemEntity( + id = UUID.randomUUID().toString(), + key = info.secret, + type = type, + algorithm = algorithmEnum, + period = info.period, + digits = info.digits, + issuer = issuer, + userId = null, + accountName = accountName, + favorite = favorite, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/BitwardenExportParser.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/BitwardenExportParser.kt new file mode 100644 index 0000000000..b5d4703e02 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/BitwardenExportParser.kt @@ -0,0 +1,134 @@ +package com.bitwarden.authenticator.data.platform.manager.imports.parsers + +import android.net.Uri +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager +import com.bitwarden.authenticator.data.authenticator.manager.model.ExportJsonData +import com.bitwarden.authenticator.data.platform.manager.imports.model.ExportParseResult +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat +import com.bitwarden.authenticator.ui.platform.base.util.asText +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import java.io.ByteArrayInputStream + +/** + * Implementation of [ExportParser] responsible for parsing exports from the Bitwarden application. + */ +class BitwardenExportParser( + private val fileFormat: ImportFileFormat, +) : ExportParser() { + override fun parse(byteArray: ByteArray): ExportParseResult { + return when (fileFormat) { + ImportFileFormat.BITWARDEN_JSON -> importJsonFile(byteArray) + else -> ExportParseResult.Error(R.string.import_bitwarden_unsupported_format.asText()) + } + } + + @OptIn(ExperimentalSerializationApi::class) + private fun importJsonFile(byteArray: ByteArray): ExportParseResult { + val importJson = Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + } + + val exportData = importJson.decodeFromStream( + ByteArrayInputStream(byteArray), + ) + + return ExportParseResult.Success( + items = exportData.items + .filter { it.login?.totp != null } + .toAuthenticatorItemEntities(), + ) + } + + private fun List.toAuthenticatorItemEntities() = map { + it.toAuthenticatorItemEntity() + } + + @Suppress("MaxLineLength", "CyclomaticComplexMethod", "LongMethod") + private fun ExportJsonData.ExportItem.toAuthenticatorItemEntity(): AuthenticatorItemEntity { + val otpString = requireNotNull(login?.totp) + + val otpUri = when { + otpString.startsWith(TotpCodeManager.TOTP_CODE_PREFIX) -> { + Uri.parse(otpString) + } + + otpString.startsWith(TotpCodeManager.STEAM_CODE_PREFIX) -> { + Uri.parse(otpString) + } + + else -> { + val uriString = "${TotpCodeManager.TOTP_CODE_PREFIX}/$name?${TotpCodeManager.SECRET_PARAM}=$otpString" + Uri.parse(uriString) + } + } + + val type = if (otpUri.scheme == "otpauth" && otpUri.authority == "totp") { + AuthenticatorItemType.TOTP + } else if (otpUri.scheme == "steam") { + AuthenticatorItemType.STEAM + } else { + throw IllegalArgumentException("Unsupported OTP type.") + } + + val key = when (type) { + AuthenticatorItemType.TOTP -> { + requireNotNull(otpUri.getQueryParameter(TotpCodeManager.SECRET_PARAM)) + } + + AuthenticatorItemType.STEAM -> { + requireNotNull(otpUri.authority) + } + } + + val algorithm = otpUri.getQueryParameter(TotpCodeManager.ALGORITHM_PARAM) + ?: TotpCodeManager.ALGORITHM_DEFAULT.name + + val period = otpUri.getQueryParameter(TotpCodeManager.PERIOD_PARAM) + ?.toIntOrNull() + ?: TotpCodeManager.PERIOD_SECONDS_DEFAULT + + val digits = when (type) { + AuthenticatorItemType.TOTP -> { + otpUri.getQueryParameter(TotpCodeManager.DIGITS_PARAM) + ?.toIntOrNull() + ?: TotpCodeManager.TOTP_DIGITS_DEFAULT + } + + AuthenticatorItemType.STEAM -> { + TotpCodeManager.STEAM_DIGITS_DEFAULT + } + } + val issuer = otpUri.getQueryParameter(TotpCodeManager.ISSUER_PARAM) ?: name + + val label = when (type) { + AuthenticatorItemType.TOTP -> { + otpUri.pathSegments + .firstOrNull() + .orEmpty() + .removePrefix("$issuer:") + } + + AuthenticatorItemType.STEAM -> null + } + + return AuthenticatorItemEntity( + id = id, + key = key, + type = type, + algorithm = algorithm.let { AuthenticatorItemAlgorithm.valueOf(it) }, + period = period, + digits = digits, + issuer = issuer, + accountName = label, + favorite = favorite, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/ExportParser.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/ExportParser.kt new file mode 100644 index 0000000000..a5c83543dd --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/ExportParser.kt @@ -0,0 +1,57 @@ +package com.bitwarden.authenticator.data.platform.manager.imports.parsers + +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.platform.manager.imports.model.ExportParseResult +import com.bitwarden.authenticator.ui.platform.base.util.asText +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.MissingFieldException +import kotlinx.serialization.SerializationException +import java.io.IOException + +/** + * Responsible for transforming exported authenticator data to a format consumable by this + * application. + */ +abstract class ExportParser { + /** + * Converts the given [byteArray] content of a file to a collection of + * [AuthenticatorItemEntity]. + */ + protected abstract fun parse(byteArray: ByteArray): ExportParseResult + + /** + * Parses the given byte array into an [ExportParseResult]. + * + * This method attempts to deserialize the input data and return a successful result. + * If deserialization fails due to various exceptions, an appropriate error result + * is returned instead. + * + * Exceptions handled include: + * - [MissingFieldException]: If required fields are missing in the input data. + * - [SerializationException]: If the input data cannot be processed due to invalid format. + * - [IllegalArgumentException]: If an argument provided to a method is invalid. + * - [IOException]: If an I/O error occurs during processing. + * + * @param byteArray The input data to be parsed as a [ByteArray]. + * @return [ExportParseResult] indicating success or a specific error result. + */ + @OptIn(ExperimentalSerializationApi::class) + fun parseForResult(byteArray: ByteArray): ExportParseResult = try { + parse(byteArray = byteArray) + } catch (error: MissingFieldException) { + ExportParseResult.Error( + title = R.string.required_information_missing.asText(), + message = R.string.required_information_missing_message.asText(), + ) + } catch (error: SerializationException) { + ExportParseResult.Error( + title = R.string.file_could_not_be_processed.asText(), + message = R.string.file_could_not_be_processed_message.asText(), + ) + } catch (error: IllegalArgumentException) { + ExportParseResult.Error(message = error.message?.asText()) + } catch (error: IOException) { + ExportParseResult.Error(message = error.message?.asText()) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/LastPassExportParser.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/LastPassExportParser.kt new file mode 100644 index 0000000000..226e1fa613 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/LastPassExportParser.kt @@ -0,0 +1,64 @@ +package com.bitwarden.authenticator.data.platform.manager.imports.parsers + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.data.platform.manager.imports.model.ExportParseResult +import com.bitwarden.authenticator.data.platform.manager.imports.model.LastPassJsonExport +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import java.io.ByteArrayInputStream +import java.util.UUID + +/** + * An [ExportParser] responsible for transforming LastPass export files into Bitwarden Authenticator + * items. + */ +class LastPassExportParser : ExportParser() { + + @OptIn(ExperimentalSerializationApi::class) + override fun parse(byteArray: ByteArray): ExportParseResult { + val importJson = Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + } + + val exportData = importJson.decodeFromStream( + ByteArrayInputStream(byteArray), + ) + + return ExportParseResult.Success( + items = exportData.accounts + .toAuthenticatorItemEntities(), + ) + } + + private fun List.toAuthenticatorItemEntities() = map { + it.toAuthenticatorItemEntity() + } + + private fun LastPassJsonExport.Account.toAuthenticatorItemEntity(): AuthenticatorItemEntity { + + // Lastpass only supports TOTP codes. + val type = AuthenticatorItemType.TOTP + + val algorithmEnum = AuthenticatorItemAlgorithm + .fromStringOrNull(algorithm) + ?: throw IllegalArgumentException("Unsupported algorithm.") + + return AuthenticatorItemEntity( + id = UUID.randomUUID().toString(), + key = secret, + type = type, + algorithm = algorithmEnum, + period = timeStep, + digits = digits, + issuer = originalIssuerName, + userId = null, + accountName = originalUserName, + favorite = isFavorite, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/TwoFasExportParser.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/TwoFasExportParser.kt new file mode 100644 index 0000000000..bf77306ca9 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/TwoFasExportParser.kt @@ -0,0 +1,92 @@ +package com.bitwarden.authenticator.data.platform.manager.imports.parsers + +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.data.platform.manager.imports.model.ExportParseResult +import com.bitwarden.authenticator.data.platform.manager.imports.model.TwoFasJsonExport +import com.bitwarden.authenticator.ui.platform.base.util.asText +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import java.io.ByteArrayInputStream +import java.util.UUID + +private const val TOKEN_TYPE_HOTP = "HOTP" + +/** + * An [ExportParser] responsible for transforming 2FAS export files into Bitwarden Authenticator + * items. + */ +class TwoFasExportParser : ExportParser() { + override fun parse(byteArray: ByteArray): ExportParseResult { + return import2fasJson(byteArray) + } + + @OptIn(ExperimentalSerializationApi::class) + private fun import2fasJson(byteArray: ByteArray): ExportParseResult { + val importJson = Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + encodeDefaults = true + } + + val exportData = importJson.decodeFromStream( + ByteArrayInputStream(byteArray), + ) + + return if (!exportData.servicesEncrypted.isNullOrEmpty()) { + ExportParseResult.Error( + message = R.string.import_2fas_password_protected_not_supported.asText(), + ) + } else { + ExportParseResult.Success( + items = exportData.services.toAuthenticatorItemEntities(), + ) + } + } + + private fun List.toAuthenticatorItemEntities() = mapNotNull { + it.toAuthenticatorItemEntityOrNull() + } + + @Suppress("MaxLineLength") + private fun TwoFasJsonExport.Service.toAuthenticatorItemEntityOrNull(): AuthenticatorItemEntity { + + val type = otp.tokenType + ?.let { tokenType -> + // We do not support HOTP codes so we ignore them instead of throwing an exception + if (tokenType.equals(other = TOKEN_TYPE_HOTP, ignoreCase = true)) { + null + } else { + AuthenticatorItemType.fromStringOrNull(tokenType) + } + } + ?: throw IllegalArgumentException("Unsupported OTP type: ${otp.tokenType}.") + + val algorithm = otp.algorithm + ?.let { algorithm -> + AuthenticatorItemAlgorithm + .entries + .find { entry -> + entry.name.equals(other = algorithm, ignoreCase = true) + } + } + ?: throw IllegalArgumentException("Unsupported algorithm: ${otp.algorithm}.") + + return AuthenticatorItemEntity( + id = UUID.randomUUID().toString(), + key = secret, + type = type, + algorithm = algorithm, + period = otp.period ?: 30, + digits = otp.digits ?: 6, + issuer = otp.issuer.takeUnless { it.isNullOrEmpty() } ?: name.orEmpty(), + userId = null, + accountName = otp.account, + favorite = false, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/model/FlagKey.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/model/FlagKey.kt new file mode 100644 index 0000000000..a58fe6a30f --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/model/FlagKey.kt @@ -0,0 +1,80 @@ +package com.bitwarden.authenticator.data.platform.manager.model + +/** + * Class to hold feature flag keys. + */ +sealed class FlagKey { + /** + * The string value of the given key. This must match the network value. + */ + abstract val keyName: String + + /** + * The value to be used if the flags value cannot be determined or is not remotely configured. + */ + abstract val defaultValue: T + + /** + * Indicates if the flag should respect the network value or not. + */ + abstract val isRemotelyConfigured: Boolean + + @Suppress("UndocumentedPublicClass") + companion object { + /** + * List of all flag keys to consider + */ + val activeFlags: List> by lazy { + listOf( + BitwardenAuthenticationEnabled, + PasswordManagerSync, + ) + } + } + + /** + * Indicates the state of Bitwarden authentication. + */ + data object BitwardenAuthenticationEnabled : FlagKey() { + override val keyName: String = "bitwarden-authentication-enabled" + override val defaultValue: Boolean = false + override val isRemotelyConfigured: Boolean = false + } + + /** + * Indicates whether syncing with the main Bitwarden password manager app should be enabled.. + */ + data object PasswordManagerSync : FlagKey() { + override val keyName: String = "enable-pm-bwa-sync" + override val defaultValue: Boolean = false + override val isRemotelyConfigured: Boolean = true + } + + /** + * Data object holding the key for a [Boolean] flag to be used in tests. + */ + data object DummyBoolean : FlagKey() { + override val keyName: String = "dummy-boolean" + override val defaultValue: Boolean = false + override val isRemotelyConfigured: Boolean = true + } + + /** + * Data object holding the key for an [Int] flag to be used in tests. + */ + data class DummyInt( + override val isRemotelyConfigured: Boolean = true, + ) : FlagKey() { + override val keyName: String = "dummy-int" + override val defaultValue: Int = Int.MIN_VALUE + } + + /** + * Data object holding the key for a [String] flag to be used in tests. + */ + data object DummyString : FlagKey() { + override val keyName: String = "dummy-string" + override val defaultValue: String = "defaultValue" + override val isRemotelyConfigured: Boolean = true + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepository.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepository.kt new file mode 100644 index 0000000000..280a1193df --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepository.kt @@ -0,0 +1,35 @@ +package com.bitwarden.authenticator.data.platform.repository + +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import kotlinx.coroutines.flow.Flow + +/** + * Repository for accessing data required or associated with the debug menu. + */ +interface DebugMenuRepository { + + /** + * Value to determine if the debug menu is enabled. + */ + val isDebugMenuEnabled: Boolean + + /** + * Observable flow for when any of the feature flag overrides have been updated. + */ + val featureFlagOverridesUpdatedFlow: Flow + + /** + * Update a feature flag which matches the given [key] to the given [value]. + */ + fun updateFeatureFlag(key: FlagKey, value: T) + + /** + * Get a feature flag value based on the associated [FlagKey]. + */ + fun getFeatureFlag(key: FlagKey): T? + + /** + * Reset all feature flag overrides to their default values or values from the network. + */ + fun resetFeatureFlagOverrides() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepositoryImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepositoryImpl.kt new file mode 100644 index 0000000000..6751b5be8d --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepositoryImpl.kt @@ -0,0 +1,45 @@ +package com.bitwarden.authenticator.data.platform.repository + +import com.bitwarden.authenticator.BuildConfig +import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagOverrideDiskSource +import com.bitwarden.authenticator.data.platform.manager.getFlagValueOrDefault +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onSubscription + +/** + * Default implementation of the [DebugMenuRepository] + */ +class DebugMenuRepositoryImpl( + private val featureFlagOverrideDiskSource: FeatureFlagOverrideDiskSource, + private val serverConfigRepository: ServerConfigRepository, +) : DebugMenuRepository { + + private val mutableOverridesUpdatedFlow = bufferedMutableSharedFlow(replay = 1) + override val featureFlagOverridesUpdatedFlow: Flow = mutableOverridesUpdatedFlow + .onSubscription { emit(Unit) } + + override val isDebugMenuEnabled: Boolean + get() = BuildConfig.HAS_DEBUG_MENU + + override fun updateFeatureFlag(key: FlagKey, value: T) { + featureFlagOverrideDiskSource.saveFeatureFlag(key = key, value = value) + mutableOverridesUpdatedFlow.tryEmit(Unit) + } + + override fun getFeatureFlag(key: FlagKey): T? = + featureFlagOverrideDiskSource.getFeatureFlag( + key = key, + ) + + override fun resetFeatureFlagOverrides() { + val currentServerConfig = serverConfigRepository.serverConfigStateFlow.value + FlagKey.activeFlags.forEach { flagKey -> + updateFeatureFlag( + flagKey, + currentServerConfig.getFlagValueOrDefault(flagKey), + ) + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/FeatureFlagRepository.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/FeatureFlagRepository.kt new file mode 100644 index 0000000000..e840a7164d --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/FeatureFlagRepository.kt @@ -0,0 +1,20 @@ +package com.bitwarden.authenticator.data.platform.repository + +import com.bitwarden.authenticator.data.platform.datasource.disk.model.FeatureFlagsConfiguration +import kotlinx.coroutines.flow.StateFlow + +/** + * Provides an API for observing the server config state. + */ +interface FeatureFlagRepository { + + /** + * Emits updates that track [FeatureFlagsConfiguration]. + */ + val featureFlagConfigStateFlow: StateFlow + + /** + * Gets the state [FeatureFlagsConfiguration]. + */ + suspend fun getFeatureFlagsConfiguration(): FeatureFlagsConfiguration +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/FeatureFlagRepositoryImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/FeatureFlagRepositoryImpl.kt new file mode 100644 index 0000000000..221b5ff54c --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/FeatureFlagRepositoryImpl.kt @@ -0,0 +1,47 @@ +package com.bitwarden.authenticator.data.platform.repository + +import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.model.FeatureFlagsConfiguration +import com.bitwarden.authenticator.data.platform.manager.DispatcherManager +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.serialization.json.JsonPrimitive + +/** + * Primary implementation of [FeatureFlagRepositoryImpl]. + */ +class FeatureFlagRepositoryImpl( + private val featureFlagDiskSource: FeatureFlagDiskSource, + dispatcherManager: DispatcherManager, +) : FeatureFlagRepository { + + private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) + + override val featureFlagConfigStateFlow: StateFlow + get() = featureFlagDiskSource + .featureFlagsConfigurationFlow + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = featureFlagDiskSource.featureFlagsConfiguration, + ) + + override suspend fun getFeatureFlagsConfiguration() = + featureFlagDiskSource.featureFlagsConfiguration + ?: initLocalFeatureFlagsConfiguration() + + private fun initLocalFeatureFlagsConfiguration(): FeatureFlagsConfiguration { + val config = FeatureFlagsConfiguration( + mapOf( + FlagKey.BitwardenAuthenticationEnabled.keyName to JsonPrimitive( + FlagKey.BitwardenAuthenticationEnabled.defaultValue, + ), + ), + ) + featureFlagDiskSource.featureFlagsConfiguration = config + return config + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepository.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepository.kt new file mode 100644 index 0000000000..4128106386 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepository.kt @@ -0,0 +1,21 @@ +package com.bitwarden.authenticator.data.platform.repository + +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import kotlinx.coroutines.flow.StateFlow + +/** + * Provides an API for observing the server config state. + */ +interface ServerConfigRepository { + + /** + * Emits updates that track [ServerConfig]. + */ + val serverConfigStateFlow: StateFlow + + /** + * Gets the state [ServerConfig]. If needed or forced by [forceRefresh], + * updates the values using server side data. + */ + suspend fun getServerConfig(forceRefresh: Boolean): ServerConfig? +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepositoryImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepositoryImpl.kt new file mode 100644 index 0000000000..df428e08dd --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepositoryImpl.kt @@ -0,0 +1,65 @@ +package com.bitwarden.authenticator.data.platform.repository + +import com.bitwarden.authenticator.data.platform.datasource.disk.ConfigDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.data.platform.datasource.network.service.ConfigService +import com.bitwarden.authenticator.data.platform.manager.DispatcherManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import java.time.Clock +import java.time.Instant + +/** + * Primary implementation of [ServerConfigRepositoryImpl]. + */ +class ServerConfigRepositoryImpl( + private val configDiskSource: ConfigDiskSource, + private val configService: ConfigService, + private val clock: Clock, + dispatcherManager: DispatcherManager, +) : ServerConfigRepository { + + private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) + + override val serverConfigStateFlow: StateFlow + get() = configDiskSource + .serverConfigFlow + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = configDiskSource.serverConfig, + ) + + override suspend fun getServerConfig(forceRefresh: Boolean): ServerConfig? { + val localConfig = configDiskSource.serverConfig + val needsRefresh = localConfig == null || + Instant + .ofEpochMilli(localConfig.lastSync) + .isAfter( + clock.instant().plusSeconds(MINIMUM_CONFIG_SYNC_INTERVAL_SEC), + ) + + if (needsRefresh || forceRefresh) { + configService + .getConfig() + .onSuccess { configResponse -> + val serverConfig = ServerConfig( + lastSync = clock.instant().toEpochMilli(), + serverData = configResponse, + ) + configDiskSource.serverConfig = serverConfig + return serverConfig + } + } + + // If we are unable to retrieve a configuration from the server, + // fall back to the local configuration. + return localConfig + } + + private companion object { + private const val MINIMUM_CONFIG_SYNC_INTERVAL_SEC: Long = 60 * 60 + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt new file mode 100644 index 0000000000..91cf03f160 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt @@ -0,0 +1,110 @@ +package com.bitwarden.authenticator.data.platform.repository + +import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +/** + * Provides an API for observing and modifying settings state. + */ +interface SettingsRepository { + + /** + * The [AppLanguage] for the current user. + */ + var appLanguage: AppLanguage + + /** + * The currently stored [AppTheme]. + */ + var appTheme: AppTheme + + /** + * Tracks changes to the [AppTheme]. + */ + val appThemeStateFlow: StateFlow + + /** + * The currently stored expiration alert threshold. + */ + var authenticatorAlertThresholdSeconds: Int + + /** + * The currently stored [DefaultSaveOption]. + */ + var defaultSaveOption: DefaultSaveOption + + /** + * Flow that emits changes to [defaultSaveOption] + */ + val defaultSaveOptionFlow: Flow + + /** + * Whether or not biometric unlocking is enabled for the current user. + */ + val isUnlockWithBiometricsEnabled: Boolean + + /** + * Tracks changes to the expiration alert threshold. + */ + val authenticatorAlertThresholdSecondsFlow: StateFlow + + /** + * Whether the user has seen the Welcome tutorial. + */ + var hasSeenWelcomeTutorial: Boolean + + /** + * Tracks whether the user has seen the Welcome tutorial. + */ + val hasSeenWelcomeTutorialFlow: StateFlow + + /** + * Sets whether or not screen capture is allowed for the current user. + */ + var isScreenCaptureAllowed: Boolean + + /** + * A set of Bitwarden account IDs that have previously been synced. + */ + var previouslySyncedBitwardenAccountIds: Set + + /** + * Whether or not screen capture is allowed for the current user. + */ + val isScreenCaptureAllowedStateFlow: StateFlow + + /** + * Clears any previously stored encrypted user key used with biometrics for the current user. + */ + fun clearBiometricsKey() + + /** + * Stores the encrypted user key for biometrics, allowing it to be used to unlock the current + * user's vault. + */ + suspend fun setupBiometricsKey(): BiometricsKeyResult + + /** + * The current setting for crash logging. + */ + var isCrashLoggingEnabled: Boolean + + /** + * Emits updates that track the [isCrashLoggingEnabled] value. + */ + val isCrashLoggingEnabledFlow: Flow + + /** + * Whether or not the user has previously dismissed the download Bitwarden action card. + */ + var hasUserDismissedDownloadBitwardenCard: Boolean + + /** + * Whether or not the user has previously dismissed the sync with Bitwarden action card. + */ + var hasUserDismissedSyncWithBitwardenCard: Boolean +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt new file mode 100644 index 0000000000..fe559d23c9 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt @@ -0,0 +1,152 @@ +package com.bitwarden.authenticator.data.platform.repository + +import com.bitwarden.authenticator.BuildConfig +import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource +import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource +import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager +import com.bitwarden.authenticator.data.platform.manager.DispatcherManager +import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +private val DEFAULT_IS_SCREEN_CAPTURE_ALLOWED = BuildConfig.DEBUG + +/** + * Primary implementation of [SettingsRepository]. + */ +class SettingsRepositoryImpl( + private val settingsDiskSource: SettingsDiskSource, + private val authDiskSource: AuthDiskSource, + private val biometricsEncryptionManager: BiometricsEncryptionManager, + private val authenticatorSdkSource: AuthenticatorSdkSource, + dispatcherManager: DispatcherManager, +) : SettingsRepository { + + private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) + + override var appLanguage: AppLanguage + get() = settingsDiskSource.appLanguage ?: AppLanguage.DEFAULT + set(value) { + settingsDiskSource.appLanguage = value + } + + override var appTheme: AppTheme by settingsDiskSource::appTheme + + override var authenticatorAlertThresholdSeconds = settingsDiskSource.getAlertThresholdSeconds() + + override var defaultSaveOption: DefaultSaveOption by settingsDiskSource::defaultSaveOption + + override val defaultSaveOptionFlow: Flow + by settingsDiskSource::defaultSaveOptionFlow + + override val isUnlockWithBiometricsEnabled: Boolean + get() = authDiskSource.getUserBiometricUnlockKey() != null + + override val appThemeStateFlow: StateFlow + get() = settingsDiskSource + .appThemeFlow + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = settingsDiskSource.appTheme, + ) + + override val authenticatorAlertThresholdSecondsFlow: StateFlow + get() = settingsDiskSource + .getAlertThresholdSecondsFlow() + .map { it } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = settingsDiskSource.getAlertThresholdSeconds(), + ) + + override var hasSeenWelcomeTutorial: Boolean + get() = settingsDiskSource.hasSeenWelcomeTutorial + set(value) { + settingsDiskSource.hasSeenWelcomeTutorial = value + } + + override val hasSeenWelcomeTutorialFlow: StateFlow + get() = settingsDiskSource + .hasSeenWelcomeTutorialFlow + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = hasSeenWelcomeTutorial, + ) + + override var isScreenCaptureAllowed: Boolean + get() = settingsDiskSource.getScreenCaptureAllowed() + ?: DEFAULT_IS_SCREEN_CAPTURE_ALLOWED + set(value) { + settingsDiskSource.storeScreenCaptureAllowed( + isScreenCaptureAllowed = value, + ) + } + override var previouslySyncedBitwardenAccountIds: Set by + settingsDiskSource::previouslySyncedBitwardenAccountIds + + override val isScreenCaptureAllowedStateFlow: StateFlow + get() = settingsDiskSource.getScreenCaptureAllowedFlow() + .map { isAllowed -> isAllowed ?: DEFAULT_IS_SCREEN_CAPTURE_ALLOWED } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Lazily, + initialValue = settingsDiskSource.getScreenCaptureAllowed() + ?: DEFAULT_IS_SCREEN_CAPTURE_ALLOWED, + ) + + override suspend fun setupBiometricsKey(): BiometricsKeyResult { + biometricsEncryptionManager.setupBiometrics() + return authenticatorSdkSource + .generateBiometricsKey() + .onSuccess { + authDiskSource.storeUserBiometricUnlockKey(biometricsKey = it) + } + .fold( + onSuccess = { BiometricsKeyResult.Success }, + onFailure = { BiometricsKeyResult.Error }, + ) + } + + override fun clearBiometricsKey() { + authDiskSource.storeUserBiometricUnlockKey(biometricsKey = null) + } + + override var isCrashLoggingEnabled: Boolean + get() = settingsDiskSource.isCrashLoggingEnabled ?: true + set(value) { + settingsDiskSource.isCrashLoggingEnabled = value + } + + override val isCrashLoggingEnabledFlow: Flow + get() = settingsDiskSource + .isCrashLoggingEnabledFlow + .map { it ?: isCrashLoggingEnabled } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = isCrashLoggingEnabled, + ) + + override var hasUserDismissedDownloadBitwardenCard: Boolean + get() = settingsDiskSource.hasUserDismissedDownloadBitwardenCard ?: false + set(value) { + settingsDiskSource.hasUserDismissedDownloadBitwardenCard = value + } + + override var hasUserDismissedSyncWithBitwardenCard: Boolean + get() = settingsDiskSource.hasUserDismissedSyncWithBitwardenCard ?: false + set(value) { + settingsDiskSource.hasUserDismissedSyncWithBitwardenCard = value + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt new file mode 100644 index 0000000000..5fad4099a8 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt @@ -0,0 +1,86 @@ +package com.bitwarden.authenticator.data.platform.repository.di + +import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource +import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource +import com.bitwarden.authenticator.data.platform.datasource.disk.ConfigDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagOverrideDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource +import com.bitwarden.authenticator.data.platform.datasource.network.service.ConfigService +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager +import com.bitwarden.authenticator.data.platform.manager.DispatcherManager +import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository +import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepositoryImpl +import com.bitwarden.authenticator.data.platform.repository.FeatureFlagRepository +import com.bitwarden.authenticator.data.platform.repository.FeatureFlagRepositoryImpl +import com.bitwarden.authenticator.data.platform.repository.ServerConfigRepository +import com.bitwarden.authenticator.data.platform.repository.ServerConfigRepositoryImpl +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.data.platform.repository.SettingsRepositoryImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.time.Clock +import javax.inject.Singleton + +/** + * Provides repositories in the auth package. + */ +@Module +@InstallIn(SingletonComponent::class) +object PlatformRepositoryModule { + + @Provides + @Singleton + fun provideSettingsRepository( + settingsDiskSource: SettingsDiskSource, + authDiskSource: AuthDiskSource, + dispatcherManager: DispatcherManager, + biometricsEncryptionManager: BiometricsEncryptionManager, + authenticatorSdkSource: AuthenticatorSdkSource, + ): SettingsRepository = + SettingsRepositoryImpl( + settingsDiskSource = settingsDiskSource, + authDiskSource = authDiskSource, + dispatcherManager = dispatcherManager, + biometricsEncryptionManager = biometricsEncryptionManager, + authenticatorSdkSource = authenticatorSdkSource, + ) + + @Provides + @Singleton + fun provideServerConfigRepository( + configDiskSource: ConfigDiskSource, + configService: ConfigService, + clock: Clock, + dispatcherManager: DispatcherManager, + ): ServerConfigRepository = + ServerConfigRepositoryImpl( + configDiskSource = configDiskSource, + configService = configService, + clock = clock, + dispatcherManager = dispatcherManager, + ) + + @Provides + @Singleton + fun provideFeatureFlagRepo( + featureFlagDiskSource: FeatureFlagDiskSource, + dispatcherManager: DispatcherManager, + ): FeatureFlagRepository = + FeatureFlagRepositoryImpl( + featureFlagDiskSource = featureFlagDiskSource, + dispatcherManager = dispatcherManager, + ) + + @Provides + @Singleton + fun provideDebugMenuRepository( + featureFlagOverrideDiskSource: FeatureFlagOverrideDiskSource, + serverConfigRepository: ServerConfigRepository, + ): DebugMenuRepository = DebugMenuRepositoryImpl( + featureFlagOverrideDiskSource = featureFlagOverrideDiskSource, + serverConfigRepository = serverConfigRepository, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/BiometricsKeyResult.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/BiometricsKeyResult.kt new file mode 100644 index 0000000000..2e79383ec7 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/BiometricsKeyResult.kt @@ -0,0 +1,16 @@ +package com.bitwarden.authenticator.data.platform.repository.model + +/** + * Models result of setting up a biometrics key. + */ +sealed class BiometricsKeyResult { + /** + * Biometrics key setup successfully. + */ + data object Success : BiometricsKeyResult() + + /** + * Generic error while setting up the biometrics key. + */ + data object Error : BiometricsKeyResult() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/DataState.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/DataState.kt new file mode 100644 index 0000000000..3692a09e5c --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/DataState.kt @@ -0,0 +1,48 @@ +package com.bitwarden.authenticator.data.platform.repository.model + +/** + * A data state that can be used as a template for data in the repository layer. + */ +sealed class DataState { + + /** + * Data that is being wrapped by [DataState]. + */ + abstract val data: T? + + /** + * Loading state that has no data is available. + */ + data object Loading : DataState() { + override val data: Nothing? get() = null + } + + /** + * Loaded state that has data available. + */ + data class Loaded( + override val data: T, + ) : DataState() + + /** + * Pending state that has data available. + */ + data class Pending( + override val data: T, + ) : DataState() + + /** + * Error state that may have data available. + */ + data class Error( + val error: Throwable, + override val data: T? = null, + ) : DataState() + + /** + * No network state that may have data is available. + */ + data class NoNetwork( + override val data: T? = null, + ) : DataState() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/Environment.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/Environment.kt new file mode 100644 index 0000000000..9a2681da74 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/Environment.kt @@ -0,0 +1,68 @@ +package com.bitwarden.authenticator.data.platform.repository.model + +import com.bitwarden.authenticator.data.auth.datasource.disk.model.EnvironmentUrlDataJson +import com.bitwarden.authenticator.data.platform.repository.util.labelOrBaseUrlHost + +/** + * A higher-level wrapper around [EnvironmentUrlDataJson] that provides type-safety, enumerability, + * and human-readable labels. + */ +sealed class Environment { + /** + * The [Type] of the environment. + */ + abstract val type: Type + + /** + * The raw [environmentUrlData] that contains specific base URLs for each relevant domain. + */ + abstract val environmentUrlData: EnvironmentUrlDataJson + + /** + * A human-readable label for the environment based in some way on its base URL. + */ + abstract val label: String + + /** + * The default US environment. + */ + data object Us : Environment() { + override val type: Type get() = Type.US + override val environmentUrlData: EnvironmentUrlDataJson + get() = EnvironmentUrlDataJson.DEFAULT_US + override val label: String + get() = "bitwarden.com" + } + + /** + * The default EU environment. + */ + data object Eu : Environment() { + override val type: Type get() = Type.EU + override val environmentUrlData: EnvironmentUrlDataJson + get() = EnvironmentUrlDataJson.DEFAULT_EU + override val label: String + get() = "bitwarden.eu" + } + + /** + * A custom self-hosted environment with a fully configurable [environmentUrlData]. + */ + data class SelfHosted( + override val environmentUrlData: EnvironmentUrlDataJson, + ) : Environment() { + override val type: Type get() = Type.SELF_HOSTED + override val label: String + get() = environmentUrlData.labelOrBaseUrlHost + } + + /** + * A summary of the various types that can be enumerated over and which contains a + * human-readable [label]. + */ + enum class Type { + US, + EU, + SELF_HOSTED, + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/util/DataStateExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/util/DataStateExtensions.kt new file mode 100644 index 0000000000..bc23a7855c --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/util/DataStateExtensions.kt @@ -0,0 +1,94 @@ +package com.bitwarden.authenticator.data.platform.repository.util + +import com.bitwarden.authenticator.data.platform.repository.model.DataState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.transformWhile + +/** + * Maps the data inside a [DataState] with the given [transform]. + */ +inline fun DataState.map( + transform: (T) -> R, +): DataState = when (this) { + is DataState.Loaded -> DataState.Loaded(transform(data)) + is DataState.Loading -> DataState.Loading + is DataState.Pending -> DataState.Pending(transform(data)) + is DataState.Error -> DataState.Error(error, data?.let(transform)) + is DataState.NoNetwork -> DataState.NoNetwork(data?.let(transform)) +} + +/** + * Emits all values of a [DataState] [Flow] until it emits a [DataState.Loaded]. + */ +fun Flow>.takeUntilLoaded(): Flow> = transformWhile { + emit(it) + it !is DataState.Loaded +} + +/** + * Combines the [dataState1] and [dataState2] [DataState]s together using the provided [transform]. + * + * This function will internally manage the final `DataState` type that is returned. This is done + * by prioritizing each if the states in this order: + * + * - [DataState.Error] + * - [DataState.NoNetwork] + * - [DataState.Loading] + * - [DataState.Pending] + * - [DataState.Loaded] + * + * This priority order ensures that the total state is accurately reflecting the underlying states. + * If one of the `DataState`s has a higher priority than the other, the output will be the highest + * priority. For example, if one `DataState` is `DataState.Loaded` and another is `DataState.Error` + * then the returned `DataState` will be `DataState.Error`. + * + * The payload of the final `DataState` be created by the `transform` lambda which will be invoked + * whenever the payloads of both `dataState1` and `dataState2` are not null. In the scenario where + * one or both payloads are null, the resulting `DataState` will have a null payload. + */ +fun combineDataStates( + dataState1: DataState, + dataState2: DataState, + transform: (t1: T1, t2: T2) -> R, +): DataState { + // Wraps the `transform` lambda to allow null data to be passed in. If either of the passed in + // values are null, the regular transform will not be invoked and null is returned. + val nullableTransform: (T1?, T2?) -> R? = { t1, t2 -> + if (t1 != null && t2 != null) transform(t1, t2) else null + } + return when { + // Error states have highest priority, fail fast. + dataState1 is DataState.Error -> { + DataState.Error( + error = dataState1.error, + data = nullableTransform(dataState1.data, dataState2.data), + ) + } + + dataState2 is DataState.Error -> { + DataState.Error( + error = dataState2.error, + data = nullableTransform(dataState1.data, dataState2.data), + ) + } + + dataState1 is DataState.NoNetwork || dataState2 is DataState.NoNetwork -> { + DataState.NoNetwork(nullableTransform(dataState1.data, dataState2.data)) + } + + // Something is still loading, we will wait for all the data. + dataState1 is DataState.Loading || dataState2 is DataState.Loading -> DataState.Loading + + // Pending state for everything while any one piece of data is updating. + dataState1 is DataState.Pending || dataState2 is DataState.Pending -> { + @Suppress("UNCHECKED_CAST") + DataState.Pending(transform(dataState1.data as T1, dataState2.data as T2)) + } + + // Both states are Loaded and have data + else -> { + @Suppress("UNCHECKED_CAST") + DataState.Loaded(transform(dataState1.data as T1, dataState2.data as T2)) + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/util/EnvironmentUrlDataJsonExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/util/EnvironmentUrlDataJsonExtensions.kt new file mode 100644 index 0000000000..c183f36665 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/util/EnvironmentUrlDataJsonExtensions.kt @@ -0,0 +1,122 @@ +package com.bitwarden.authenticator.data.platform.repository.util + +import com.bitwarden.authenticator.data.auth.datasource.disk.model.EnvironmentUrlDataJson +import com.bitwarden.authenticator.data.platform.repository.model.Environment +import java.net.URI + +private const val DEFAULT_API_URL: String = "https://api.bitwarden.com" +private const val DEFAULT_WEB_VAULT_URL: String = "https://vault.bitwarden.com" +private const val DEFAULT_WEB_SEND_URL: String = "https://send.bitwarden.com/#" +private const val DEFAULT_ICON_URL: String = "https://icons.bitwarden.net" + +/** + * Returns the base api URL or the default value if one is not present. + */ +val EnvironmentUrlDataJson.baseApiUrl: String + get() = this.base.sanitizeUrl?.let { "$it/api" } + ?: this.api.sanitizeUrl + ?: DEFAULT_API_URL + +/** + * Returns the base web vault URL. This will check for a custom [EnvironmentUrlDataJson.webVault] + * before falling back to the [EnvironmentUrlDataJson.base]. This can still return null if both are + * null or blank. + */ +val EnvironmentUrlDataJson.baseWebVaultUrlOrNull: String? + get() = this.webVault.sanitizeUrl + ?: this.base.sanitizeUrl + +/** + * Returns the base web vault URL or the default value if one is not present. + * + * See [baseWebVaultUrlOrNull] for more details. + */ +val EnvironmentUrlDataJson.baseWebVaultUrlOrDefault: String + get() = this.baseWebVaultUrlOrNull ?: DEFAULT_WEB_VAULT_URL + +/** + * Returns the base web send URL or the default value if one is not present. + */ +val EnvironmentUrlDataJson.baseWebSendUrl: String + get() = + this + .baseWebVaultUrlOrNull + ?.let { "$it/#/send/" } + ?: DEFAULT_WEB_SEND_URL + +/** + * Returns the base web vault import URL or the default value if one is not present. + */ +val EnvironmentUrlDataJson.toBaseWebVaultImportUrl: String + get() = + this + .baseWebVaultUrlOrDefault + .let { "$it/#/tools/import" } + +/** + * Returns a base icon url based on the environment or the default value if values are missing. + */ +val EnvironmentUrlDataJson.baseIconUrl: String + get() = this.icon.sanitizeUrl + ?: this.base.sanitizeUrl?.let { "$it/icons" } + ?: DEFAULT_ICON_URL + +/** + * Returns the appropriate pre-defined labels for environments matching the known US/EU values. + * Otherwise returns the host of the custom base URL. + * + * @see getSelfHostedUrlOrNull + */ +val EnvironmentUrlDataJson.labelOrBaseUrlHost: String + get() = when (this) { + EnvironmentUrlDataJson.DEFAULT_US -> Environment.Us.label + EnvironmentUrlDataJson.DEFAULT_EU -> Environment.Eu.label + else -> { + // Grab the domain + // Ex: + // - "https://www.abc.com/path-1/path-1" -> "www.abc.com" + URI + .create(getSelfHostedUrlOrNull().orEmpty()) + .host + .orEmpty() + } + } + +/** + * Returns the first self-hosted environment URL from + * [EnvironmentUrlDataJson.webVault], [EnvironmentUrlDataJson.base], + * [EnvironmentUrlDataJson.api], and finally [EnvironmentUrlDataJson.identity]. Returns `null` if + * all self-host environment URLs are null. + */ +private fun EnvironmentUrlDataJson.getSelfHostedUrlOrNull(): String? = + this.webVault.sanitizeUrl + ?: this.base.sanitizeUrl + ?: this.api.sanitizeUrl + ?: this.identity.sanitizeUrl + +/** + * A helper method to filter out blank urls and remove any trailing forward slashes. + */ +private val String?.sanitizeUrl: String? + get() = this?.trimEnd('/').takeIf { !it.isNullOrBlank() } + +/** + * Converts a raw [EnvironmentUrlDataJson] to an externally-consumable [Environment]. + */ +fun EnvironmentUrlDataJson.toEnvironmentUrls(): Environment = + when (this) { + EnvironmentUrlDataJson.DEFAULT_US, + -> Environment.Us + + EnvironmentUrlDataJson.DEFAULT_EU, + -> Environment.Eu + + else -> Environment.SelfHosted(environmentUrlData = this) + } + +/** + * Converts a nullable [EnvironmentUrlDataJson] to an [Environment], where `null` values default to + * the US environment. + */ +fun EnvironmentUrlDataJson?.toEnvironmentUrlsOrDefault(): Environment = + this?.toEnvironmentUrls() ?: Environment.Us diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/util/SharedFlowExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/util/SharedFlowExtensions.kt new file mode 100644 index 0000000000..78e0880307 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/util/SharedFlowExtensions.kt @@ -0,0 +1,14 @@ +package com.bitwarden.authenticator.data.platform.repository.util + +import kotlinx.coroutines.flow.MutableSharedFlow + +/** + * Creates a [MutableSharedFlow] with a buffer of [Int.MAX_VALUE] and the given [replay] count. + */ +fun bufferedMutableSharedFlow( + replay: Int = 0, +): MutableSharedFlow = + MutableSharedFlow( + replay = replay, + extraBufferCapacity = Int.MAX_VALUE, + ) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/util/IntentExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/util/IntentExtensions.kt new file mode 100644 index 0000000000..5a8ac5ae8a --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/util/IntentExtensions.kt @@ -0,0 +1,19 @@ +package com.bitwarden.authenticator.data.platform.util + +import android.content.Intent + +/** + * Returns true if this intent contains unexpected or suspicious data. + */ +val Intent.isSuspicious: Boolean + get() { + return try { + val containsSuspiciousExtras = extras?.isEmpty() == false + val containsSuspiciousData = data != null + containsSuspiciousData || containsSuspiciousExtras + } catch (_: Exception) { + // `unparcel()` throws an exception on Android 12 and below if the bundle contains + // suspicious data, so we catch the exception and return true. + true + } + } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/util/JsonExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/util/JsonExtensions.kt new file mode 100644 index 0000000000..bb658485c6 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/util/JsonExtensions.kt @@ -0,0 +1,19 @@ +package com.bitwarden.authenticator.data.platform.util + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + +/** + * Attempts to decode the given JSON [string] into the given type [T]. If there is an error in + * processing the JSON or deserializing it to an instance of [T], `null` will be returned. + */ +inline fun Json.decodeFromStringOrNull( + string: String, +): T? = + try { + decodeFromString(string = string) + } catch (e: SerializationException) { + null + } catch (e: IllegalArgumentException) { + null + } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/util/ResultExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/util/ResultExtensions.kt new file mode 100644 index 0000000000..87a3eaba31 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/util/ResultExtensions.kt @@ -0,0 +1,22 @@ +package com.bitwarden.authenticator.data.platform.util + +/** + * Flat maps a successful [Result] with the given [transform] to another [Result], and leaves + * failures untouched. + */ +inline fun Result.flatMap(transform: (T) -> Result): Result = + this.exceptionOrNull() + ?.asFailure() + ?: transform(this.getOrThrow()) + +/** + * Returns the given receiver of type [T] as a "success" [Result]. + */ +fun T.asSuccess(): Result = + Result.success(this) + +/** + * Returns the given [Throwable] as a "failure" [Result]. + */ +fun Throwable.asFailure(): Result = + Result.failure(this) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/util/SpecialCharWithPrecedenceComparator.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/util/SpecialCharWithPrecedenceComparator.kt new file mode 100644 index 0000000000..85effc97db --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/util/SpecialCharWithPrecedenceComparator.kt @@ -0,0 +1,67 @@ +package com.bitwarden.authenticator.data.platform.util + +import java.util.Locale + +/** + * String [Comparator] where the characters are compared giving precedence to + * special characters. + */ +object SpecialCharWithPrecedenceComparator : Comparator { + override fun compare(str1: String, str2: String): Int { + val minLength = minOf(str1.length, str2.length) + for (i in 0 until minLength) { + val char1 = str1[i] + val char2 = str2[i] + val compareResult = compareCharsSpecialCharsWithPrecedence(char1, char2) + if (compareResult != 0) { + return compareResult + } + } + // If all compared chars are the same give precedence to the shorter String. + return str1.length - str2.length + } +} + +/** + * Compare two characters, where a special character is considered with higher precedence over + * letters and numbers. If both characters are a letter and they are equal ignoring the case, + * give priority to the lowercase instance. If they are both a digit or a non-equal letter + * use the default [String.compareTo] converting the chars to the [Locale] specific uppercase + * String. + */ +private fun compareCharsSpecialCharsWithPrecedence(c1: Char, c2: Char): Int { + return when { + c1.isLetterOrDigit() && !c2.isLetterOrDigit() -> 1 + !c1.isLetterOrDigit() && c2.isLetterOrDigit() -> -1 + c1.isLetter() && c2.isLetter() && c1.equals(other = c2, ignoreCase = true) -> { + compareLettersLowerCaseFirst(c1 = c1, c2 = c2) + } + + else -> { + val upperCaseStr1 = c1.toString().uppercase(Locale.getDefault()) + val upperCaseStr2 = c2.toString().uppercase(Locale.getDefault()) + upperCaseStr1.compareTo(upperCaseStr2) + } + } +} + +/** + * Compare two equal letters ignoring case (i.e. 'A' == 'a'), give precedence to the + * the character which is lowercase. If both [c1] and [c2] are equal and the + * same case return 0 to indicate they are the same. + */ +private fun compareLettersLowerCaseFirst(c1: Char, c2: Char): Int { + require( + value = c1.isLetter() && + c2.isLetter() && + c1.equals(other = c2, ignoreCase = true), + ) { + "Both character must be the same letter, case does not matter." + } + + return when { + !c1.isLowerCase() && c2.isLowerCase() -> 1 + c1.isLowerCase() && !c2.isLowerCase() -> -1 + else -> 0 + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockNavigation.kt new file mode 100644 index 0000000000..dceddeb0fb --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockNavigation.kt @@ -0,0 +1,28 @@ +package com.bitwarden.authenticator.ui.auth.unlock + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable + +const val UNLOCK_ROUTE: String = "unlock" + +/** + * Navigate to the unlock screen. + */ +fun NavController.navigateToUnlock( + navOptions: NavOptions? = null, +) { + navigate(route = UNLOCK_ROUTE, navOptions = navOptions) +} + +/** + * Add the unlock screen to the nav graph. + */ +fun NavGraphBuilder.unlockDestination( + onUnlocked: () -> Unit, +) { + composable(route = UNLOCK_ROUTE) { + UnlockScreen(onUnlocked = onUnlocked) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt new file mode 100644 index 0000000000..e1f73479ca --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt @@ -0,0 +1,159 @@ +package com.bitwarden.authenticator.ui.auth.unlock + +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton +import com.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenLoadingDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.LoadingDialogState +import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager +import com.bitwarden.authenticator.ui.platform.theme.LocalBiometricsManager + +/** + * Top level composable for the unlock screen. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UnlockScreen( + viewModel: UnlockViewModel = hiltViewModel(), + biometricsManager: BiometricsManager = LocalBiometricsManager.current, + onUnlocked: () -> Unit, +) { + + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + val resources = context.resources + var showBiometricsPrompt by remember { mutableStateOf(true) } + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is UnlockEvent.ShowToast -> { + Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show() + } + + UnlockEvent.NavigateToItemListing -> onUnlocked() + } + } + + when (val dialog = state.dialog) { + is UnlockState.Dialog.Error -> BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = dialog.message, + ), + onDismissRequest = remember(viewModel) { + { + viewModel.trySendAction(UnlockAction.DismissDialog) + } + }, + ) + + UnlockState.Dialog.Loading -> BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown(R.string.loading.asText()), + ) + + null -> Unit + } + + val onBiometricsUnlock: () -> Unit = remember(viewModel) { + { viewModel.trySendAction(UnlockAction.BiometricsUnlock) } + } + val onBiometricsLockOut: () -> Unit = remember(viewModel) { + { viewModel.trySendAction(UnlockAction.BiometricsLockout) } + } + + if (showBiometricsPrompt) { + biometricsManager.promptBiometrics( + onSuccess = { + showBiometricsPrompt = false + onBiometricsUnlock() + }, + onCancel = { + showBiometricsPrompt = false + }, + onError = { + showBiometricsPrompt = false + }, + onLockOut = { + showBiometricsPrompt = false + onBiometricsLockOut() + }, + ) + } + + BitwardenScaffold( + modifier = Modifier + .fillMaxSize(), + ) { innerPadding -> + Box { + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + modifier = Modifier + .padding(horizontal = 16.dp) + .width(220.dp) + .height(74.dp) + .fillMaxWidth(), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + painter = painterResource(id = R.drawable.ic_logo_horizontal), + contentDescription = stringResource(R.string.bitwarden_authenticator), + ) + Spacer(modifier = Modifier.height(32.dp)) + BitwardenFilledTonalButton( + label = stringResource(id = R.string.use_biometrics_to_unlock), + onClick = { + biometricsManager.promptBiometrics( + onSuccess = onBiometricsUnlock, + onCancel = { + // no-op + }, + onError = { + // no-op + }, + onLockOut = onBiometricsLockOut, + ) + }, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + } + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModel.kt new file mode 100644 index 0000000000..1bcb32f2d3 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModel.kt @@ -0,0 +1,149 @@ +package com.bitwarden.authenticator.ui.auth.unlock + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * View model for the Unlock screen. + */ +@HiltViewModel +class UnlockViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val settingsRepository: SettingsRepository, + private val biometricsEncryptionManager: BiometricsEncryptionManager, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] ?: run { + UnlockState( + isBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled, + isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(), + dialog = null, + ) + }, +) { + + init { + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + } + + override fun handleAction(action: UnlockAction) { + when (action) { + UnlockAction.BiometricsUnlock -> { + handleBiometricsUnlock() + } + + UnlockAction.DismissDialog -> { + handleDismissDialog() + } + + UnlockAction.BiometricsLockout -> { + handleBiometricsLockout() + } + } + } + + private fun handleBiometricsUnlock() { + if (state.isBiometricsEnabled && !state.isBiometricsValid) { + biometricsEncryptionManager.setupBiometrics() + } + sendEvent(UnlockEvent.NavigateToItemListing) + } + + private fun handleDismissDialog() { + mutableStateFlow.update { it.copy(dialog = null) } + } + + private fun handleBiometricsLockout() { + mutableStateFlow.update { + it.copy( + dialog = UnlockState.Dialog.Error( + message = R.string.too_many_failed_biometric_attempts.asText(), + ), + ) + } + } +} + +/** + * Represents state for the Unlock screen + */ +@Parcelize +data class UnlockState( + val isBiometricsEnabled: Boolean, + val isBiometricsValid: Boolean, + val dialog: Dialog?, +) : Parcelable { + + /** + * Represents the various dialogs the Unlock screen can display. + */ + @Parcelize + sealed class Dialog : Parcelable { + /** + * Displays a generic error dialog to the user. + */ + data class Error( + val message: Text, + ) : Dialog() + + /** + * Displays the loading dialog to the user. + */ + data object Loading : Dialog() + } +} + +/** + * Models events for the Unlock screen. + */ +sealed class UnlockEvent { + + /** + * Navigates to the item listing screen. + */ + data object NavigateToItemListing : UnlockEvent() + + /** + * Displays a toast to the user. + */ + data class ShowToast( + val message: Text, + ) : UnlockEvent() +} + +/** + * Models actions for the Unlock screen. + */ +sealed class UnlockAction { + + /** + * The user dismissed the dialog. + */ + data object DismissDialog : UnlockAction() + + /** + * The user has failed biometric unlock too many times. + */ + data object BiometricsLockout : UnlockAction() + + /** + * The user has successfully unlocked the app with biometrics. + */ + data object BiometricsUnlock : UnlockAction() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt new file mode 100644 index 0000000000..683b6de821 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt @@ -0,0 +1,68 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.authenticator + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.navigation +import com.bitwarden.authenticator.ui.authenticator.feature.edititem.navigateToEditItem +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.itemListingGraph +import com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry.navigateToManualCodeEntryScreen +import com.bitwarden.authenticator.ui.authenticator.feature.navbar.AUTHENTICATOR_NAV_BAR_ROUTE +import com.bitwarden.authenticator.ui.authenticator.feature.navbar.authenticatorNavBarDestination +import com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.navigateToQrCodeScanScreen +import com.bitwarden.authenticator.ui.authenticator.feature.search.navigateToSearch +import com.bitwarden.authenticator.ui.platform.feature.settings.export.navigateToExport +import com.bitwarden.authenticator.ui.platform.feature.settings.importing.navigateToImporting +import com.bitwarden.authenticator.ui.platform.feature.tutorial.navigateToSettingsTutorial + +const val AUTHENTICATOR_GRAPH_ROUTE = "authenticator_graph" + +/** + * Navigate to the authenticator graph + */ +fun NavController.navigateToAuthenticatorGraph(navOptions: NavOptions? = null) { + navigate(AUTHENTICATOR_NAV_BAR_ROUTE, navOptions) +} + +/** + * Add the top authenticator graph to the nav graph. + */ +fun NavGraphBuilder.authenticatorGraph( + navController: NavController, + onNavigateBack: () -> Unit, +) { + navigation( + startDestination = AUTHENTICATOR_NAV_BAR_ROUTE, + route = AUTHENTICATOR_GRAPH_ROUTE, + ) { + authenticatorNavBarDestination( + onNavigateBack = onNavigateBack, + onNavigateToSearch = { navController.navigateToSearch() }, + onNavigateToQrCodeScanner = { navController.navigateToQrCodeScanScreen() }, + onNavigateToManualKeyEntry = { navController.navigateToManualCodeEntryScreen() }, + onNavigateToEditItem = { navController.navigateToEditItem(itemId = it) }, + onNavigateToExport = { navController.navigateToExport() }, + onNavigateToImport = { navController.navigateToImporting() }, + onNavigateToTutorial = { navController.navigateToSettingsTutorial() }, + ) + itemListingGraph( + navController = navController, + navigateBack = onNavigateBack, + navigateToSearch = { + navController.navigateToSearch() + }, + navigateToQrCodeScanner = { + navController.navigateToQrCodeScanScreen() + }, + navigateToManualKeyEntry = { + navController.navigateToManualCodeEntryScreen() + }, + navigateToEditItem = { + navController.navigateToEditItem(itemId = it) + }, + navigateToExport = { navController.navigateToExport() }, + navigateToImport = { navController.navigateToImporting() }, + navigateToTutorial = { navController.navigateToSettingsTutorial() }, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemNavigation.kt new file mode 100644 index 0000000000..f2512b1ffd --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemNavigation.kt @@ -0,0 +1,55 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.edititem + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.bitwarden.authenticator.ui.platform.base.util.composableWithPushTransitions + +private const val EDIT_ITEM_PREFIX = "edit_item" +private const val EDIT_ITEM_ITEM_ID = "item_id" +private const val EDIT_ITEM_ROUTE = "$EDIT_ITEM_PREFIX/{$EDIT_ITEM_ITEM_ID}" + +/** + * Class to retrieve authenticator item arguments from the [SavedStateHandle]. + * + * @property itemId ID of the item to be edited. + */ +data class EditItemArgs(val itemId: String) { + constructor(savedStateHandle: SavedStateHandle) : this( + checkNotNull(savedStateHandle[EDIT_ITEM_ITEM_ID]) as String, + ) +} + +/** + * Add the edit item screen to the nav graph. + */ +fun NavGraphBuilder.editItemDestination( + onNavigateBack: () -> Unit = { }, +) { + composableWithPushTransitions( + route = EDIT_ITEM_ROUTE, + arguments = listOf( + navArgument(EDIT_ITEM_ITEM_ID) { type = NavType.StringType }, + ), + ) { + EditItemScreen( + onNavigateBack = onNavigateBack, + ) + } +} + +/** + * Navigate to the edit item screen. + */ +fun NavController.navigateToEditItem( + itemId: String, + navOptions: NavOptions? = null, +) { + navigate( + route = "$EDIT_ITEM_PREFIX/$itemId", + navOptions = navOptions, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemScreen.kt new file mode 100644 index 0000000000..620c74dee7 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemScreen.kt @@ -0,0 +1,559 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.edititem + +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.ui.authenticator.feature.edititem.model.EditItemData +import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenTextButton +import com.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenLoadingDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.LoadingDialogState +import com.bitwarden.authenticator.ui.platform.components.dropdown.BitwardenMultiSelectButton +import com.bitwarden.authenticator.ui.platform.components.field.BitwardenPasswordField +import com.bitwarden.authenticator.ui.platform.components.field.BitwardenTextField +import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderText +import com.bitwarden.authenticator.ui.platform.components.icon.BitwardenIcon +import com.bitwarden.authenticator.ui.platform.components.model.IconData +import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.components.stepper.BitwardenStepper +import com.bitwarden.authenticator.ui.platform.components.toggle.BitwardenSwitch +import com.bitwarden.authenticator.ui.platform.theme.DEFAULT_FADE_TRANSITION_TIME_MS +import com.bitwarden.authenticator.ui.platform.theme.DEFAULT_STAY_TRANSITION_TIME_MS +import kotlinx.collections.immutable.toImmutableList + +/** + * Displays the edit authenticator item screen. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditItemScreen( + viewModel: EditItemViewModel = hiltViewModel(), + onNavigateBack: () -> Unit = { }, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val context = LocalContext.current + val resources = context.resources + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + EditItemEvent.NavigateBack -> { + onNavigateBack() + } + + is EditItemEvent.ShowToast -> { + Toast + .makeText( + context, + event.message(resources), + Toast.LENGTH_LONG, + ) + .show() + } + } + } + + EditItemDialogs( + dialogState = state.dialog, + onDismissRequest = { viewModel.trySendAction(EditItemAction.DismissDialog) }, + ) + + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource( + id = R.string.edit_item, + ), + scrollBehavior = scrollBehavior, + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { + viewModel.trySendAction(EditItemAction.CancelClick) + } + }, + actions = { + BitwardenTextButton( + label = stringResource(id = R.string.save), + onClick = remember(viewModel) { + { viewModel.trySendAction(EditItemAction.SaveClick) } + }, + modifier = Modifier.semantics { testTag = "SaveButton" }, + ) + }, + ) + }, + floatingActionButtonPosition = FabPosition.EndOverlay, + ) { innerPadding -> + when (val viewState = state.viewState) { + is EditItemState.ViewState.Content -> { + EditItemContent( + modifier = Modifier + .imePadding() + .padding(innerPadding), + viewState = viewState, + onIssuerNameTextChange = remember(viewModel) { + { + viewModel.trySendAction( + EditItemAction.IssuerNameTextChange(it), + ) + } + }, + onUsernameTextChange = remember(viewModel) { + { + viewModel.trySendAction( + EditItemAction.UsernameTextChange(it), + ) + } + }, + onToggleFavorite = remember(viewModel) { + { + viewModel.trySendAction( + EditItemAction.FavoriteToggleClick(it), + ) + } + }, + onTypeOptionClicked = remember(viewModel) { + { + viewModel.trySendAction( + EditItemAction.TypeOptionClick(it), + ) + } + }, + onTotpCodeTextChange = remember(viewModel) { + { + viewModel.trySendAction( + EditItemAction.TotpCodeTextChange(it), + ) + } + }, + onAlgorithmOptionClicked = remember(viewModel) { + { + viewModel.trySendAction( + EditItemAction.AlgorithmOptionClick(it), + ) + } + }, + onRefreshPeriodOptionClicked = remember(viewModel) { + { + viewModel.trySendAction( + EditItemAction.RefreshPeriodOptionClick(it), + ) + } + }, + onNumberOfDigitsChanged = remember(viewModel) { + { + viewModel.trySendAction( + EditItemAction.NumberOfDigitsOptionClick(it), + ) + } + }, + onExpandAdvancedOptionsClicked = remember(viewModel) { + { + viewModel.trySendAction( + EditItemAction.ExpandAdvancedOptionsClick, + ) + } + }, + ) + } + + is EditItemState.ViewState.Error -> { + /*ItemErrorContent(state)*/ + } + + EditItemState.ViewState.Loading -> EditItemState.ViewState.Loading + } + } +} + +/** + * The top level content UI state for the [EditItemScreen]. + */ +@Suppress("LongMethod") +@Composable +fun EditItemContent( + modifier: Modifier = Modifier, + viewState: EditItemState.ViewState.Content, + onIssuerNameTextChange: (String) -> Unit = {}, + onUsernameTextChange: (String) -> Unit = {}, + onToggleFavorite: (Boolean) -> Unit = {}, + onTypeOptionClicked: (AuthenticatorItemType) -> Unit = {}, + onTotpCodeTextChange: (String) -> Unit = {}, + onAlgorithmOptionClicked: (AuthenticatorItemAlgorithm) -> Unit = {}, + onRefreshPeriodOptionClicked: (AuthenticatorRefreshPeriodOption) -> Unit = {}, + onNumberOfDigitsChanged: (Int) -> Unit = {}, + onExpandAdvancedOptionsClicked: () -> Unit = {}, +) { + Column(modifier = modifier) { + LazyColumn { + item { + BitwardenListHeaderText( + modifier = Modifier + .padding(horizontal = 16.dp), + label = stringResource(id = R.string.information), + ) + } + + item { + Spacer(Modifier.height(8.dp)) + BitwardenTextField( + modifier = Modifier + .semantics { testTag = "NameTextField" } + .fillMaxSize() + .padding(horizontal = 16.dp), + label = stringResource(id = R.string.name), + value = viewState.itemData.issuer, + onValueChange = onIssuerNameTextChange, + singleLine = true, + ) + } + + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenPasswordField( + modifier = Modifier + .semantics { testTag = "KeyTextField" } + .fillMaxSize() + .padding(horizontal = 16.dp), + label = stringResource(id = R.string.key), + value = viewState.itemData.totpCode, + onValueChange = onTotpCodeTextChange, + singleLine = true, + capitalization = KeyboardCapitalization.Characters, + ) + } + + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + modifier = Modifier + .semantics { testTag = "UsernameTextField" } + .fillMaxWidth() + .padding(horizontal = 16.dp), + label = stringResource(id = R.string.username), + value = viewState.itemData.username.orEmpty(), + onValueChange = onUsernameTextChange, + singleLine = true, + ) + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + BitwardenSwitch( + label = stringResource(id = R.string.favorite), + isChecked = viewState.itemData.favorite, + onCheckedChange = onToggleFavorite, + modifier = Modifier + .testTag("ItemFavoriteToggle") + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + AdvancedOptions( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + viewState = viewState, + onExpandStateChange = onExpandAdvancedOptionsClicked, + onAlgorithmOptionClicked = onAlgorithmOptionClicked, + onTypeOptionClicked = onTypeOptionClicked, + onRefreshPeriodOptionClicked = onRefreshPeriodOptionClicked, + onNumberOfDigitsChanged = onNumberOfDigitsChanged, + ) + } +} + +@Suppress("LongMethod") +@Composable +private fun AdvancedOptions( + modifier: Modifier = Modifier, + viewState: EditItemState.ViewState.Content, + onExpandStateChange: () -> Unit, + onAlgorithmOptionClicked: (AuthenticatorItemAlgorithm) -> Unit, + onTypeOptionClicked: (AuthenticatorItemType) -> Unit, + onRefreshPeriodOptionClicked: (AuthenticatorRefreshPeriodOption) -> Unit, + onNumberOfDigitsChanged: (Int) -> Unit, +) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .semantics { testTag = "CollapseAdvancedOptions" } + .padding(vertical = 4.dp) + .clip(RoundedCornerShape(28.dp)) + .clickable( + indication = ripple( + bounded = true, + color = MaterialTheme.colorScheme.primary, + ), + interactionSource = remember { MutableInteractionSource() }, + ) { + onExpandStateChange() + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.advanced), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + BitwardenIcon( + iconData = IconData.Local( + iconRes = if (viewState.isAdvancedOptionsExpanded) { + R.drawable.ic_chevron_up + } else { + R.drawable.ic_chevron_down + }, + ), + contentDescription = stringResource( + id = R.string.collapse_advanced_options, + ), + tint = MaterialTheme.colorScheme.primary, + ) + } + + AnimatedVisibility( + visible = viewState.isAdvancedOptionsExpanded, + enter = fadeIn(tween(DEFAULT_FADE_TRANSITION_TIME_MS)) + + expandVertically(tween(DEFAULT_STAY_TRANSITION_TIME_MS)), + exit = fadeOut(tween(DEFAULT_FADE_TRANSITION_TIME_MS)) + + shrinkVertically(tween(DEFAULT_STAY_TRANSITION_TIME_MS)), + ) { + LazyColumn { + item { + val possibleTypeOptions = AuthenticatorItemType.entries + val typeOptionsWithStrings = + possibleTypeOptions.associateWith { it.name } + Spacer(modifier = Modifier.height(8.dp)) + BitwardenMultiSelectButton( + modifier = Modifier + .fillMaxSize() + .semantics { testTag = "OTPItemTypePicker" }, + label = stringResource(id = R.string.otp_type), + options = typeOptionsWithStrings.values.toImmutableList(), + selectedOption = viewState.itemData.type.name, + onOptionSelected = { selectedOption -> + val selectedOptionName = typeOptionsWithStrings + .entries + .first { it.value == selectedOption } + .key + + onTypeOptionClicked(selectedOptionName) + }, + ) + } + + item { + val possibleAlgorithmOptions = AuthenticatorItemAlgorithm.entries + val algorithmOptionsWithStrings = + possibleAlgorithmOptions.associateWith { it.name } + Spacer(Modifier.height(8.dp)) + BitwardenMultiSelectButton( + modifier = Modifier + .fillMaxWidth() + .semantics { testTag = "AlgorithmItemTypePicker" }, + label = stringResource(id = R.string.algorithm), + options = algorithmOptionsWithStrings.values.toImmutableList(), + selectedOption = viewState.itemData.algorithm.name, + onOptionSelected = { selectedOption -> + val selectedOptionName = algorithmOptionsWithStrings + .entries + .first { it.value == selectedOption } + .key + + onAlgorithmOptionClicked(selectedOptionName) + }, + ) + } + + item { + val possibleRefreshPeriodOptions = AuthenticatorRefreshPeriodOption.entries + val refreshPeriodOptionsWithStrings = possibleRefreshPeriodOptions + .associateWith { + stringResource( + id = R.string.refresh_period_seconds, + it.seconds, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + BitwardenMultiSelectButton( + modifier = Modifier + .fillMaxWidth() + .semantics { testTag = "RefreshPeriodItemTypePicker" }, + label = stringResource(id = R.string.refresh_period), + options = refreshPeriodOptionsWithStrings.values.toImmutableList(), + selectedOption = stringResource( + id = R.string.refresh_period_seconds, + viewState.itemData.refreshPeriod.seconds, + ), + onOptionSelected = remember(viewState) { + { selectedOption -> + val selectedOptionName = refreshPeriodOptionsWithStrings + .entries + .first { it.value == selectedOption } + .key + + onRefreshPeriodOptionClicked(selectedOptionName) + } + }, + ) + } + + item { + Spacer(modifier = Modifier.height(8.dp)) + DigitsCounterItem( + digits = viewState.itemData.digits, + onDigitsCounterChange = onNumberOfDigitsChanged, + minValue = viewState.minDigitsAllowed, + maxValue = viewState.maxDigitsAllowed, + ) + } + } + } + } +} + +@Composable +private fun EditItemDialogs( + dialogState: EditItemState.DialogState?, + onDismissRequest: () -> Unit, +) { + when (dialogState) { + is EditItemState.DialogState.Generic -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialogState.title, + message = dialogState.message, + ), + onDismissRequest = onDismissRequest, + ) + } + + is EditItemState.DialogState.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown(dialogState.message), + ) + } + + null -> Unit + } +} + +@Composable +private fun DigitsCounterItem( + digits: Int, + onDigitsCounterChange: (Int) -> Unit, + minValue: Int, + maxValue: Int, +) { + BitwardenStepper( + label = stringResource(id = R.string.number_of_digits), + value = digits.coerceIn(minValue, maxValue), + range = minValue..maxValue, + onValueChange = onDigitsCounterChange, + increaseButtonTestTag = "DigitsIncreaseButton", + decreaseButtonTestTag = "DigitsDecreaseButton", + modifier = Modifier.testTag("DigitsValueLabel"), + ) +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +private fun EditItemContentExpandedOptionsPreview() { + EditItemContent( + viewState = EditItemState.ViewState.Content( + isAdvancedOptionsExpanded = true, + itemData = EditItemData( + refreshPeriod = AuthenticatorRefreshPeriodOption.THIRTY, + totpCode = "123456", + type = AuthenticatorItemType.TOTP, + username = "account name", + issuer = "issuer", + algorithm = AuthenticatorItemAlgorithm.SHA1, + digits = 6, + favorite = true, + ), + minDigitsAllowed = 5, + maxDigitsAllowed = 10, + ), + ) +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +private fun EditItemContentCollapsedOptionsPreview() { + EditItemContent( + viewState = EditItemState.ViewState.Content( + isAdvancedOptionsExpanded = false, + itemData = EditItemData( + refreshPeriod = AuthenticatorRefreshPeriodOption.THIRTY, + totpCode = "123456", + type = AuthenticatorItemType.TOTP, + username = "account name", + issuer = "issuer", + algorithm = AuthenticatorItemAlgorithm.SHA1, + digits = 6, + favorite = false, + ), + minDigitsAllowed = 5, + maxDigitsAllowed = 10, + ), + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemViewModel.kt new file mode 100644 index 0000000000..40a33fb693 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemViewModel.kt @@ -0,0 +1,559 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.edititem + +import android.os.Parcelable +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.toUpperCase +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult +import com.bitwarden.authenticator.data.platform.repository.model.DataState +import com.bitwarden.authenticator.data.platform.repository.util.takeUntilLoaded +import com.bitwarden.authenticator.ui.authenticator.feature.edititem.AuthenticatorRefreshPeriodOption.entries +import com.bitwarden.authenticator.ui.authenticator.feature.edititem.EditItemState.Companion.MAX_ALLOWED_CODE_DIGITS +import com.bitwarden.authenticator.ui.authenticator.feature.edititem.EditItemState.Companion.MIN_ALLOWED_CODE_DIGITS +import com.bitwarden.authenticator.ui.authenticator.feature.edititem.model.EditItemData +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.base.util.concat +import com.bitwarden.authenticator.ui.platform.base.util.isBase32 +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * View model responsible for handling user interaction with the edit authenticator item screen. + */ +@Suppress("TooManyFunctions") +@HiltViewModel +class EditItemViewModel @Inject constructor( + private val authenticatorRepository: AuthenticatorRepository, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] ?: EditItemState( + itemId = EditItemArgs(savedStateHandle).itemId, + viewState = EditItemState.ViewState.Loading, + dialog = null, + ), +) { + + init { + authenticatorRepository.getItemStateFlow(state.itemId) + .takeUntilLoaded() + .map { itemState -> EditItemAction.Internal.EditItemDataReceive(itemState) } + .onEach(::sendAction) + .launchIn(viewModelScope) + } + + override fun handleAction(action: EditItemAction) { + when (action) { + is EditItemAction.DismissDialog -> handleDismissDialogClick() + is EditItemAction.Internal -> handleInternalAction(action) + is EditItemAction.AlgorithmOptionClick -> handleAlgorithmOptionClick(action) + is EditItemAction.CancelClick -> handleCancelClick() + is EditItemAction.TypeOptionClick -> handleTypeOptionClick(action) + is EditItemAction.IssuerNameTextChange -> handleIssuerNameTextChange(action) + is EditItemAction.UsernameTextChange -> handleIssuerTextChange(action) + is EditItemAction.FavoriteToggleClick -> handleFavoriteToggleClick(action) + is EditItemAction.RefreshPeriodOptionClick -> handlePeriodTextChange(action) + is EditItemAction.TotpCodeTextChange -> handleTotpCodeTextChange(action) + is EditItemAction.NumberOfDigitsOptionClick -> handleNumberOfDigitsOptionChange(action) + is EditItemAction.SaveClick -> handleSaveClick() + is EditItemAction.ExpandAdvancedOptionsClick -> handleExpandAdvancedOptionsClick() + } + } + + private fun handleExpandAdvancedOptionsClick() { + updateContent { currentContent -> + currentContent.copy( + isAdvancedOptionsExpanded = currentContent.isAdvancedOptionsExpanded.not(), + ) + } + } + + private fun handleSaveClick() = onContent { content -> + if (content.itemData.issuer.isBlank()) { + mutableStateFlow.update { + it.copy( + dialog = EditItemState.DialogState.Generic( + title = R.string.an_error_has_occurred.asText(), + message = R.string.validation_field_required.asText(R.string.name.asText()), + ), + ) + } + return@onContent + } else if (content.itemData.totpCode.isBlank()) { + mutableStateFlow.update { + it.copy( + dialog = EditItemState.DialogState.Generic( + title = R.string.an_error_has_occurred.asText(), + message = R.string.validation_field_required.asText(R.string.key.asText()), + ), + ) + } + return@onContent + } else if (!content.itemData.totpCode.isBase32()) { + mutableStateFlow.update { + it.copy( + dialog = EditItemState.DialogState.Generic( + title = R.string.an_error_has_occurred.asText(), + message = R.string.key_is_invalid.asText(), + ), + ) + } + return@onContent + } + + mutableStateFlow.update { + it.copy( + dialog = EditItemState.DialogState.Loading( + R.string.saving.asText(), + ), + ) + } + viewModelScope.launch { + val result = authenticatorRepository.createItem( + AuthenticatorItemEntity( + id = state.itemId, + key = content.itemData.totpCode.trim(), + accountName = content.itemData.username?.trim(), + type = content.itemData.type, + algorithm = content.itemData.algorithm, + period = content.itemData.refreshPeriod.seconds, + digits = content.itemData.digits, + issuer = content.itemData.issuer.trim(), + favorite = content.itemData.favorite, + ), + ) + trySendAction(EditItemAction.Internal.UpdateItemResult(result)) + } + } + + private fun handleNumberOfDigitsOptionChange(action: EditItemAction.NumberOfDigitsOptionClick) { + updateItemData { currentItemData -> + currentItemData.copy( + digits = action.digits, + ) + } + } + + private fun handleIssuerNameTextChange(action: EditItemAction.IssuerNameTextChange) { + updateItemData { currentItemData -> + currentItemData.copy( + issuer = action.issuerName, + ) + } + } + + private fun handleIssuerTextChange(action: EditItemAction.UsernameTextChange) { + updateItemData { currentItemData -> + currentItemData.copy( + username = action.username, + ) + } + } + + private fun handleFavoriteToggleClick(action: EditItemAction.FavoriteToggleClick) { + updateItemData { currentItemData -> + currentItemData.copy( + favorite = action.favorite, + ) + } + } + + private fun handleTotpCodeTextChange(action: EditItemAction.TotpCodeTextChange) { + updateItemData { currentItemData -> + currentItemData.copy( + totpCode = action.totpCode, + ) + } + } + + private fun handlePeriodTextChange(action: EditItemAction.RefreshPeriodOptionClick) { + updateItemData { currentItemData -> + currentItemData.copy( + refreshPeriod = action.period, + ) + } + } + + private fun handleAlgorithmOptionClick(action: EditItemAction.AlgorithmOptionClick) { + updateItemData { currentItemData -> + currentItemData.copy( + algorithm = action.algorithmOption, + ) + } + } + + private fun handleCancelClick() { + sendEvent(EditItemEvent.NavigateBack) + } + + private fun handleTypeOptionClick(action: EditItemAction.TypeOptionClick) { + updateItemData { currentItemData -> + currentItemData.copy( + type = action.typeOption, + ) + } + } + + private fun handleDismissDialogClick() { + mutableStateFlow.update { it.copy(dialog = null) } + } + + private fun handleInternalAction(action: EditItemAction.Internal) { + when (action) { + is EditItemAction.Internal.EditItemDataReceive -> handleItemDataReceive(action) + is EditItemAction.Internal.UpdateItemResult -> handleUpdateItemResultReceive(action) + } + } + + private fun handleUpdateItemResultReceive(action: EditItemAction.Internal.UpdateItemResult) { + when (action.result) { + CreateItemResult.Error -> mutableStateFlow.update { + it.copy( + dialog = EditItemState.DialogState.Generic( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + ) + } + + CreateItemResult.Success -> { + sendEvent(EditItemEvent.ShowToast(R.string.item_saved.asText())) + sendEvent(EditItemEvent.NavigateBack) + } + } + } + + @Suppress("LongMethod") + private fun handleItemDataReceive(action: EditItemAction.Internal.EditItemDataReceive) { + when (val itemState = action.itemDataState) { + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + viewState = EditItemState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + is DataState.Loaded -> { + val expandAdvancedOptions = + (state.viewState as? EditItemState.ViewState.Content)?.isAdvancedOptionsExpanded + ?: false + mutableStateFlow.update { + it.copy( + viewState = itemState + .data + ?.toViewState(expandAdvancedOptions) + ?: EditItemState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + DataState.Loading -> { + mutableStateFlow.update { + it.copy( + viewState = EditItemState.ViewState.Loading, + ) + } + } + + is DataState.NoNetwork -> { + mutableStateFlow.update { + it.copy( + viewState = EditItemState.ViewState.Error( + message = R.string.internet_connection_required_title + .asText() + .concat(R.string.internet_connection_required_message.asText()), + ), + ) + } + } + + is DataState.Pending -> { + val expandAdvancedOptions = + (state.viewState as? EditItemState.ViewState.Content)?.isAdvancedOptionsExpanded + ?: false + mutableStateFlow.update { + it.copy( + viewState = itemState + .data + ?.toViewState(expandAdvancedOptions) + ?: EditItemState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + } + } + + //region Utility Functions + private inline fun onContent( + crossinline block: (EditItemState.ViewState.Content) -> Unit, + ) { + (state.viewState as? EditItemState.ViewState.Content)?.let(block) + } + + private inline fun updateContent( + crossinline block: ( + EditItemState.ViewState.Content, + ) -> EditItemState.ViewState.Content?, + ) { + val currentState = state.viewState + val updatedContent = (currentState as? EditItemState.ViewState.Content) + ?.let(block) + ?: return + mutableStateFlow.update { it.copy(viewState = updatedContent) } + } + + private inline fun updateItemData( + crossinline block: (EditItemData) -> EditItemData?, + ) { + updateContent { currentContent -> + val currentItemData = currentContent.itemData + val updatedItemData = currentItemData + .let(block) + ?: currentContent.itemData + + currentContent.copy(itemData = updatedItemData) + } + } + + private fun AuthenticatorItemEntity.toViewState( + isAdvancedOptionsExpanded: Boolean, + ) = EditItemState.ViewState.Content( + isAdvancedOptionsExpanded = isAdvancedOptionsExpanded, + minDigitsAllowed = MIN_ALLOWED_CODE_DIGITS, + maxDigitsAllowed = MAX_ALLOWED_CODE_DIGITS, + itemData = EditItemData( + refreshPeriod = AuthenticatorRefreshPeriodOption.fromSeconds(period) + ?: AuthenticatorRefreshPeriodOption.THIRTY, + totpCode = key.toUpperCase(Locale.current), + type = type, + username = accountName, + issuer = issuer, + algorithm = algorithm, + digits = digits, + favorite = favorite, + ), + ) + //endregion Utility Functions +} + +/** + * Represents the state for displaying an editable item in the authenticator. + * + * @property itemId ID of the item displayed. + * @property viewState Current state of the [EditItemScreen]. + * @property dialog State of the dialog on the [EditItemScreen]. `null` if no dialog is shown. + */ +@Parcelize +data class EditItemState( + val itemId: String, + val viewState: ViewState, + val dialog: DialogState?, +) : Parcelable { + + /** + * Represents the different view states for the [EditItemScreen]. + */ + @Parcelize + sealed class ViewState : Parcelable { + + /** + * Represents an error state for the [EditItemScreen]. + */ + @Parcelize + data class Error( + val message: Text, + ) : ViewState() + + /** + * Represents the [EditItemScreen] content is being processed. + */ + @Parcelize + data object Loading : ViewState() + + /** + * Represents a loaded content state for the [EditItemScreen]. + */ + @Parcelize + data class Content( + val isAdvancedOptionsExpanded: Boolean, + val minDigitsAllowed: Int, + val maxDigitsAllowed: Int, + val itemData: EditItemData, + ) : ViewState() + } + + /** + * Displays a dialog on the [EditItemScreen]. + */ + sealed class DialogState : Parcelable { + + /** + * Displays a generic dialog to the user. + */ + @Parcelize + data class Generic( + val title: Text, + val message: Text, + ) : DialogState() + + /** + * Displays the loading dialog with a [message]. + */ + @Parcelize + data class Loading( + val message: Text, + ) : DialogState() + } + + @Suppress("UndocumentedPublicClass") + companion object { + const val MIN_ALLOWED_CODE_DIGITS = 5 + const val MAX_ALLOWED_CODE_DIGITS = 10 + } +} + +/** + * Represents a set of events related to editing an authenticator item. + */ +sealed class EditItemEvent { + + /** + * Navigates back. + */ + data object NavigateBack : EditItemEvent() + + /** + * Show a toast with the given [message]. + */ + data class ShowToast(val message: Text) : EditItemEvent() +} + +/** + * Represents a set of actions related to editing an authenticator item. + * Each subclass of this sealed class denotes a distinct action that can be taken. + */ +sealed class EditItemAction { + /** + * The user has clicked the save button. + */ + data object SaveClick : EditItemAction() + + /** + * The user has clicked the close button. + */ + data object CancelClick : EditItemAction() + + /** + * The user has dismissed the displayed item. + */ + data object DismissDialog : EditItemAction() + + /** + * The user has changed the account name text. + */ + data class IssuerNameTextChange(val issuerName: String) : EditItemAction() + + /** + * The user has changed the issue text. + */ + data class UsernameTextChange(val username: String) : EditItemAction() + + /** + * The user toggled the favorite checkbox. + */ + data class FavoriteToggleClick(val favorite: Boolean) : EditItemAction() + + /** + * The user has selected an Item Type option. + */ + data class TypeOptionClick(val typeOption: AuthenticatorItemType) : EditItemAction() + + /** + * The user has changed the TOTP code text. + */ + data class TotpCodeTextChange(val totpCode: String) : EditItemAction() + + /** + * The user has selected a refresh period option. + */ + data class RefreshPeriodOptionClick( + val period: AuthenticatorRefreshPeriodOption, + ) : EditItemAction() + + /** + * The user has selected an Algorithm option. + */ + data class AlgorithmOptionClick( + val algorithmOption: AuthenticatorItemAlgorithm, + ) : EditItemAction() + + /** + * The user has selected the number of verification code digits. + */ + data class NumberOfDigitsOptionClick( + val digits: Int, + ) : EditItemAction() + + /** + * The user has clicked to expand advanced OTP options. + */ + data object ExpandAdvancedOptionsClick : EditItemAction() + + /** + * Models actions the [EditItemScreen] itself may send. + */ + sealed class Internal : EditItemAction() { + + /** + * Indicates that the item data has been received. + */ + data class EditItemDataReceive( + val itemDataState: DataState, + ) : Internal() + + /** + * Indicates a update item result has been received. + */ + data class UpdateItemResult(val result: CreateItemResult) : Internal() + } +} + +/** + * Enum class representing refresh period options. + */ +enum class AuthenticatorRefreshPeriodOption(val seconds: Int) { + THIRTY(seconds = 30), + SIXTY(seconds = 60), + NINETY(seconds = 90), + ; + + @Suppress("UndocumentedPublicClass") + companion object { + /** + * Returns a [AuthenticatorRefreshPeriodOption] with the provided [seconds], or null. + */ + fun fromSeconds(seconds: Int) = entries.find { it.seconds == seconds } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/model/EditItemData.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/model/EditItemData.kt new file mode 100644 index 0000000000..369ddd60c0 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/model/EditItemData.kt @@ -0,0 +1,29 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.edititem.model + +import android.os.Parcelable +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.ui.authenticator.feature.edititem.AuthenticatorRefreshPeriodOption +import kotlinx.parcelize.Parcelize + +/** + * The data relating to the verification code. + * + * @property refreshPeriod The period for the verification code. + * @property totpCode The totp code for the item. + * @property username Account or username for this item. + * @property issuer Name of the item provider. + * @property algorithm Hashing algorithm used with the item. + * @property digits Number of digits in the verification code. + */ +@Parcelize +data class EditItemData( + val refreshPeriod: AuthenticatorRefreshPeriodOption, + val totpCode: String, + val type: AuthenticatorItemType, + val username: String?, + val issuer: String, + val algorithm: AuthenticatorItemAlgorithm, + val digits: Int, + val favorite: Boolean, +) : Parcelable diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/FirstTimeSyncSnackbarHost.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/FirstTimeSyncSnackbarHost.kt new file mode 100644 index 0000000000..e99a8d5515 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/FirstTimeSyncSnackbarHost.kt @@ -0,0 +1,69 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.R + +/** + * Show a snackbar that says "Account synced from Bitwarden app" with a close action. + * + * @param state Snackbar state used to show/hide. The message and title from this state are unused. + */ +@Composable +fun FirstTimeSyncSnackbarHost( + state: SnackbarHostState, +) { + SnackbarHost( + hostState = state, + snackbar = { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + .shadow(elevation = 6.dp) + .background( + color = MaterialTheme.colorScheme.inverseSurface, + shape = RoundedCornerShape(8.dp), + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .padding(16.dp) + .weight(1f, fill = true), + text = stringResource(R.string.account_synced_from_bitwarden_app), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.inverseOnSurface, + ) + IconButton( + onClick = { state.currentSnackbarData?.dismiss() }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = stringResource(id = R.string.close), + tint = MaterialTheme.colorScheme.inverseOnSurface, + modifier = Modifier + .size(24.dp), + ) + } + } + }, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt new file mode 100644 index 0000000000..be8e0767c2 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt @@ -0,0 +1,82 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.navigation +import com.bitwarden.authenticator.ui.authenticator.feature.edititem.editItemDestination +import com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry.manualCodeEntryDestination +import com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry.navigateToManualCodeEntryScreen +import com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.navigateToQrCodeScanScreen +import com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.qrCodeScanDestination +import com.bitwarden.authenticator.ui.authenticator.feature.search.itemSearchDestination +import com.bitwarden.authenticator.ui.platform.feature.settings.settingsGraph + +const val ITEM_LISTING_GRAPH_ROUTE = "item_listing_graph" + +/** + * Add the item listing graph to the nav graph. + */ +@Suppress("LongParameterList") +fun NavGraphBuilder.itemListingGraph( + navController: NavController, + navigateBack: () -> Unit, + navigateToSearch: () -> Unit, + navigateToQrCodeScanner: () -> Unit, + navigateToManualKeyEntry: () -> Unit, + navigateToEditItem: (String) -> Unit, + navigateToExport: () -> Unit, + navigateToImport: () -> Unit, + navigateToTutorial: () -> Unit, +) { + navigation( + route = ITEM_LISTING_GRAPH_ROUTE, + startDestination = ITEM_LIST_ROUTE, + ) { + itemListingDestination( + onNavigateBack = navigateBack, + onNavigateToSearch = navigateToSearch, + onNavigateToQrCodeScanner = navigateToQrCodeScanner, + onNavigateToManualKeyEntry = navigateToManualKeyEntry, + onNavigateToEditItemScreen = navigateToEditItem, + ) + editItemDestination( + onNavigateBack = { navController.popBackStack() }, + ) + itemSearchDestination( + onNavigateBack = { navController.popBackStack() }, + ) + qrCodeScanDestination( + onNavigateBack = { navController.popBackStack() }, + onNavigateToManualCodeEntryScreen = { + navController.popBackStack() + navController.navigateToManualCodeEntryScreen() + }, + ) + manualCodeEntryDestination( + onNavigateBack = { navController.popBackStack() }, + onNavigateToQrCodeScreen = { + navController.popBackStack() + navController.navigateToQrCodeScanScreen() + }, + ) + settingsGraph( + navController = navController, + onNavigateToExport = navigateToExport, + onNavigateToImport = navigateToImport, + onNavigateToTutorial = navigateToTutorial, + ) + } +} + +/** + * Navigate to the item listing graph. + */ +fun NavController.navigateToItemListGraph( + navOptions: NavOptions? = null, +) { + navigate( + route = ITEM_LISTING_GRAPH_ROUTE, + navOptions = navOptions, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingNavigation.kt new file mode 100644 index 0000000000..95d34b29cb --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingNavigation.kt @@ -0,0 +1,29 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting + +import androidx.navigation.NavGraphBuilder +import com.bitwarden.authenticator.ui.platform.base.util.composableWithPushTransitions + +const val ITEM_LIST_ROUTE = "item_list" + +/** + * Add the item listing screen to the nav graph. + */ +fun NavGraphBuilder.itemListingDestination( + onNavigateBack: () -> Unit = { }, + onNavigateToSearch: () -> Unit, + onNavigateToQrCodeScanner: () -> Unit = { }, + onNavigateToManualKeyEntry: () -> Unit = { }, + onNavigateToEditItemScreen: (id: String) -> Unit = { }, +) { + composableWithPushTransitions( + route = ITEM_LIST_ROUTE, + ) { + ItemListingScreen( + onNavigateBack = onNavigateBack, + onNavigateToSearch = onNavigateToSearch, + onNavigateToQrCodeScanner = onNavigateToQrCodeScanner, + onNavigateToManualKeyEntry = onNavigateToManualKeyEntry, + onNavigateToEditItemScreen = onNavigateToEditItemScreen, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt new file mode 100644 index 0000000000..2ca2bf1043 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt @@ -0,0 +1,794 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.ItemListingExpandableFabAction +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.SharedCodesDisplayState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VaultDropdownMenuAction +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem +import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenMediumTopAppBar +import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.authenticator.ui.platform.components.appbar.action.BitwardenSearchActionItem +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton +import com.bitwarden.authenticator.ui.platform.components.card.BitwardenActionCard +import com.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenLoadingDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenTwoButtonDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.LoadingDialogState +import com.bitwarden.authenticator.ui.platform.components.fab.ExpandableFabIcon +import com.bitwarden.authenticator.ui.platform.components.fab.ExpandableFloatingActionButton +import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderText +import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderTextWithSupportLabel +import com.bitwarden.authenticator.ui.platform.components.model.IconResource +import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.components.util.rememberVectorPainter +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import com.bitwarden.authenticator.ui.platform.manager.permissions.PermissionsManager +import com.bitwarden.authenticator.ui.platform.theme.LocalIntentManager +import com.bitwarden.authenticator.ui.platform.theme.LocalPermissionsManager +import com.bitwarden.authenticator.ui.platform.theme.Typography + +/** + * Displays the item listing screen. + */ +@Suppress("LongMethod", "CyclomaticComplexMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ItemListingScreen( + viewModel: ItemListingViewModel = hiltViewModel(), + intentManager: IntentManager = LocalIntentManager.current, + permissionsManager: PermissionsManager = LocalPermissionsManager.current, + onNavigateBack: () -> Unit, + onNavigateToSearch: () -> Unit, + onNavigateToQrCodeScanner: () -> Unit, + onNavigateToManualKeyEntry: () -> Unit, + onNavigateToEditItemScreen: (id: String) -> Unit, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val context = LocalContext.current + var shouldShowPermissionDialog by rememberSaveable { mutableStateOf(false) } + val launcher = permissionsManager.getLauncher { isGranted -> + if (isGranted) { + viewModel.trySendAction(ItemListingAction.ScanQrCodeClick) + } else { + shouldShowPermissionDialog = true + } + } + val snackbarHostState = remember { SnackbarHostState() } + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is ItemListingEvent.NavigateBack -> onNavigateBack() + is ItemListingEvent.NavigateToSearch -> onNavigateToSearch() + is ItemListingEvent.NavigateToQrCodeScanner -> onNavigateToQrCodeScanner() + is ItemListingEvent.NavigateToManualAddItem -> onNavigateToManualKeyEntry() + is ItemListingEvent.ShowToast -> { + Toast + .makeText( + context, + event.message(context.resources), + Toast.LENGTH_LONG, + ) + .show() + } + + is ItemListingEvent.NavigateToEditItem -> onNavigateToEditItemScreen(event.id) + is ItemListingEvent.NavigateToAppSettings -> { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = Uri.parse("package:" + context.packageName) + + intentManager.startActivity(intent = intent) + } + + ItemListingEvent.NavigateToBitwardenListing -> { + intentManager.launchUri( + "https://play.google.com/store/apps/details?id=com.x8bit.bitwarden".toUri(), + ) + } + + ItemListingEvent.NavigateToBitwardenSettings -> { + intentManager.startMainBitwardenAppAccountSettings() + } + + is ItemListingEvent.ShowFirstTimeSyncSnackbar -> { + // Message property is overridden by FirstTimeSyncSnackbarHost: + snackbarHostState.showSnackbar("") + } + } + } + + if (shouldShowPermissionDialog) { + BitwardenTwoButtonDialog( + message = stringResource(id = R.string.enable_camera_permission_to_use_the_scanner), + confirmButtonText = stringResource(id = R.string.settings), + dismissButtonText = stringResource(id = R.string.no_thanks), + onConfirmClick = remember(viewModel) { + { viewModel.trySendAction(ItemListingAction.SettingsClick) } + }, + onDismissClick = { shouldShowPermissionDialog = false }, + onDismissRequest = { shouldShowPermissionDialog = false }, + title = null, + ) + } + + ItemListingDialogs( + dialog = state.dialog, + onDismissRequest = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.DialogDismiss, + ) + } + }, + onConfirmDeleteClick = remember(viewModel) { + { itemId -> + viewModel.trySendAction( + ItemListingAction.ConfirmDeleteClick(itemId = itemId), + ) + } + }, + ) + + when (val currentState = state.viewState) { + is ItemListingState.ViewState.Content -> { + ItemListingContent( + state = currentState, + snackbarHostState = snackbarHostState, + scrollBehavior = scrollBehavior, + onNavigateToSearch = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.SearchClick, + ) + } + }, + onScanQrCodeClick = remember(viewModel) { + { + launcher.launch(Manifest.permission.CAMERA) + } + }, + onEnterSetupKeyClick = remember(viewModel) { + { + viewModel.trySendAction(ItemListingAction.EnterSetupKeyClick) + } + }, + onItemClick = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.ItemClick(it), + ) + } + }, + onDropdownMenuClick = remember(viewModel) { + { action, item -> + viewModel.trySendAction( + ItemListingAction.DropdownMenuClick( + menuAction = action, + item = item, + ), + ) + } + }, + onDownloadBitwardenClick = remember(viewModel) { + { + viewModel.trySendAction(ItemListingAction.DownloadBitwardenClick) + } + }, + onDismissDownloadBitwardenClick = remember(viewModel) { + { + viewModel.trySendAction(ItemListingAction.DownloadBitwardenDismiss) + } + }, + onSyncWithBitwardenClick = remember(viewModel) { + { + viewModel.trySendAction(ItemListingAction.SyncWithBitwardenClick) + } + }, + onDismissSyncWithBitwardenClick = remember(viewModel) { + { + viewModel.trySendAction(ItemListingAction.SyncWithBitwardenDismiss) + } + }, + ) + } + + ItemListingState.ViewState.Loading -> Unit + is ItemListingState.ViewState.NoItems, + -> { + EmptyItemListingContent( + actionCardState = currentState.actionCard, + appTheme = state.appTheme, + scrollBehavior = scrollBehavior, + onAddCodeClick = remember(viewModel) { + { + launcher.launch(Manifest.permission.CAMERA) + } + }, + onScanQuCodeClick = remember(viewModel) { + { + launcher.launch(Manifest.permission.CAMERA) + } + }, + onEnterSetupKeyClick = remember(viewModel) { + { + viewModel.trySendAction(ItemListingAction.EnterSetupKeyClick) + } + }, + onDownloadBitwardenClick = remember(viewModel) { + { + viewModel.trySendAction(ItemListingAction.DownloadBitwardenClick) + } + }, + onDismissDownloadBitwardenClick = remember(viewModel) { + { + viewModel.trySendAction(ItemListingAction.DownloadBitwardenDismiss) + } + }, + onSyncWithBitwardenClick = remember(viewModel) { + { + viewModel.trySendAction(ItemListingAction.SyncWithBitwardenClick) + } + }, + onDismissSyncWithBitwardenClick = remember(viewModel) { + { + viewModel.trySendAction(ItemListingAction.SyncWithBitwardenDismiss) + } + }, + ) + } + } +} + +@Composable +private fun ItemListingDialogs( + dialog: ItemListingState.DialogState?, + onDismissRequest: () -> Unit, + onConfirmDeleteClick: (itemId: String) -> Unit, +) { + when (dialog) { + ItemListingState.DialogState.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown( + text = R.string.syncing.asText(), + ), + ) + } + + is ItemListingState.DialogState.Error -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialog.title, + message = dialog.message, + ), + onDismissRequest = onDismissRequest, + ) + } + + is ItemListingState.DialogState.DeleteConfirmationPrompt -> { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.delete), + message = dialog.message(), + confirmButtonText = stringResource(id = R.string.ok), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { + onConfirmDeleteClick(dialog.itemId) + }, + onDismissClick = onDismissRequest, + onDismissRequest = onDismissRequest, + ) + } + + null -> Unit + } +} + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ItemListingContent( + state: ItemListingState.ViewState.Content, + snackbarHostState: SnackbarHostState, + scrollBehavior: TopAppBarScrollBehavior, + onNavigateToSearch: () -> Unit, + onScanQrCodeClick: () -> Unit, + onEnterSetupKeyClick: () -> Unit, + onItemClick: (String) -> Unit, + onDropdownMenuClick: (VaultDropdownMenuAction, VerificationCodeDisplayItem) -> Unit, + onDownloadBitwardenClick: () -> Unit, + onDismissDownloadBitwardenClick: () -> Unit, + onSyncWithBitwardenClick: () -> Unit, + onDismissSyncWithBitwardenClick: () -> Unit, +) { + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenMediumTopAppBar( + title = stringResource(id = R.string.verification_codes), + scrollBehavior = scrollBehavior, + actions = { + BitwardenSearchActionItem( + contentDescription = stringResource(id = R.string.search_codes), + onClick = onNavigateToSearch, + ) + }, + ) + }, + floatingActionButton = { + ExpandableFloatingActionButton( + modifier = Modifier + .semantics { testTag = "AddItemButton" } + .padding(bottom = 16.dp), + label = R.string.add_item.asText(), + items = listOf( + ItemListingExpandableFabAction.ScanQrCode( + label = R.string.scan_a_qr_code.asText(), + icon = IconResource( + iconPainter = painterResource(id = R.drawable.ic_camera), + contentDescription = stringResource(id = R.string.scan_a_qr_code), + testTag = "ScanQRCodeButton", + ), + onScanQrCodeClick = onScanQrCodeClick, + ), + ItemListingExpandableFabAction.EnterSetupKey( + label = R.string.enter_key_manually.asText(), + icon = IconResource( + iconPainter = painterResource(id = R.drawable.ic_keyboard_24px), + contentDescription = stringResource(id = R.string.enter_key_manually), + testTag = "EnterSetupKeyButton", + ), + onEnterSetupKeyClick = onEnterSetupKeyClick, + ), + ), + expandableFabIcon = ExpandableFabIcon( + iconData = IconResource( + iconPainter = painterResource(id = R.drawable.ic_plus), + contentDescription = stringResource(id = R.string.add_item), + testTag = "AddItemButton", + ), + iconRotation = 45f, + ), + ) + }, + floatingActionButtonPosition = FabPosition.EndOverlay, + snackbarHost = { FirstTimeSyncSnackbarHost(state = snackbarHostState) }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + LazyColumn { + item { + when (state.actionCard) { + ItemListingState.ActionCardState.DownloadBitwardenApp -> + DownloadBitwardenActionCard( + modifier = Modifier.padding(horizontal = 16.dp), + onDownloadBitwardenClick = onDownloadBitwardenClick, + onDismissClick = onDismissDownloadBitwardenClick, + ) + + ItemListingState.ActionCardState.SyncWithBitwarden -> + SyncWithBitwardenActionCard( + modifier = Modifier.padding(16.dp), + onSyncWithBitwardenClick = onSyncWithBitwardenClick, + onDismissClick = onDismissSyncWithBitwardenClick, + ) + + ItemListingState.ActionCardState.None -> Unit + } + } + if (state.favoriteItems.isNotEmpty()) { + item { + BitwardenListHeaderTextWithSupportLabel( + label = stringResource(id = R.string.favorites), + supportingLabel = state.favoriteItems.count().toString(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + + item { + Spacer(modifier = Modifier.height(4.dp)) + } + + items(state.favoriteItems) { + VaultVerificationCodeItem( + authCode = it.authCode, + primaryLabel = it.title, + secondaryLabel = it.subtitle, + periodSeconds = it.periodSeconds, + timeLeftSeconds = it.timeLeftSeconds, + alertThresholdSeconds = it.alertThresholdSeconds, + startIcon = it.startIcon, + onItemClick = { onItemClick(it.authCode) }, + onDropdownMenuClick = { action -> + onDropdownMenuClick(action, it) + }, + showMoveToBitwarden = it.showMoveToBitwarden, + allowLongPress = it.allowLongPressActions, + modifier = Modifier.fillMaxWidth(), + ) + } + + item { + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier + .fillMaxWidth() + .padding(all = 16.dp), + ) + } + } + + if (state.shouldShowLocalHeader) { + item { + BitwardenListHeaderText( + label = stringResource(id = R.string.local_codes), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + + items(state.itemList) { + VaultVerificationCodeItem( + authCode = it.authCode, + primaryLabel = it.title, + secondaryLabel = it.subtitle, + periodSeconds = it.periodSeconds, + timeLeftSeconds = it.timeLeftSeconds, + alertThresholdSeconds = it.alertThresholdSeconds, + startIcon = it.startIcon, + onItemClick = { onItemClick(it.authCode) }, + onDropdownMenuClick = { action -> + onDropdownMenuClick(action, it) + }, + showMoveToBitwarden = it.showMoveToBitwarden, + allowLongPress = it.allowLongPressActions, + modifier = Modifier.fillMaxWidth(), + ) + } + + // If there are any items in the local lists, add a spacer between + // local codes and shared codes: + if (state.itemList.isNotEmpty() || state.favoriteItems.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + } + } + + when (state.sharedItems) { + is SharedCodesDisplayState.Codes -> { + items(state.sharedItems.sections) { section -> + BitwardenListHeaderText( + label = section.label(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + section.codes.forEach { + VaultVerificationCodeItem( + authCode = it.authCode, + primaryLabel = it.title, + secondaryLabel = it.subtitle, + periodSeconds = it.periodSeconds, + timeLeftSeconds = it.timeLeftSeconds, + alertThresholdSeconds = it.alertThresholdSeconds, + startIcon = it.startIcon, + onItemClick = { onItemClick(it.authCode) }, + onDropdownMenuClick = { action -> + onDropdownMenuClick(action, it) + }, + showMoveToBitwarden = it.showMoveToBitwarden, + allowLongPress = it.allowLongPressActions, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + SharedCodesDisplayState.Error -> item { + Text( + text = stringResource(R.string.shared_codes_error), + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + // Add a spacer item to prevent the FAB from hiding verification codes at the + // bottom of the list + item { + Spacer(Modifier.height(72.dp)) + } + } + } + } +} + +/** + * Displays the item listing screen with no existing items. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmptyItemListingContent( + modifier: Modifier = Modifier, + actionCardState: ItemListingState.ActionCardState, + appTheme: AppTheme, + scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior( + rememberTopAppBarState(), + ), + onAddCodeClick: () -> Unit, + onScanQuCodeClick: () -> Unit, + onEnterSetupKeyClick: () -> Unit, + onDownloadBitwardenClick: () -> Unit, + onDismissDownloadBitwardenClick: () -> Unit, + onSyncWithBitwardenClick: () -> Unit, + onDismissSyncWithBitwardenClick: () -> Unit, +) { + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.verification_codes), + scrollBehavior = scrollBehavior, + navigationIcon = null, + actions = { }, + ) + }, + floatingActionButton = { + ExpandableFloatingActionButton( + modifier = Modifier + .semantics { testTag = "AddItemButton" } + .padding(bottom = 16.dp), + label = R.string.add_item.asText(), + items = listOf( + ItemListingExpandableFabAction.ScanQrCode( + label = R.string.scan_a_qr_code.asText(), + icon = IconResource( + iconPainter = painterResource(id = R.drawable.ic_camera), + contentDescription = stringResource(id = R.string.scan_a_qr_code), + testTag = "ScanQRCodeButton", + ), + onScanQrCodeClick = onScanQuCodeClick, + ), + ItemListingExpandableFabAction.EnterSetupKey( + label = R.string.enter_key_manually.asText(), + icon = IconResource( + iconPainter = painterResource(id = R.drawable.ic_keyboard_24px), + contentDescription = stringResource(id = R.string.enter_key_manually), + testTag = "EnterSetupKeyButton", + ), + onEnterSetupKeyClick = onEnterSetupKeyClick, + ), + ), + expandableFabIcon = ExpandableFabIcon( + iconData = IconResource( + iconPainter = painterResource(id = R.drawable.ic_plus), + contentDescription = stringResource(id = R.string.add_item), + testTag = "AddItemButton", + ), + iconRotation = 45f, + ), + ) + }, + floatingActionButtonPosition = FabPosition.EndOverlay, + ) { innerPadding -> + Column( + modifier = modifier + .fillMaxSize() + .padding(paddingValues = innerPadding) + .verticalScroll(rememberScrollState()), + verticalArrangement = when (actionCardState) { + ItemListingState.ActionCardState.None -> Arrangement.Center + ItemListingState.ActionCardState.DownloadBitwardenApp -> Arrangement.Top + ItemListingState.ActionCardState.SyncWithBitwarden -> Arrangement.Top + }, + ) { + when (actionCardState) { + ItemListingState.ActionCardState.DownloadBitwardenApp -> + DownloadBitwardenActionCard( + modifier = Modifier.padding(16.dp), + onDismissClick = onDismissDownloadBitwardenClick, + onDownloadBitwardenClick = onDownloadBitwardenClick, + ) + + ItemListingState.ActionCardState.SyncWithBitwarden -> + SyncWithBitwardenActionCard( + modifier = Modifier.padding(16.dp), + onDismissClick = onDismissSyncWithBitwardenClick, + onSyncWithBitwardenClick = onSyncWithBitwardenClick, + ) + + ItemListingState.ActionCardState.None -> Unit + } + + // Add a spacer if an action card is showing: + when (actionCardState) { + ItemListingState.ActionCardState.None -> Unit + ItemListingState.ActionCardState.DownloadBitwardenApp, + ItemListingState.ActionCardState.SyncWithBitwarden, + -> Spacer(Modifier.height(16.dp)) + } + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + + Image( + modifier = Modifier.fillMaxWidth(), + painter = painterResource( + id = when (appTheme) { + AppTheme.DARK -> R.drawable.ic_empty_vault_dark + AppTheme.LIGHT -> R.drawable.ic_empty_vault_light + AppTheme.DEFAULT -> R.drawable.ic_empty_vault + }, + ), + contentDescription = stringResource( + id = R.string.empty_item_list, + ), + contentScale = ContentScale.Fit, + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = R.string.you_dont_have_items_to_display), + style = Typography.titleMedium, + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + textAlign = TextAlign.Center, + text = stringResource(id = R.string.empty_item_list_instruction), + ) + + Spacer(modifier = Modifier.height(16.dp)) + BitwardenFilledTonalButton( + modifier = Modifier + .semantics { testTag = "AddCodeButton" } + .fillMaxWidth(), + label = stringResource(R.string.add_code), + onClick = onAddCodeClick, + ) + } + } + } +} + +@Composable +private fun DownloadBitwardenActionCard( + modifier: Modifier = Modifier, + onDismissClick: () -> Unit, + onDownloadBitwardenClick: () -> Unit, +) = BitwardenActionCard( + modifier = modifier, + actionIcon = rememberVectorPainter(R.drawable.ic_bitwarden), + actionText = stringResource(R.string.download_bitwarden_card_message), + callToActionText = stringResource(R.string.download_now), + titleText = stringResource(R.string.download_bitwarden_card_title), + onCardClicked = onDownloadBitwardenClick, + trailingContent = { + IconButton( + onClick = onDismissClick, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = stringResource(id = R.string.close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(24.dp), + ) + } + }, +) + +@Composable +private fun SyncWithBitwardenActionCard( + modifier: Modifier = Modifier, + onDismissClick: () -> Unit, + onSyncWithBitwardenClick: () -> Unit, +) = BitwardenActionCard( + modifier = modifier, + actionIcon = rememberVectorPainter(R.drawable.ic_refresh), + actionText = stringResource(R.string.sync_with_bitwarden_action_card_message), + callToActionText = stringResource(R.string.go_to_settings), + titleText = stringResource(R.string.sync_with_bitwarden_app), + onCardClicked = onSyncWithBitwardenClick, + trailingContent = { + IconButton( + onClick = onDismissClick, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = stringResource(id = R.string.close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(24.dp), + ) + } + }, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Preview(showBackground = true) +private fun EmptyListingContentPreview() { + EmptyItemListingContent( + modifier = Modifier.padding(horizontal = 16.dp), + appTheme = AppTheme.DEFAULT, + onAddCodeClick = { }, + onScanQuCodeClick = { }, + onEnterSetupKeyClick = { }, + actionCardState = ItemListingState.ActionCardState.DownloadBitwardenApp, + onDownloadBitwardenClick = { }, + onDismissDownloadBitwardenClick = { }, + onSyncWithBitwardenClick = { }, + onDismissSyncWithBitwardenClick = { }, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt new file mode 100644 index 0000000000..905abd5e8d --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt @@ -0,0 +1,940 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting + +import android.net.Uri +import android.os.Parcelable +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem +import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult +import com.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult +import com.bitwarden.authenticator.data.platform.manager.BitwardenEncodingManager +import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager +import com.bitwarden.authenticator.data.platform.manager.imports.model.GoogleAuthenticatorProtos +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.data.platform.repository.model.DataState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.SharedCodesDisplayState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VaultDropdownMenuAction +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util.toDisplayItem +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util.toSharedCodesDisplayState +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import java.util.UUID +import javax.inject.Inject + +/** + * View model responsible for handling user interactions with the item listing screen. + */ +@Suppress("TooManyFunctions") +@HiltViewModel +class ItemListingViewModel @Inject constructor( + private val authenticatorRepository: AuthenticatorRepository, + private val authenticatorBridgeManager: AuthenticatorBridgeManager, + private val clipboardManager: BitwardenClipboardManager, + private val encodingManager: BitwardenEncodingManager, + private val settingsRepository: SettingsRepository, +) : BaseViewModel( + initialState = ItemListingState( + settingsRepository.appTheme, + settingsRepository.authenticatorAlertThresholdSeconds, + viewState = ItemListingState.ViewState.Loading, + dialog = null, + ), +) { + + init { + settingsRepository + .authenticatorAlertThresholdSecondsFlow + .map { ItemListingAction.Internal.AlertThresholdSecondsReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + + settingsRepository + .appThemeStateFlow + .map { ItemListingAction.Internal.AppThemeChangeReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + + combine( + flow = authenticatorRepository.getLocalVerificationCodesFlow(), + flow2 = authenticatorRepository.sharedCodesStateFlow, + ItemListingAction.Internal::AuthCodesUpdated, + ) + .onEach(::sendAction) + .launchIn(viewModelScope) + + authenticatorRepository + .totpCodeFlow + .map { ItemListingAction.Internal.TotpCodeReceive(totpResult = it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + + authenticatorRepository + .firstTimeAccountSyncFlow + .map { ItemListingAction.Internal.FirstTimeUserSyncReceive } + .onEach(::sendAction) + .launchIn(viewModelScope) + } + + override fun handleAction(action: ItemListingAction) { + when (action) { + ItemListingAction.ScanQrCodeClick -> { + sendEvent(ItemListingEvent.NavigateToQrCodeScanner) + } + + ItemListingAction.EnterSetupKeyClick -> { + sendEvent(ItemListingEvent.NavigateToManualAddItem) + } + + ItemListingAction.BackClick -> { + sendEvent(ItemListingEvent.NavigateBack) + } + + is ItemListingAction.ConfirmDeleteClick -> { + handleConfirmDeleteClick(action) + } + + is ItemListingAction.SearchClick -> { + sendEvent(ItemListingEvent.NavigateToSearch) + } + + is ItemListingAction.ItemClick -> { + handleCopyItemClick(action.authCode) + } + + is ItemListingAction.DialogDismiss -> { + handleDialogDismiss() + } + + is ItemListingAction.SettingsClick -> { + handleSettingsClick() + } + + is ItemListingAction.Internal -> { + handleInternalAction(action) + } + + is ItemListingAction.DropdownMenuClick -> { + handleDropdownMenuClick(action) + } + + ItemListingAction.DownloadBitwardenClick -> { + handleDownloadBitwardenClick() + } + + ItemListingAction.DownloadBitwardenDismiss -> { + handleDownloadBitwardenDismiss() + } + + ItemListingAction.SyncWithBitwardenClick -> { + handleSyncWithBitwardenClick() + } + + ItemListingAction.SyncWithBitwardenDismiss -> { + handleSyncWithBitwardenDismiss() + } + } + } + + private fun handleSettingsClick() { + sendEvent(ItemListingEvent.NavigateToAppSettings) + } + + private fun handleCopyItemClick(authCode: String) { + clipboardManager.setText(authCode) + } + + private fun handleEditItemClick(itemId: String) { + sendEvent(ItemListingEvent.NavigateToEditItem(itemId)) + } + + private fun handleMoveToBitwardenClick(itemId: String) { + viewModelScope.launch { + val item = authenticatorRepository + .getItemStateFlow(itemId) + .first { it.data != null } + + val didLaunchAddTotpFlow = authenticatorBridgeManager.startAddTotpLoginItemFlow( + totpUri = item.data?.toOtpAuthUriString().orEmpty(), + ) + if (!didLaunchAddTotpFlow) { + mutableStateFlow.update { + it.copy( + dialog = ItemListingState.DialogState.Error( + title = R.string.something_went_wrong.asText(), + message = R.string.please_try_again.asText(), + ), + ) + } + } + } + } + + private fun handleDeleteItemClick(itemId: String) { + mutableStateFlow.update { + it.copy( + dialog = ItemListingState.DialogState.DeleteConfirmationPrompt( + message = R.string.do_you_really_want_to_permanently_delete_cipher.asText(), + itemId = itemId, + ), + ) + } + } + + private fun handleConfirmDeleteClick(action: ItemListingAction.ConfirmDeleteClick) { + mutableStateFlow.update { + it.copy( + dialog = ItemListingState.DialogState.Loading, + ) + } + + viewModelScope.launch { + trySendAction( + ItemListingAction.Internal.DeleteItemReceive( + authenticatorRepository.hardDeleteItem(action.itemId), + ), + ) + } + } + + private fun handleInternalAction(internalAction: ItemListingAction.Internal) { + when (internalAction) { + is ItemListingAction.Internal.AuthCodesUpdated -> { + handleAuthenticatorDataReceive(internalAction) + } + + is ItemListingAction.Internal.AlertThresholdSecondsReceive -> { + handleAlertThresholdSecondsReceive(internalAction) + } + + is ItemListingAction.Internal.TotpCodeReceive -> { + handleTotpCodeReceive(internalAction) + } + + is ItemListingAction.Internal.CreateItemResultReceive -> { + handleCreateItemResultReceive(internalAction) + } + + is ItemListingAction.Internal.DeleteItemReceive -> { + handleDeleteItemReceive(internalAction.result) + } + + is ItemListingAction.Internal.AppThemeChangeReceive -> { + handleAppThemeChangeReceive(internalAction.appTheme) + } + + ItemListingAction.Internal.FirstTimeUserSyncReceive -> { + handleFirstTimeUserSync() + } + } + } + + private fun handleFirstTimeUserSync() { + sendEvent(ItemListingEvent.ShowFirstTimeSyncSnackbar) + } + + private fun handleAppThemeChangeReceive(appTheme: AppTheme) { + mutableStateFlow.update { + it.copy(appTheme = appTheme) + } + } + + private fun handleDeleteItemReceive(result: DeleteItemResult) { + when (result) { + DeleteItemResult.Error -> { + mutableStateFlow.update { + it.copy( + dialog = ItemListingState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + DeleteItemResult.Success -> { + mutableStateFlow.update { + it.copy(dialog = null) + } + sendEvent( + ItemListingEvent.ShowToast( + message = R.string.item_deleted.asText(), + ), + ) + } + } + } + + private fun handleCreateItemResultReceive( + action: ItemListingAction.Internal.CreateItemResultReceive, + ) { + mutableStateFlow.update { it.copy(dialog = null) } + + when (action.result) { + CreateItemResult.Error -> { + mutableStateFlow.update { + it.copy( + dialog = ItemListingState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.authenticator_key_read_error.asText(), + ), + ) + } + } + + CreateItemResult.Success -> { + sendEvent( + event = ItemListingEvent.ShowToast( + message = R.string.verification_code_added.asText(), + ), + ) + } + } + } + + private fun handleTotpCodeReceive(action: ItemListingAction.Internal.TotpCodeReceive) { + mutableStateFlow.update { it.copy(viewState = ItemListingState.ViewState.Loading) } + + viewModelScope.launch { + when (val totpResult = action.totpResult) { + TotpCodeResult.CodeScanningError -> { + handleCodeScanningErrorReceive() + } + + is TotpCodeResult.TotpCodeScan -> { + handleTotpCodeScanReceive(totpResult) + } + + is TotpCodeResult.GoogleExportScan -> { + handleGoogleExportScan(totpResult) + } + } + } + } + + private suspend fun handleTotpCodeScanReceive( + totpResult: TotpCodeResult.TotpCodeScan, + ) { + val item = totpResult.code.toAuthenticatorEntityOrNull() + ?: run { + handleCodeScanningErrorReceive() + return + } + + val result = authenticatorRepository.createItem(item) + sendAction(ItemListingAction.Internal.CreateItemResultReceive(result)) + } + + private suspend fun handleGoogleExportScan( + totpResult: TotpCodeResult.GoogleExportScan, + ) { + val base64EncodedMigrationData = encodingManager.uriDecode( + value = totpResult.data, + ) + + val decodedMigrationData = encodingManager.base64Decode( + value = base64EncodedMigrationData, + ) + + val payload = GoogleAuthenticatorProtos.MigrationPayload + .parseFrom(decodedMigrationData) + + val entries = payload + .otpParametersList + .mapNotNull { otpParam -> + val secret = encodingManager.base32Encode( + byteArray = otpParam.secret.toByteArray(), + ) + + // Google Authenticator only supports TOTP and HOTP codes. We do not support HOTP + // codes so we skip over codes that are not TOTP. + val type = when (otpParam.type) { + GoogleAuthenticatorProtos.MigrationPayload.OtpType.OTP_TOTP -> { + AuthenticatorItemType.TOTP + } + + else -> return@mapNotNull null + } + + // Google Authenticator does not always provide a valid digits value so we double + // check it and fallback to the default value if it is not within our valid range. + val digits = if (otpParam.digits in TotpCodeManager.TOTP_DIGITS_RANGE) { + otpParam.digits + } else { + TotpCodeManager.TOTP_DIGITS_DEFAULT + } + + // Google Authenticator only supports SHA1 algorithms. + val algorithm = AuthenticatorItemAlgorithm.SHA1 + + // Google Authenticator ignores period so we always set it to our default. + val period = TotpCodeManager.PERIOD_SECONDS_DEFAULT + + val accountName: String = when { + otpParam.issuer.isNullOrEmpty().not() && + otpParam.name.startsWith("${otpParam.issuer}:") -> { + otpParam.name.replace("${otpParam.issuer}:", "") + } + + else -> otpParam.name + } + + // If the issuer is not provided fallback to the token name since issuer is required + // in our database + val issuer = when { + otpParam.issuer.isNullOrEmpty() -> otpParam.name + else -> otpParam.issuer + } + + AuthenticatorItemEntity( + id = UUID.randomUUID().toString(), + key = secret, + type = type, + algorithm = algorithm, + period = period, + digits = digits, + issuer = issuer, + accountName = accountName, + userId = null, + favorite = false, + ) + } + + val result = authenticatorRepository.addItems(*entries.toTypedArray()) + sendAction(ItemListingAction.Internal.CreateItemResultReceive(result)) + } + + private suspend fun handleCodeScanningErrorReceive() { + sendAction( + action = ItemListingAction.Internal.CreateItemResultReceive( + result = CreateItemResult.Error, + ), + ) + } + + private fun handleAlertThresholdSecondsReceive( + action: ItemListingAction.Internal.AlertThresholdSecondsReceive, + ) { + mutableStateFlow.update { + it.copy( + alertThresholdSeconds = action.thresholdSeconds, + ) + } + } + + private fun handleDialogDismiss() { + mutableStateFlow.update { + it.copy(dialog = null) + } + } + + private fun handleAuthenticatorDataReceive( + action: ItemListingAction.Internal.AuthCodesUpdated, + ) { + val localItems = action.localCodes.data ?: run { + // If local items haven't loaded from DB, show Loading: + mutableStateFlow.update { + it.copy( + viewState = ItemListingState.ViewState.Loading, + ) + } + return + } + val sharedItemsState: SharedCodesDisplayState = when (action.sharedCodesState) { + SharedVerificationCodesState.Error -> SharedCodesDisplayState.Error + SharedVerificationCodesState.AppNotInstalled, + SharedVerificationCodesState.FeatureNotEnabled, + SharedVerificationCodesState.Loading, + SharedVerificationCodesState.OsVersionNotSupported, + SharedVerificationCodesState.SyncNotEnabled, + -> SharedCodesDisplayState.Codes(emptyList()) + + is SharedVerificationCodesState.Success -> + action.sharedCodesState.toSharedCodesDisplayState( + alertThresholdSeconds = state.alertThresholdSeconds, + ) + } + + if (localItems.isEmpty() && sharedItemsState.isEmpty()) { + // If there are no items, show empty state: + mutableStateFlow.update { + it.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = action.sharedCodesState.toActionCard(), + ), + ) + } + } else { + val viewState = ItemListingState.ViewState.Content( + favoriteItems = localItems + .filter { it.source is AuthenticatorItem.Source.Local && it.source.isFavorite } + .map { + it.toDisplayItem( + alertThresholdSeconds = state.alertThresholdSeconds, + sharedVerificationCodesState = + authenticatorRepository.sharedCodesStateFlow.value, + ) + }, + itemList = localItems + .filter { it.source is AuthenticatorItem.Source.Local && !it.source.isFavorite } + .map { + it.toDisplayItem( + alertThresholdSeconds = state.alertThresholdSeconds, + sharedVerificationCodesState = + authenticatorRepository.sharedCodesStateFlow.value, + ) + }, + sharedItems = sharedItemsState, + actionCard = action.sharedCodesState.toActionCard(), + ) + mutableStateFlow.update { it.copy(viewState = viewState) } + } + } + + private fun handleDownloadBitwardenClick() { + sendEvent(ItemListingEvent.NavigateToBitwardenListing) + } + + private fun handleDropdownMenuClick(action: ItemListingAction.DropdownMenuClick) { + when (action.menuAction) { + VaultDropdownMenuAction.COPY -> handleCopyItemClick(action.item.authCode) + VaultDropdownMenuAction.EDIT -> handleEditItemClick(action.item.id) + VaultDropdownMenuAction.MOVE -> handleMoveToBitwardenClick(action.item.id) + VaultDropdownMenuAction.DELETE -> handleDeleteItemClick(action.item.id) + } + } + + private fun handleDownloadBitwardenDismiss() { + settingsRepository.hasUserDismissedDownloadBitwardenCard = true + mutableStateFlow.update { + it.copy( + viewState = when (it.viewState) { + ItemListingState.ViewState.Loading -> it.viewState + is ItemListingState.ViewState.Content -> it.viewState.copy( + actionCard = ItemListingState.ActionCardState.None, + ) + + is ItemListingState.ViewState.NoItems -> it.viewState.copy( + actionCard = ItemListingState.ActionCardState.None, + ) + }, + ) + } + } + + private fun handleSyncWithBitwardenClick() { + sendEvent(ItemListingEvent.NavigateToBitwardenSettings) + } + + private fun handleSyncWithBitwardenDismiss() { + settingsRepository.hasUserDismissedSyncWithBitwardenCard = true + mutableStateFlow.update { + it.copy( + viewState = when (it.viewState) { + ItemListingState.ViewState.Loading -> it.viewState + is ItemListingState.ViewState.Content -> it.viewState.copy( + actionCard = ItemListingState.ActionCardState.None, + ) + + is ItemListingState.ViewState.NoItems -> it.viewState.copy( + actionCard = ItemListingState.ActionCardState.None, + ) + }, + ) + } + } + + /** + * Converts a [SharedVerificationCodesState] into an action card for display. + */ + private fun SharedVerificationCodesState.toActionCard(): ItemListingState.ActionCardState = + when (this) { + SharedVerificationCodesState.AppNotInstalled -> + if (!settingsRepository.hasUserDismissedDownloadBitwardenCard) { + ItemListingState.ActionCardState.DownloadBitwardenApp + } else { + ItemListingState.ActionCardState.None + } + + SharedVerificationCodesState.SyncNotEnabled -> + if (!settingsRepository.hasUserDismissedSyncWithBitwardenCard) { + ItemListingState.ActionCardState.SyncWithBitwarden + } else { + ItemListingState.ActionCardState.None + } + + SharedVerificationCodesState.Error, + SharedVerificationCodesState.FeatureNotEnabled, + SharedVerificationCodesState.Loading, + SharedVerificationCodesState.OsVersionNotSupported, + is SharedVerificationCodesState.Success, + -> ItemListingState.ActionCardState.None + } + + private fun String.toAuthenticatorEntityOrNull(): AuthenticatorItemEntity? { + val uri = Uri.parse(this) + + val type = AuthenticatorItemType + .entries + .find { it.name.lowercase() == uri.host } + ?: return null + + val label = uri.pathSegments.firstOrNull() ?: return null + val accountName = if (label.contains(":")) { + label + .split(":") + .last() + } else { + label + } + + val key = uri.getQueryParameter(SECRET) ?: return null + + val algorithm = AuthenticatorItemAlgorithm + .entries + .find { it.name == uri.getQueryParameter(ALGORITHM) } + ?: AuthenticatorItemAlgorithm.SHA1 + + val digits = uri.getQueryParameter(DIGITS)?.toIntOrNull() + ?: TotpCodeManager.TOTP_DIGITS_DEFAULT + + val issuer = uri.getQueryParameter(ISSUER) + ?: label + + val period = uri.getQueryParameter(PERIOD)?.toIntOrNull() + ?: TotpCodeManager.PERIOD_SECONDS_DEFAULT + + return AuthenticatorItemEntity( + id = UUID.randomUUID().toString(), + key = key, + accountName = accountName, + type = type, + algorithm = algorithm, + period = period, + digits = digits, + issuer = issuer, + userId = null, + favorite = false, + ) + } +} + +const val ALGORITHM = "algorithm" +const val DIGITS = "digits" +const val PERIOD = "period" +const val SECRET = "secret" +const val ISSUER = "issuer" + +/** + * Represents the state for displaying the item listing. + * + * @property viewState Current state of the [ItemListingScreen]. + * @property dialog State of the dialog show on the [ItemListingScreen]. `null` if no dialog is + * shown. + */ +@Parcelize +data class ItemListingState( + val appTheme: AppTheme, + val alertThresholdSeconds: Int, + val viewState: ViewState, + val dialog: DialogState?, +) : Parcelable { + /** + * Represents the different view states of the [ItemListingScreen]. + */ + @Parcelize + sealed class ViewState : Parcelable { + + /** + * Represents the [ItemListingScreen] data is processing. + */ + @Parcelize + data object Loading : ViewState() + + /** + * Represents a state where the [ItemListingScreen] has no items to display. + */ + @Parcelize + data class NoItems( + val actionCard: ActionCardState, + ) : ViewState() + + /** + * Represents a loaded content state for the [ItemListingScreen]. + */ + @Parcelize + data class Content( + val actionCard: ActionCardState, + val favoriteItems: List, + val itemList: List, + val sharedItems: SharedCodesDisplayState, + ) : ViewState() { + + /** + * Whether or not there should be a "Local codes" header shown above local codes. + */ + val shouldShowLocalHeader + get() = + // Only show header if there are shared items + !sharedItems.isEmpty() && + // And also local items + itemList.isNotEmpty() && + // But there are no favorite items + // (If there are favorite items, the favorites header will take care of us) + favoriteItems.isEmpty() + } + } + + /** + * Display an action card on the item [ItemListingScreen]. + */ + sealed class ActionCardState : Parcelable { + /** + * Display no action card. + */ + @Parcelize + data object None : ActionCardState() + + /** + * Display the "Download the Bitwarden app" card. + */ + @Parcelize + data object DownloadBitwardenApp : ActionCardState() + + /** + * Display the "Sync with the Bitwarden app" card. + */ + @Parcelize + data object SyncWithBitwarden : ActionCardState() + } + + /** + * Display a dialog on the [ItemListingScreen]. + */ + sealed class DialogState : Parcelable { + /** + * Displays the loading dialog to the user. + */ + @Parcelize + data object Loading : DialogState() + + /** + * Displays a generic error dialog to the user. + */ + @Parcelize + data class Error( + val title: Text, + val message: Text, + ) : DialogState() + + /** + * Displays a prompt to confirm item deletion. + */ + @Parcelize + data class DeleteConfirmationPrompt( + val message: Text, + val itemId: String, + ) : DialogState() + } +} + +/** + * Represents a set of events related to viewing the item listing. + */ +sealed class ItemListingEvent { + /** + * Navigates to the Create Account screen. + */ + data object NavigateBack : ItemListingEvent() + + /** + * Navigates to the Search screen. + */ + data object NavigateToSearch : ItemListingEvent() + + /** + * Navigate to the QR Code Scanner screen. + */ + data object NavigateToQrCodeScanner : ItemListingEvent() + + /** + * Navigate to the Manual Add Item screen. + */ + data object NavigateToManualAddItem : ItemListingEvent() + + /** + * Navigate to the Edit Item screen. + */ + data class NavigateToEditItem( + val id: String, + ) : ItemListingEvent() + + /** + * Navigate to the app settings. + */ + data object NavigateToAppSettings : ItemListingEvent() + + /** + * Navigate to Bitwarden play store listing. + */ + data object NavigateToBitwardenListing : ItemListingEvent() + + /** + * Navigate to Bitwarden account security settings. + */ + data object NavigateToBitwardenSettings : ItemListingEvent() + + /** + * Show a Toast with [message]. + */ + data class ShowToast( + val message: Text, + ) : ItemListingEvent() + + /** + * Show a Snackbar letting the user know accounts have synced. + */ + data object ShowFirstTimeSyncSnackbar : ItemListingEvent() +} + +/** + * Represents a set of actions related to viewing the authenticator item listing. + * Each subclass of this sealed class denotes a distinct action that can be taken. + */ +sealed class ItemListingAction { + /** + * The user clicked the back button. + */ + data object BackClick : ItemListingAction() + + /** + * The user has clicked the search button. + */ + data object SearchClick : ItemListingAction() + + /** + * The user clicked the Scan QR Code button. + */ + data object ScanQrCodeClick : ItemListingAction() + + /** + * The user clicked the Enter Setup Key button. + */ + data object EnterSetupKeyClick : ItemListingAction() + + /** + * The user clicked a list item to copy its auth code. + */ + data class ItemClick(val authCode: String) : ItemListingAction() + + /** + * The user dismissed the dialog. + */ + data object DialogDismiss : ItemListingAction() + + /** + * The user has clicked the settings button + */ + data object SettingsClick : ItemListingAction() + + /** + * The user tapped download Bitwarden action card. + */ + data object DownloadBitwardenClick : ItemListingAction() + + /** + * The user dismissed download Bitwarden action card. + */ + data object DownloadBitwardenDismiss : ItemListingAction() + + /** + * The user tapped sync Bitwarden action card. + */ + data object SyncWithBitwardenClick : ItemListingAction() + + /** + * The user dismissed sync Bitwarden action card. + */ + data object SyncWithBitwardenDismiss : ItemListingAction() + + /** + * The user clicked confirm when prompted to delete an item. + */ + data class ConfirmDeleteClick(val itemId: String) : ItemListingAction() + + /** + * Represents an action triggered when the user clicks an item in the dropdown menu. + * + * @param menuAction The action selected from the dropdown menu. + * @param id The identifier of the item on which the action is being performed. + */ + data class DropdownMenuClick( + val menuAction: VaultDropdownMenuAction, + val item: VerificationCodeDisplayItem, + ) : ItemListingAction() + + /** + * Models actions that [ItemListingScreen] itself may send. + */ + sealed class Internal : ItemListingAction() { + /** + * Indicates verification items have been received. + */ + data class AuthCodesUpdated( + val localCodes: DataState>, + val sharedCodesState: SharedVerificationCodesState, + ) : Internal() + + /** + * Indicates authenticator item alert threshold seconds changes has been received. + */ + data class AlertThresholdSecondsReceive( + val thresholdSeconds: Int, + ) : Internal() + + /** + * Indicates a new TOTP code scan result has been received. + */ + data class TotpCodeReceive(val totpResult: TotpCodeResult) : Internal() + + /** + * Indicates a result for creating and item has been received. + */ + data class CreateItemResultReceive(val result: CreateItemResult) : Internal() + + /** + * Indicates a result for deleting an item has been received. + */ + data class DeleteItemReceive(val result: DeleteItemResult) : Internal() + + /** + * Indicates app theme change has been received. + */ + data class AppThemeChangeReceive(val appTheme: AppTheme) : Internal() + + /** + * Indicates that a user synced with Bitwarden for the first time. + */ + data object FirstTimeUserSyncReceive : Internal() + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt new file mode 100644 index 0000000000..0ac68978ac --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt @@ -0,0 +1,250 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VaultDropdownMenuAction +import com.bitwarden.authenticator.ui.platform.components.icon.BitwardenIcon +import com.bitwarden.authenticator.ui.platform.components.indicator.BitwardenCircularCountdownIndicator +import com.bitwarden.authenticator.ui.platform.components.model.IconData +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme + +/** + * The verification code item displayed to the user. + * + * @param authCode The code for the item. + * @param primaryLabel The label for the item. Represents the OTP issuer. + * @param secondaryLabel The supporting label for the item. Represents the OTP account name. + * @param periodSeconds The times span where the code is valid. + * @param timeLeftSeconds The seconds remaining until a new code is needed. + * @param alertThresholdSeconds The time threshold in seconds to display an expiration warning. + * @param startIcon The leading icon for the item. + * @param onItemClick The lambda function to be invoked when the item is clicked. + * @param onDropdownMenuClick A lambda function invoked when a dropdown menu action is clicked. + * @param allowLongPress Whether long-press interactions are enabled for the item. + * @param showMoveToBitwarden Whether the option to move the item to Bitwarden is displayed. + * @param modifier The modifier for the item. + */ +@OptIn(ExperimentalFoundationApi::class) +@Suppress("LongMethod", "MagicNumber") +@Composable +fun VaultVerificationCodeItem( + authCode: String, + primaryLabel: String?, + secondaryLabel: String?, + periodSeconds: Int, + timeLeftSeconds: Int, + alertThresholdSeconds: Int, + startIcon: IconData, + onItemClick: () -> Unit, + onDropdownMenuClick: (VaultDropdownMenuAction) -> Unit, + allowLongPress: Boolean, + showMoveToBitwarden: Boolean, + modifier: Modifier = Modifier, +) { + var shouldShowDropdownMenu by remember { mutableStateOf(value = false) } + Box(modifier = modifier) { + Row( + modifier = Modifier + .semantics { testTag = "Item" } + .then( + if (allowLongPress) { + Modifier.combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(color = MaterialTheme.colorScheme.primary), + onClick = onItemClick, + onLongClick = { shouldShowDropdownMenu = true }, + ) + } else { + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(color = MaterialTheme.colorScheme.primary), + onClick = onItemClick, + ) + }, + ) + .defaultMinSize(minHeight = 72.dp) + .padding( + vertical = 8.dp, + horizontal = 16.dp, + ) + .then(modifier), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + BitwardenIcon( + iconData = startIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .semantics { testTag = "BitwardenIcon" } + .size(24.dp), + ) + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.weight(1f), + ) { + if (!primaryLabel.isNullOrEmpty()) { + Text( + modifier = Modifier.semantics { testTag = "Name" }, + text = primaryLabel, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (!secondaryLabel.isNullOrEmpty()) { + Text( + modifier = Modifier.semantics { testTag = "Username" }, + text = secondaryLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + BitwardenCircularCountdownIndicator( + modifier = Modifier.semantics { testTag = "CircularCountDown" }, + timeLeftSeconds = timeLeftSeconds, + periodSeconds = periodSeconds, + alertThresholdSeconds = alertThresholdSeconds, + ) + + Text( + modifier = Modifier.semantics { testTag = "AuthCode" }, + text = authCode.chunked(3).joinToString(" "), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + DropdownMenu( + expanded = shouldShowDropdownMenu, + onDismissRequest = { shouldShowDropdownMenu = false }, + ) { + DropdownMenuItem( + text = { + Text(text = stringResource(id = R.string.copy)) + }, + onClick = { + shouldShowDropdownMenu = false + onDropdownMenuClick(VaultDropdownMenuAction.COPY) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_copy), + contentDescription = stringResource(id = R.string.copy), + ) + }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { + Text(text = stringResource(id = R.string.edit_item)) + }, + onClick = { + shouldShowDropdownMenu = false + onDropdownMenuClick(VaultDropdownMenuAction.EDIT) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_edit_item), + contentDescription = stringResource(R.string.edit_item), + ) + }, + ) + if (showMoveToBitwarden) { + HorizontalDivider() + DropdownMenuItem( + text = { + Text(text = stringResource(id = R.string.move_to_bitwarden)) + }, + onClick = { + shouldShowDropdownMenu = false + onDropdownMenuClick(VaultDropdownMenuAction.MOVE) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_right), + contentDescription = stringResource(id = R.string.move_to_bitwarden), + ) + }, + ) + } + HorizontalDivider() + DropdownMenuItem( + text = { + Text(text = stringResource(id = R.string.delete_item)) + }, + onClick = { + shouldShowDropdownMenu = false + onDropdownMenuClick(VaultDropdownMenuAction.DELETE) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_delete_item), + contentDescription = stringResource(id = R.string.delete_item), + ) + }, + ) + } + } +} + +@Suppress("MagicNumber") +@Preview(showBackground = true) +@Composable +private fun VerificationCodeItem_preview() { + AuthenticatorTheme { + VaultVerificationCodeItem( + authCode = "1234567890".chunked(3).joinToString(" "), + primaryLabel = "Issuer, AKA Name", + secondaryLabel = "username@bitwarden.com", + periodSeconds = 30, + timeLeftSeconds = 15, + alertThresholdSeconds = 7, + startIcon = IconData.Local(R.drawable.ic_login_item), + onItemClick = {}, + onDropdownMenuClick = {}, + allowLongPress = true, + modifier = Modifier.padding(horizontal = 16.dp), + showMoveToBitwarden = true, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/ItemListingExpandableFabAction.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/ItemListingExpandableFabAction.kt new file mode 100644 index 0000000000..ec4d301f05 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/ItemListingExpandableFabAction.kt @@ -0,0 +1,42 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model + +import androidx.compose.material3.ExtendedFloatingActionButton +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.components.fab.ExpandableFabOption +import com.bitwarden.authenticator.ui.platform.components.model.IconResource + +/** + * Models [ExpandableFabOption]s that can be triggered by the [ExtendedFloatingActionButton]. + */ +sealed class ItemListingExpandableFabAction( + label: Text?, + icon: IconResource, + onFabOptionClick: () -> Unit, +) : ExpandableFabOption(label, icon, onFabOptionClick) { + + /** + * Indicates the Scan QR code button was clicked. + */ + class ScanQrCode( + label: Text?, + icon: IconResource, + onScanQrCodeClick: () -> Unit, + ) : ItemListingExpandableFabAction( + label, + icon, + onScanQrCodeClick, + ) + + /** + * Indicates the Enter Key button was clicked. + */ + class EnterSetupKey( + label: Text?, + icon: IconResource, + onEnterSetupKeyClick: () -> Unit, + ) : ItemListingExpandableFabAction( + label, + icon, + onEnterSetupKeyClick, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/SharedCodesDisplayState.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/SharedCodesDisplayState.kt new file mode 100644 index 0000000000..8fa5374fa9 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/SharedCodesDisplayState.kt @@ -0,0 +1,40 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model + +import android.os.Parcelable +import com.bitwarden.authenticator.ui.platform.base.util.Text +import kotlinx.parcelize.Parcelize + +/** + * Models how shared codes should be displayed. + */ +sealed class SharedCodesDisplayState : Parcelable { + + /** + * There was an error syncing codes. + */ + @Parcelize + data object Error : SharedCodesDisplayState() + + /** + * Display the given [sections] of verification codes. + */ + @Parcelize + data class Codes(val sections: List) : SharedCodesDisplayState() + + /** + * Models a section of shared authenticator codes to be displayed. + */ + @Parcelize + data class SharedCodesAccountSection( + val label: Text, + val codes: List, + ) : Parcelable + + /** + * Utility function to determine if there are any codes synced. + */ + fun isEmpty() = when (this) { + is Codes -> this.sections.isEmpty() + Error -> true + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VaultDropdownMenuAction.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VaultDropdownMenuAction.kt new file mode 100644 index 0000000000..0247844a10 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VaultDropdownMenuAction.kt @@ -0,0 +1,11 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model + +/** + * Enum representing the available actions in the Vault dropdown menu. + */ +enum class VaultDropdownMenuAction { + COPY, + EDIT, + MOVE, + DELETE, +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VerificationCodeDisplayItem.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VerificationCodeDisplayItem.kt new file mode 100644 index 0000000000..b92728e7af --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VerificationCodeDisplayItem.kt @@ -0,0 +1,24 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model + +import android.os.Parcelable +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.model.IconData +import kotlinx.parcelize.Parcelize + +/** + * The data for the verification code item to display. + */ +@Parcelize +data class VerificationCodeDisplayItem( + val id: String, + val title: String, + val subtitle: String?, + val timeLeftSeconds: Int, + val periodSeconds: Int, + val alertThresholdSeconds: Int, + val authCode: String, + val startIcon: IconData = IconData.Local(R.drawable.ic_login_item), + val favorite: Boolean, + val allowLongPressActions: Boolean, + val showMoveToBitwarden: Boolean, +) : Parcelable diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/SharedVerificationCodesStateExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/SharedVerificationCodesStateExtensions.kt new file mode 100644 index 0000000000..0420d318fc --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/SharedVerificationCodesStateExtensions.kt @@ -0,0 +1,43 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util + +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.SharedCodesDisplayState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem +import com.bitwarden.authenticator.ui.platform.base.util.asText + +/** + * Convert [SharedVerificationCodesState.Success] into [SharedCodesDisplayState.Codes]. + */ +fun SharedVerificationCodesState.Success.toSharedCodesDisplayState( + alertThresholdSeconds: Int, +): SharedCodesDisplayState.Codes { + val codesMap = + mutableMapOf>() + // Make a map where each key is a Bitwarden account and each value is a list of verification + // codes for that account: + this.items.forEach { + codesMap.putIfAbsent(it.source as AuthenticatorItem.Source.Shared, mutableListOf()) + codesMap[it.source]?.add( + it.toDisplayItem( + alertThresholdSeconds = alertThresholdSeconds, + // Always map based on Error state, because shared codes will never + // show "Move to Bitwarden" action. + sharedVerificationCodesState = SharedVerificationCodesState.Error, + ), + ) + } + // Flatten that map down to a list of accounts that each has a list of codes: + return codesMap + .map { + SharedCodesDisplayState.SharedCodesAccountSection( + label = R.string.shared_accounts_header.asText( + it.key.email, + it.key.environmentLabel, + ), + codes = it.value, + ) + } + .let { SharedCodesDisplayState.Codes(it) } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt new file mode 100644 index 0000000000..c9a27d0b2b --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt @@ -0,0 +1,49 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util + +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem + +/** + * Converts [VerificationCodeItem] to a [VerificationCodeDisplayItem]. + */ +fun VerificationCodeItem.toDisplayItem( + alertThresholdSeconds: Int, + sharedVerificationCodesState: SharedVerificationCodesState, +) = VerificationCodeDisplayItem( + id = id, + title = issuer ?: label ?: "--", + subtitle = if (issuer != null) { + // Only show label if it is not being used as the primary title: + label + } else { + null + }, + timeLeftSeconds = timeLeftSeconds, + periodSeconds = periodSeconds, + alertThresholdSeconds = alertThresholdSeconds, + authCode = code, + allowLongPressActions = when (source) { + is AuthenticatorItem.Source.Local -> true + is AuthenticatorItem.Source.Shared -> false + }, + favorite = (source as? AuthenticatorItem.Source.Local)?.isFavorite ?: false, + showMoveToBitwarden = when (source) { + // Shared items should never show Move to Bitwarden action: + is AuthenticatorItem.Source.Shared -> false + + // Local items should only show Move to Bitwarden if we are successfully syncing: = + is AuthenticatorItem.Source.Local -> when (sharedVerificationCodesState) { + SharedVerificationCodesState.AppNotInstalled, + SharedVerificationCodesState.Error, + SharedVerificationCodesState.FeatureNotEnabled, + SharedVerificationCodesState.Loading, + SharedVerificationCodesState.OsVersionNotSupported, + SharedVerificationCodesState.SyncNotEnabled, + -> false + + is SharedVerificationCodesState.Success -> true + } + }, +) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryNavigation.kt new file mode 100644 index 0000000000..97bdd4121c --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryNavigation.kt @@ -0,0 +1,34 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.bitwarden.authenticator.ui.platform.base.util.composableWithSlideTransitions + +private const val MANUAL_CODE_ENTRY_ROUTE: String = "manual_code_entry" + +/** + * Add the manual code entry screen to the nav graph. + */ +fun NavGraphBuilder.manualCodeEntryDestination( + onNavigateBack: () -> Unit, + onNavigateToQrCodeScreen: () -> Unit, +) { + composableWithSlideTransitions( + route = MANUAL_CODE_ENTRY_ROUTE, + ) { + ManualCodeEntryScreen( + onNavigateBack = onNavigateBack, + onNavigateToQrCodeScreen = onNavigateToQrCodeScreen, + ) + } +} + +/** + * Navigate to the manual code entry screen. + */ +fun NavController.navigateToManualCodeEntryScreen( + navOptions: NavOptions? = null, +) { + this.navigate(MANUAL_CODE_ENTRY_ROUTE, navOptions) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt new file mode 100644 index 0000000000..6bb5330247 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt @@ -0,0 +1,247 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.base.util.toAnnotatedString +import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenLoadingDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenTwoButtonDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.LoadingDialogState +import com.bitwarden.authenticator.ui.platform.components.field.BitwardenPasswordField +import com.bitwarden.authenticator.ui.platform.components.field.BitwardenTextField +import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import com.bitwarden.authenticator.ui.platform.manager.permissions.PermissionsManager +import com.bitwarden.authenticator.ui.platform.theme.LocalIntentManager +import com.bitwarden.authenticator.ui.platform.theme.LocalPermissionsManager + +/** + * The screen to manually add a totp code. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManualCodeEntryScreen( + onNavigateBack: () -> Unit, + onNavigateToQrCodeScreen: () -> Unit, + viewModel: ManualCodeEntryViewModel = hiltViewModel(), + intentManager: IntentManager = LocalIntentManager.current, + permissionsManager: PermissionsManager = LocalPermissionsManager.current, +) { + var shouldShowPermissionDialog by rememberSaveable { mutableStateOf(false) } + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val launcher = permissionsManager.getLauncher { isGranted -> + if (isGranted) { + viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick) + } else { + shouldShowPermissionDialog = true + } + } + + val context = LocalContext.current + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is ManualCodeEntryEvent.NavigateToAppSettings -> { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = Uri.parse("package:" + context.packageName) + + intentManager.startActivity(intent = intent) + } + + is ManualCodeEntryEvent.ShowToast -> { + Toast + .makeText(context, event.message.invoke(context.resources), Toast.LENGTH_SHORT) + .show() + } + + is ManualCodeEntryEvent.NavigateToQrCodeScreen -> { + onNavigateToQrCodeScreen.invoke() + } + + is ManualCodeEntryEvent.NavigateBack -> { + onNavigateBack.invoke() + } + } + } + + if (shouldShowPermissionDialog) { + BitwardenTwoButtonDialog( + message = stringResource(id = R.string.enable_camera_permission_to_use_the_scanner), + confirmButtonText = stringResource(id = R.string.settings), + dismissButtonText = stringResource(id = R.string.no_thanks), + onConfirmClick = remember(viewModel) { + { viewModel.trySendAction(ManualCodeEntryAction.SettingsClick) } + }, + onDismissClick = { shouldShowPermissionDialog = false }, + onDismissRequest = { shouldShowPermissionDialog = false }, + title = null, + ) + } + + when (val dialog = state.dialog) { + + is ManualCodeEntryState.DialogState.Error -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialog.title, + message = dialog.message, + ), + onDismissRequest = remember(state) { + { viewModel.trySendAction(ManualCodeEntryAction.DismissDialog) } + }, + ) + } + + is ManualCodeEntryState.DialogState.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown( + dialog.message, + ), + ) + } + + null -> { + Unit + } + } + + BitwardenScaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.create_verification_code), + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(ManualCodeEntryAction.CloseClick) } + }, + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + ) + }, + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + + Text( + text = stringResource(id = R.string.enter_key_manually), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.name), + value = state.issuer, + onValueChange = remember(viewModel) { + { + viewModel.trySendAction( + ManualCodeEntryAction.IssuerTextChange(it), + ) + } + }, + modifier = Modifier + .semantics { testTag = "NameTextField" } + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + BitwardenPasswordField( + singleLine = false, + label = stringResource(id = R.string.key), + value = state.code, + onValueChange = remember(viewModel) { + { + viewModel.trySendAction( + ManualCodeEntryAction.CodeTextChange(it), + ) + } + }, + capitalization = KeyboardCapitalization.Characters, + modifier = Modifier + .semantics { testTag = "KeyTextField" } + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + SaveManualCodeButtons( + state = state.buttonState, + onSaveLocallyClick = remember(viewModel) { + { + viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) + } + }, + onSaveToBitwardenClick = remember(viewModel) { + { + viewModel.trySendAction(ManualCodeEntryAction.SaveToBitwardenClick) + } + }, + ) + + Text( + text = stringResource(id = R.string.cannot_add_authenticator_key), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 8.dp, + horizontal = 16.dp, + ), + ) + + ClickableText( + text = stringResource(id = R.string.scan_qr_code).toAnnotatedString(), + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.primary, + ), + modifier = Modifier + .padding(horizontal = 16.dp), + onClick = remember(viewModel) { + { + if (permissionsManager.checkPermission(Manifest.permission.CAMERA)) { + viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick) + } else { + launcher.launch(Manifest.permission.CAMERA) + } + } + }, + ) + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt new file mode 100644 index 0000000000..7ef89f5120 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt @@ -0,0 +1,337 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.base.util.isBase32 +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import java.util.UUID +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * The ViewModel for handling user interactions in the manual code entry screen. + * + */ +@HiltViewModel +@Suppress("TooManyFunctions") +class ManualCodeEntryViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val authenticatorRepository: AuthenticatorRepository, + private val authenticatorBridgeManager: AuthenticatorBridgeManager, + settingsRepository: SettingsRepository, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: ManualCodeEntryState( + code = "", + issuer = "", + dialog = null, + buttonState = deriveButtonState( + sharedCodesState = authenticatorRepository.sharedCodesStateFlow.value, + defaultSaveOption = settingsRepository.defaultSaveOption, + ), + ), +) { + override fun handleAction(action: ManualCodeEntryAction) { + when (action) { + is ManualCodeEntryAction.CloseClick -> handleCloseClick() + is ManualCodeEntryAction.CodeTextChange -> handleCodeTextChange(action) + is ManualCodeEntryAction.IssuerTextChange -> handleIssuerTextChange(action) + is ManualCodeEntryAction.ScanQrCodeTextClick -> handleScanQrCodeTextClick() + is ManualCodeEntryAction.SettingsClick -> handleSettingsClick() + ManualCodeEntryAction.DismissDialog -> { + handleDialogDismiss() + } + + ManualCodeEntryAction.SaveLocallyClick -> handleSaveLocallyClick() + ManualCodeEntryAction.SaveToBitwardenClick -> handleSaveToBitwardenClick() + } + } + + private fun handleDialogDismiss() { + mutableStateFlow.update { it.copy(dialog = null) } + } + + private fun handleIssuerTextChange(action: ManualCodeEntryAction.IssuerTextChange) { + mutableStateFlow.update { + it.copy(issuer = action.issuer) + } + } + + private fun handleCloseClick() { + sendEvent(ManualCodeEntryEvent.NavigateBack) + } + + private fun handleCodeTextChange(action: ManualCodeEntryAction.CodeTextChange) { + mutableStateFlow.update { + it.copy(code = action.code) + } + } + + private fun handleSaveLocallyClick() = handleCodeSubmit(saveToBitwarden = false) + + private fun handleSaveToBitwardenClick() = handleCodeSubmit(saveToBitwarden = true) + + private fun handleCodeSubmit(saveToBitwarden: Boolean) { + val isSteamCode = state.code.startsWith(TotpCodeManager.STEAM_CODE_PREFIX) + val sanitizedCode = state.code + .replace(" ", "") + .replace(TotpCodeManager.STEAM_CODE_PREFIX, "") + if (sanitizedCode.isBlank()) { + showErrorDialog(R.string.key_is_required.asText()) + return + } + + if (!sanitizedCode.isBase32()) { + showErrorDialog(R.string.key_is_invalid.asText()) + return + } + + if (state.issuer.isBlank()) { + showErrorDialog(R.string.name_is_required.asText()) + return + } + + if (saveToBitwarden) { + // Save to Bitwarden by kicking off save to Bitwarden flow: + saveValidCodeToBitwarden(sanitizedCode) + } else { + // Save locally by giving entity to AuthRepository and navigating back: + saveValidCodeLocally(sanitizedCode, isSteamCode) + } + } + + private fun saveValidCodeToBitwarden(sanitizedCode: String) { + val didLaunchSaveToBitwarden = authenticatorBridgeManager + .startAddTotpLoginItemFlow( + totpUri = "otpauth://totp/?secret=$sanitizedCode&issuer=${state.issuer}", + ) + if (!didLaunchSaveToBitwarden) { + mutableStateFlow.update { + it.copy( + dialog = ManualCodeEntryState.DialogState.Error( + title = R.string.something_went_wrong.asText(), + message = R.string.please_try_again.asText(), + ), + ) + } + } else { + sendEvent(ManualCodeEntryEvent.NavigateBack) + } + } + + private fun saveValidCodeLocally( + sanitizedCode: String, + isSteamCode: Boolean, + ) { + viewModelScope.launch { + authenticatorRepository.createItem( + AuthenticatorItemEntity( + id = UUID.randomUUID().toString(), + key = sanitizedCode, + issuer = state.issuer, + accountName = "", + userId = null, + type = if (isSteamCode) { + AuthenticatorItemType.STEAM + } else { + AuthenticatorItemType.TOTP + }, + favorite = false, + ), + ) + sendEvent( + event = ManualCodeEntryEvent.ShowToast( + message = R.string.verification_code_added.asText(), + ), + ) + sendEvent( + event = ManualCodeEntryEvent.NavigateBack, + ) + } + } + + private fun handleScanQrCodeTextClick() { + sendEvent(ManualCodeEntryEvent.NavigateToQrCodeScreen) + } + + private fun handleSettingsClick() { + sendEvent(ManualCodeEntryEvent.NavigateToAppSettings) + } + + private fun showErrorDialog(message: Text) { + mutableStateFlow.update { + it.copy( + dialog = ManualCodeEntryState.DialogState.Error( + message = message, + ), + ) + } + } +} + +private fun deriveButtonState( + sharedCodesState: SharedVerificationCodesState, + defaultSaveOption: DefaultSaveOption, +): ManualCodeEntryState.ButtonState { + // If syncing with Bitwarden is not enabled, show local save only: + if (!sharedCodesState.isSyncWithBitwardenEnabled) { + return ManualCodeEntryState.ButtonState.LocalOnly + } + // Otherwise, show save options based on user's preferences: + return when (defaultSaveOption) { + DefaultSaveOption.NONE -> ManualCodeEntryState.ButtonState.SaveToBitwardenPrimary + DefaultSaveOption.BITWARDEN_APP -> ManualCodeEntryState.ButtonState.SaveToBitwardenPrimary + DefaultSaveOption.LOCAL -> ManualCodeEntryState.ButtonState.SaveLocallyPrimary + } +} + +/** + * Models state of the manual entry screen. + */ +@Parcelize +data class ManualCodeEntryState( + val code: String, + val issuer: String, + val dialog: DialogState?, + val buttonState: ButtonState, +) : Parcelable { + + /** + * Models dialog states for [ManualCodeEntryViewModel]. + */ + @Parcelize + sealed class DialogState : Parcelable { + + /** + * Show an error dialog with an optional [title], and a [message]. + */ + @Parcelize + data class Error( + val title: Text? = null, + val message: Text, + ) : DialogState() + + /** + * Show a loading dialog. + */ + @Parcelize + data class Loading( + val message: Text, + ) : DialogState() + } + + /** + * Models what variation of button states should be shown. + */ + @Parcelize + sealed class ButtonState : Parcelable { + + /** + * Show only save locally option. + */ + @Parcelize + data object LocalOnly : ButtonState() + + /** + * Show both save locally and save to Bitwarden, with Bitwarden being the primary option. + */ + @Parcelize + data object SaveToBitwardenPrimary : ButtonState() + + /** + * Show both save locally and save to Bitwarden, with locally being the primary option. + */ + @Parcelize + data object SaveLocallyPrimary : ButtonState() + } +} + +/** + * Models events for the [ManualCodeEntryScreen]. + */ +sealed class ManualCodeEntryEvent { + + /** + * Navigate back. + */ + data object NavigateBack : ManualCodeEntryEvent() + + /** + * Navigate to the Qr code screen. + */ + data object NavigateToQrCodeScreen : ManualCodeEntryEvent() + + /** + * Navigate to the app settings. + */ + data object NavigateToAppSettings : ManualCodeEntryEvent() + + /** + * Show a toast with the given [message]. + */ + data class ShowToast(val message: Text) : ManualCodeEntryEvent() +} + +/** + * Models actions for the [ManualCodeEntryScreen]. + */ +sealed class ManualCodeEntryAction { + + /** + * User clicked close. + */ + data object CloseClick : ManualCodeEntryAction() + + /** + * The user clicked the save locally button. + */ + data object SaveLocallyClick : ManualCodeEntryAction() + + /** + * Th user clicked the save to Bitwarden button. + */ + data object SaveToBitwardenClick : ManualCodeEntryAction() + + /** + * The user has changed the code text. + */ + data class CodeTextChange(val code: String) : ManualCodeEntryAction() + + /** + * The use has changed the issuer text. + */ + data class IssuerTextChange(val issuer: String) : ManualCodeEntryAction() + + /** + * The text to switch to QR code scanning is clicked. + */ + data object ScanQrCodeTextClick : ManualCodeEntryAction() + + /** + * The action for the user clicking the settings button. + */ + data object SettingsClick : ManualCodeEntryAction() + + /** + * The user has dismissed the dialog. + */ + data object DismissDialog : ManualCodeEntryAction() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/SaveManualCodeButtons.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/SaveManualCodeButtons.kt new file mode 100644 index 0000000000..abdfac93eb --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/SaveManualCodeButtons.kt @@ -0,0 +1,81 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledButton +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenOutlinedButton + +/** + * Displays save buttons for saving a manually entered code. + * + * @param state State of the buttons to show. + * @param onSaveLocallyClick Callback invoked when the user clicks save locally. + * @param onSaveToBitwardenClick Callback invoked when the user clicks save to Bitwarden. + */ +@Composable +fun SaveManualCodeButtons( + state: ManualCodeEntryState.ButtonState, + onSaveLocallyClick: () -> Unit, + onSaveToBitwardenClick: () -> Unit, +) { + + when (state) { + ManualCodeEntryState.ButtonState.LocalOnly -> { + BitwardenFilledTonalButton( + label = stringResource(id = R.string.add_code), + onClick = onSaveLocallyClick, + modifier = Modifier + .semantics { testTag = "AddCodeButton" } + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + + ManualCodeEntryState.ButtonState.SaveLocallyPrimary -> { + Column { + BitwardenFilledButton( + label = stringResource(id = R.string.save_here), + onClick = onSaveLocallyClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenOutlinedButton( + label = stringResource(R.string.save_to_bitwarden), + onClick = onSaveToBitwardenClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + + ManualCodeEntryState.ButtonState.SaveToBitwardenPrimary -> { + Column { + BitwardenFilledButton( + label = stringResource(id = R.string.save_to_bitwarden), + onClick = onSaveToBitwardenClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenOutlinedButton( + label = stringResource(R.string.save_here), + onClick = onSaveLocallyClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt new file mode 100644 index 0000000000..4cd01caba0 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt @@ -0,0 +1,36 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.navbar + +import androidx.navigation.NavGraphBuilder +import com.bitwarden.authenticator.ui.platform.base.util.composableWithStayTransitions + +const val AUTHENTICATOR_NAV_BAR_ROUTE: String = "AuthenticatorNavBarRoute" + +/** + * Add the authenticator nav bar to the nav graph. + */ +@Suppress("LongParameterList") +fun NavGraphBuilder.authenticatorNavBarDestination( + onNavigateBack: () -> Unit, + onNavigateToSearch: () -> Unit, + onNavigateToQrCodeScanner: () -> Unit, + onNavigateToManualKeyEntry: () -> Unit, + onNavigateToEditItem: (itemId: String) -> Unit, + onNavigateToExport: () -> Unit, + onNavigateToImport: () -> Unit, + onNavigateToTutorial: () -> Unit, +) { + composableWithStayTransitions( + route = AUTHENTICATOR_NAV_BAR_ROUTE, + ) { + AuthenticatorNavBarScreen( + onNavigateBack = onNavigateBack, + onNavigateToSearch = onNavigateToSearch, + onNavigateToQrCodeScanner = onNavigateToQrCodeScanner, + onNavigateToManualKeyEntry = onNavigateToManualKeyEntry, + onNavigateToEditItem = onNavigateToEditItem, + onNavigateToExport = onNavigateToExport, + onNavigateToImport = onNavigateToImport, + onNavigateToTutorial = onNavigateToTutorial, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt new file mode 100644 index 0000000000..ba16fc7da1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt @@ -0,0 +1,342 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.navbar + +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.ITEM_LISTING_GRAPH_ROUTE +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.ITEM_LIST_ROUTE +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.itemListingGraph +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.navigateToItemListGraph +import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.base.util.max +import com.bitwarden.authenticator.ui.platform.base.util.toDp +import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.components.scrim.BitwardenAnimatedScrim +import com.bitwarden.authenticator.ui.platform.feature.settings.SETTINGS_GRAPH_ROUTE +import com.bitwarden.authenticator.ui.platform.feature.settings.navigateToSettingsGraph +import com.bitwarden.authenticator.ui.platform.theme.RootTransitionProviders +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.Parcelize + +/** + * Top level composable for the authenticator screens. + */ +@Composable +fun AuthenticatorNavBarScreen( + viewModel: AuthenticatorNavBarViewModel = hiltViewModel(), + navController: NavHostController = rememberNavController(), + onNavigateBack: () -> Unit, + onNavigateToSearch: () -> Unit, + onNavigateToQrCodeScanner: () -> Unit, + onNavigateToManualKeyEntry: () -> Unit, + onNavigateToEditItem: (itemId: String) -> Unit, + onNavigateToExport: () -> Unit, + onNavigateToImport: () -> Unit, + onNavigateToTutorial: () -> Unit, +) { + EventsEffect(viewModel = viewModel) { event -> + navController.apply { + val navOptions = navController.authenticatorNavBarScreenNavOptions() + when (event) { + AuthenticatorNavBarEvent.NavigateToSettings -> { + navigateToSettingsGraph(navOptions) + } + + AuthenticatorNavBarEvent.NavigateToVerificationCodes -> { + navigateToItemListGraph(navOptions) + } + } + } + } + + LaunchedEffect(Unit) { + navController + .currentBackStackEntryFlow + .onEach { + viewModel.trySendAction(AuthenticatorNavBarAction.BackStackUpdate) + } + .launchIn(this) + } + + AuthenticatorNavBarScaffold( + navController = navController, + verificationTabClickedAction = { + viewModel.trySendAction(AuthenticatorNavBarAction.VerificationCodesTabClick) + }, + settingsTabClickedAction = { + viewModel.trySendAction(AuthenticatorNavBarAction.SettingsTabClick) + }, + navigateBack = onNavigateBack, + navigateToSearch = onNavigateToSearch, + navigateToQrCodeScanner = onNavigateToQrCodeScanner, + navigateToManualKeyEntry = onNavigateToManualKeyEntry, + navigateToEditItem = onNavigateToEditItem, + navigateToExport = onNavigateToExport, + navigateToImport = onNavigateToImport, + navigateToTutorial = onNavigateToTutorial, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AuthenticatorNavBarScaffold( + navController: NavHostController, + verificationTabClickedAction: () -> Unit, + settingsTabClickedAction: () -> Unit, + navigateBack: () -> Unit, + navigateToSearch: () -> Unit, + navigateToQrCodeScanner: () -> Unit, + navigateToManualKeyEntry: () -> Unit, + navigateToEditItem: (itemId: String) -> Unit, + navigateToExport: () -> Unit, + navigateToImport: () -> Unit, + navigateToTutorial: () -> Unit, +) { + BitwardenScaffold( + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.statusBars), + bottomBar = { + Box { + var appBarHeightPx by remember { mutableIntStateOf(0) } + AuthenticatorBottomAppBar( + modifier = Modifier + .onGloballyPositioned { + appBarHeightPx = it.size.height + }, + navController = navController, + verificationCodesTabClickedAction = verificationTabClickedAction, + settingsTabClickedAction = settingsTabClickedAction, + ) + BitwardenAnimatedScrim( + isVisible = false, + onClick = { + // Do nothing + }, + modifier = Modifier + .fillMaxWidth() + .height(appBarHeightPx.toDp()), + ) + } + }, + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = ITEM_LISTING_GRAPH_ROUTE, + modifier = Modifier + .consumeWindowInsets(WindowInsets.navigationBars) + .consumeWindowInsets(WindowInsets.ime) + .padding(innerPadding.max(WindowInsets.ime)), + enterTransition = RootTransitionProviders.Enter.fadeIn, + exitTransition = RootTransitionProviders.Exit.fadeOut, + popEnterTransition = RootTransitionProviders.Enter.fadeIn, + popExitTransition = RootTransitionProviders.Exit.fadeOut, + ) { + itemListingGraph( + navController = navController, + navigateBack = navigateBack, + navigateToSearch = navigateToSearch, + navigateToQrCodeScanner = navigateToQrCodeScanner, + navigateToManualKeyEntry = navigateToManualKeyEntry, + navigateToEditItem = navigateToEditItem, + navigateToExport = navigateToExport, + navigateToImport = navigateToImport, + navigateToTutorial = navigateToTutorial, + ) + } + } +} + +@Suppress("LongMethod") +@Composable +private fun AuthenticatorBottomAppBar( + navController: NavController, + verificationCodesTabClickedAction: () -> Unit, + settingsTabClickedAction: () -> Unit, + modifier: Modifier = Modifier, +) { + BottomAppBar( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + modifier = modifier, + ) { + val destinations = listOf( + AuthenticatorNavBarTab.VerificationCodes, + AuthenticatorNavBarTab.Settings, + ) + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + destinations.forEach { destination -> + val isSelected = currentDestination?.hierarchy?.any { + it.route == destination.route + } == true + + NavigationBarItem( + icon = { + Icon( + painter = painterResource( + id = if (isSelected) { + destination.iconResSelected + } else { + destination.iconRes + }, + ), + contentDescription = stringResource( + id = destination.contentDescriptionRes, + ), + ) + }, + label = { + Text( + text = stringResource(id = destination.labelRes), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + selected = isSelected, + onClick = { + when (destination) { + AuthenticatorNavBarTab.VerificationCodes -> { + verificationCodesTabClickedAction() + } + + AuthenticatorNavBarTab.Settings -> { + settingsTabClickedAction() + } + } + }, + colors = NavigationBarItemDefaults.colors( + indicatorColor = MaterialTheme.colorScheme.secondaryContainer, + selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + unselectedIconColor = MaterialTheme.colorScheme.onSurface, + selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, + unselectedTextColor = MaterialTheme.colorScheme.onSurface, + ), + modifier = Modifier.semantics { testTag = destination.testTag }, + ) + } + } +} + +/** + * Represents the different tabs available in the navigation bar + * for the authenticator screens. + * + * Each tab is modeled with properties that provide information on: + * - Regular icon resource + * - Icon resource when selected + * and other essential UI and navigational data. + * + * @property iconRes The resource ID for the regular (unselected) icon representing the tab. + * @property iconResSelected The resource ID for the icon representing the tab when it's selected. + */ +@Parcelize +private sealed class AuthenticatorNavBarTab : Parcelable { + /** + * The resource ID for the icon representing the tab when it is selected. + */ + abstract val iconResSelected: Int + + /** + * Resource id for the icon representing the tab. + */ + abstract val iconRes: Int + + /** + * Resource id for the label describing the tab. + */ + abstract val labelRes: Int + + /** + * Resource id for the content description describing the tab. + */ + abstract val contentDescriptionRes: Int + + /** + * Route of the tab. + */ + abstract val route: String + + /** + * The test tag of the tab. + */ + abstract val testTag: String + + /** + * Show the Verification Codes screen. + */ + @Parcelize + data object VerificationCodes : AuthenticatorNavBarTab() { + override val iconResSelected get() = R.drawable.ic_verification_codes_filled + override val iconRes get() = R.drawable.ic_verification_codes + override val labelRes get() = R.string.verification_codes + override val contentDescriptionRes get() = R.string.verification_codes + override val route get() = ITEM_LIST_ROUTE + override val testTag get() = "VerificationCodesTab" + } + + /** + * Show the Settings screen. + */ + @Parcelize + data object Settings : AuthenticatorNavBarTab() { + override val iconResSelected get() = R.drawable.ic_settings_filled + override val iconRes get() = R.drawable.ic_settings + override val labelRes get() = R.string.settings + override val contentDescriptionRes get() = R.string.settings + + // TODO: Replace with constant when settings screen is complete. + override val route get() = SETTINGS_GRAPH_ROUTE + override val testTag get() = "SettingsTab" + } +} + +/** + * Helper function to generate [NavOptions] for [AuthenticatorNavBarScreen]. + */ +private fun NavController.authenticatorNavBarScreenNavOptions(): NavOptions = + navOptions { + popUpTo(graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarViewModel.kt new file mode 100644 index 0000000000..beee80fa41 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarViewModel.kt @@ -0,0 +1,78 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.navbar + +import com.bitwarden.authenticator.data.auth.repository.AuthRepository +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * View model for the authenticator nav bar screen. Manages bottom tab navigation within the + * application. + */ +@HiltViewModel +class AuthenticatorNavBarViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : + BaseViewModel( + initialState = Unit, + ) { + + override fun handleAction(action: AuthenticatorNavBarAction) { + when (action) { + AuthenticatorNavBarAction.SettingsTabClick -> { + handleSettingsClick() + } + + AuthenticatorNavBarAction.VerificationCodesTabClick -> { + handleVerificationCodesTabClick() + } + + AuthenticatorNavBarAction.BackStackUpdate -> { + authRepository.updateLastActiveTime() + } + } + } + + private fun handleSettingsClick() { + sendEvent(AuthenticatorNavBarEvent.NavigateToSettings) + } + + private fun handleVerificationCodesTabClick() { + sendEvent(AuthenticatorNavBarEvent.NavigateToVerificationCodes) + } +} + +/** + * Models events for the [AuthenticatorNavBarViewModel]. + */ +sealed class AuthenticatorNavBarEvent { + /** + * Navigate to the verification codes screen. + */ + data object NavigateToVerificationCodes : AuthenticatorNavBarEvent() + + /** + * Navigate to the settings screen. + */ + data object NavigateToSettings : AuthenticatorNavBarEvent() +} + +/** + * Models actions for the bottom tab of. + */ +sealed class AuthenticatorNavBarAction { + /** + * User clicked the verification codes tab. + */ + data object VerificationCodesTabClick : AuthenticatorNavBarAction() + + /** + * User clicked the settings tab. + */ + data object SettingsTabClick : AuthenticatorNavBarAction() + + /** + * Indicates the backstack has changed. + */ + data object BackStackUpdate : AuthenticatorNavBarAction() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/ChooseSaveLocationDialog.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/ChooseSaveLocationDialog.kt new file mode 100644 index 0000000000..ad6cb6e47d --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/ChooseSaveLocationDialog.kt @@ -0,0 +1,119 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenTextButton +import com.bitwarden.authenticator.ui.platform.components.toggle.BitwardenWideSwitch +import com.bitwarden.authenticator.ui.platform.components.util.maxDialogHeight +import com.bitwarden.authenticator.ui.platform.components.util.maxDialogWidth + +/** + * Displays a dialog asking the user where they would like to save a new QR code. + * + * @param onSaveHereClick Invoked when the user clicks "Save here". The boolean parameter is true if + * the user check "Save option as default". + * @param onTakeMeToBitwardenClick Invoked when the user clicks "Take me to Bitwarden". The boolean + * parameter is true if the user checked "Save option as default". + */ +@Composable +@OptIn(ExperimentalLayoutApi::class) +@Suppress("LongMethod") +fun ChooseSaveLocationDialog( + onSaveHereClick: (Boolean) -> Unit, + onTakeMeToBitwardenClick: (Boolean) -> Unit, +) { + Dialog( + onDismissRequest = { }, // Not dismissible + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + var isSaveAsDefaultChecked by remember { mutableStateOf(false) } + val configuration = LocalConfiguration.current + Column( + modifier = Modifier + .requiredHeightIn( + max = configuration.maxDialogHeight, + ) + .requiredWidthIn( + max = configuration.maxDialogWidth, + ) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(28.dp), + ), + horizontalAlignment = Alignment.End, + ) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + modifier = Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth(), + text = stringResource(R.string.verification_code_created), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + modifier = Modifier + .weight(1f, fill = false) + .padding(horizontal = 24.dp) + .fillMaxWidth(), + text = stringResource(R.string.choose_save_location_message), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(16.dp)) + BitwardenWideSwitch( + modifier = Modifier.padding(horizontal = 16.dp), + label = stringResource(R.string.save_option_as_default), + isChecked = isSaveAsDefaultChecked, + onCheckedChange = { isSaveAsDefaultChecked = !isSaveAsDefaultChecked }, + ) + Spacer(Modifier.height(16.dp)) + FlowRow( + horizontalArrangement = Arrangement.End, + modifier = Modifier.padding(horizontal = 8.dp), + ) { + BitwardenTextButton( + modifier = Modifier + .padding(horizontal = 4.dp), + label = stringResource(R.string.save_here), + labelTextColor = MaterialTheme.colorScheme.primary, + onClick = { onSaveHereClick.invoke(isSaveAsDefaultChecked) }, + ) + BitwardenTextButton( + modifier = Modifier + .padding(horizontal = 4.dp), + label = stringResource(R.string.save_to_bitwarden), + labelTextColor = MaterialTheme.colorScheme.primary, + onClick = { onTakeMeToBitwardenClick.invoke(isSaveAsDefaultChecked) }, + ) + } + Spacer(modifier = Modifier.height(24.dp)) + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanNavigation.kt new file mode 100644 index 0000000000..f84f1be77f --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanNavigation.kt @@ -0,0 +1,34 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.bitwarden.authenticator.ui.platform.base.util.composableWithSlideTransitions + +private const val QR_CODE_SCAN_ROUTE: String = "qr_code_scan" + +/** + * Add the QR code scan screen to the nav graph. + */ +fun NavGraphBuilder.qrCodeScanDestination( + onNavigateBack: () -> Unit, + onNavigateToManualCodeEntryScreen: () -> Unit, +) { + composableWithSlideTransitions( + route = QR_CODE_SCAN_ROUTE, + ) { + QrCodeScanScreen( + onNavigateToManualCodeEntryScreen = onNavigateToManualCodeEntryScreen, + onNavigateBack = onNavigateBack, + ) + } +} + +/** + * Navigate to the QR code scan screen. + */ +fun NavController.navigateToQrCodeScanScreen( + navOptions: NavOptions? = null, +) { + this.navigate(QR_CODE_SCAN_ROUTE, navOptions) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt new file mode 100644 index 0000000000..e8988edf4a --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt @@ -0,0 +1,502 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan + +import android.content.res.Configuration +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.Toast +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.util.QrCodeAnalyzer +import com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.util.QrCodeAnalyzerImpl +import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog +import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.theme.LocalNonMaterialColors +import com.bitwarden.authenticator.ui.platform.theme.clickableSpanStyle +import java.util.concurrent.Executors +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * The screen to scan QR codes for the application. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QrCodeScanScreen( + onNavigateBack: () -> Unit, + viewModel: QrCodeScanViewModel = hiltViewModel(), + qrCodeAnalyzer: QrCodeAnalyzer = QrCodeAnalyzerImpl(), + onNavigateToManualCodeEntryScreen: () -> Unit, +) { + qrCodeAnalyzer.onQrCodeScanned = remember(viewModel) { + { viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(it)) } + } + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val orientation = LocalConfiguration.current.orientation + val context = LocalContext.current + + val onEnterCodeManuallyClick = remember(viewModel) { + { viewModel.trySendAction(QrCodeScanAction.ManualEntryTextClick) } + } + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is QrCodeScanEvent.ShowToast -> { + Toast + .makeText(context, event.message.invoke(context.resources), Toast.LENGTH_SHORT) + .show() + } + + is QrCodeScanEvent.NavigateBack -> { + onNavigateBack.invoke() + } + + is QrCodeScanEvent.NavigateToManualCodeEntry -> { + onNavigateToManualCodeEntryScreen.invoke() + } + } + } + + BitwardenScaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.scan_qr_code), + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(QrCodeScanAction.CloseClick) } + }, + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + ) + }, + ) { innerPadding -> + CameraPreview( + cameraErrorReceive = remember(viewModel) { + { viewModel.trySendAction(QrCodeScanAction.CameraSetupErrorReceive) } + }, + qrCodeAnalyzer = qrCodeAnalyzer, + modifier = Modifier.padding(innerPadding), + ) + + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + LandscapeQRCodeContent( + onEnterCodeManuallyClick = onEnterCodeManuallyClick, + modifier = Modifier.padding(innerPadding), + ) + } + + else -> { + PortraitQRCodeContent( + onEnterCodeManuallyClick = onEnterCodeManuallyClick, + modifier = Modifier.padding(innerPadding), + ) + } + } + + when (state.dialog) { + QrCodeScanState.DialogState.ChooseSaveLocation -> { + ChooseSaveLocationDialog( + onSaveHereClick = remember(viewModel) { + { + viewModel.trySendAction(QrCodeScanAction.SaveLocallyClick(it)) + } + }, + onTakeMeToBitwardenClick = remember(viewModel) { + { + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(it)) + } + }, + ) + } + + QrCodeScanState.DialogState.SaveToBitwardenError -> BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = R.string.something_went_wrong.asText(), + message = R.string.please_try_again.asText(), + ), + onDismissRequest = remember(viewModel) { + { + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenErrorDismiss) + } + }, + ) + + null -> Unit + } + } +} + +@Composable +private fun PortraitQRCodeContent( + onEnterCodeManuallyClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + QrCodeSquare( + squareOutlineSize = 250.dp, + modifier = Modifier.weight(2f), + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround, + modifier = Modifier + .weight(1f) + .fillMaxSize() + .background(color = Color.Black.copy(alpha = .4f)) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(id = R.string.point_your_camera_at_the_qr_code), + textAlign = TextAlign.Center, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + BottomClickableText( + onEnterCodeManuallyClick = onEnterCodeManuallyClick, + ) + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } +} + +@Composable +private fun LandscapeQRCodeContent( + onEnterCodeManuallyClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + QrCodeSquare( + squareOutlineSize = 200.dp, + modifier = Modifier.weight(2f), + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround, + modifier = Modifier + .weight(1f) + .fillMaxSize() + .background(color = Color.Black.copy(alpha = .4f)) + .padding(horizontal = 16.dp) + .navigationBarsPadding() + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(id = R.string.point_your_camera_at_the_qr_code), + textAlign = TextAlign.Center, + color = Color.White, + style = MaterialTheme.typography.bodySmall, + ) + + BottomClickableText( + onEnterCodeManuallyClick = onEnterCodeManuallyClick, + ) + } + } +} + +@Suppress("LongMethod", "TooGenericExceptionCaught") +@Composable +private fun CameraPreview( + cameraErrorReceive: () -> Unit, + qrCodeAnalyzer: QrCodeAnalyzer, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(null) } + + val previewView = remember { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + layoutParams = ViewGroup.LayoutParams( + MATCH_PARENT, + MATCH_PARENT, + ) + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + } + } + + val imageAnalyzer = remember(qrCodeAnalyzer) { + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .apply { + setAnalyzer( + Executors.newSingleThreadExecutor(), + qrCodeAnalyzer, + ) + } + } + + val preview = Preview.Builder() + .build() + .apply { setSurfaceProvider(previewView.surfaceProvider) } + + // Unbind from the camera provider when we leave the screen. + DisposableEffect(Unit) { + onDispose { + cameraProvider?.unbindAll() + } + } + + // Set up the camera provider on a background thread. This is necessary because + // ProcessCameraProvider.getInstance returns a ListenableFuture. For an example see + // https://github.com/JetBrains/compose-multiplatform/blob/1c7154b975b79901f40f28278895183e476ed36d/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt#L85 + LaunchedEffect(imageAnalyzer) { + try { + cameraProvider = suspendCoroutine { continuation -> + ProcessCameraProvider.getInstance(context).also { future -> + future.addListener( + { continuation.resume(future.get()) }, + Executors.newSingleThreadExecutor(), + ) + } + } + + cameraProvider?.unbindAll() + cameraProvider?.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageAnalyzer, + ) + } catch (e: Exception) { + cameraErrorReceive() + } + } + + AndroidView( + factory = { previewView }, + modifier = modifier, + ) +} + +/** + * UI for the blue QR code square that is drawn onto the screen. + */ +@Suppress("MagicNumber", "LongMethod") +@Composable +private fun QrCodeSquare( + modifier: Modifier = Modifier, + squareOutlineSize: Dp, +) { + val color = MaterialTheme.colorScheme.primary + + Box( + contentAlignment = Alignment.Center, + modifier = modifier, + ) { + Canvas( + modifier = Modifier + .size(squareOutlineSize) + .padding(8.dp), + ) { + val strokeWidth = 3.dp.toPx() + + val squareSize = size.width + val strokeOffset = strokeWidth / 2 + val sideLength = (1f / 6) * squareSize + + drawIntoCanvas { canvas -> + canvas.nativeCanvas.apply { + // Draw upper top left. + drawLine( + color = color, + start = Offset(0f, strokeOffset), + end = Offset(sideLength, strokeOffset), + strokeWidth = strokeWidth, + ) + + // Draw lower top left. + drawLine( + color = color, + start = Offset(strokeOffset, strokeOffset), + end = Offset(strokeOffset, sideLength), + strokeWidth = strokeWidth, + ) + + // Draw upper top right. + drawLine( + color = color, + start = Offset(squareSize - sideLength, strokeOffset), + end = Offset(squareSize - strokeOffset, strokeOffset), + strokeWidth = strokeWidth, + ) + + // Draw lower top right. + drawLine( + color = color, + start = Offset(squareSize - strokeOffset, 0f), + end = Offset(squareSize - strokeOffset, sideLength), + strokeWidth = strokeWidth, + ) + + // Draw upper bottom right. + drawLine( + color = color, + start = Offset(squareSize - strokeOffset, squareSize), + end = Offset(squareSize - strokeOffset, squareSize - sideLength), + strokeWidth = strokeWidth, + ) + + // Draw lower bottom right. + drawLine( + color = color, + start = Offset(squareSize - strokeOffset, squareSize - strokeOffset), + end = Offset(squareSize - sideLength, squareSize - strokeOffset), + strokeWidth = strokeWidth, + ) + + // Draw upper bottom left. + drawLine( + color = color, + start = Offset(strokeOffset, squareSize), + end = Offset(strokeOffset, squareSize - sideLength), + strokeWidth = strokeWidth, + ) + + // Draw lower bottom left. + drawLine( + color = color, + start = Offset(0f, squareSize - strokeOffset), + end = Offset(sideLength, squareSize - strokeOffset), + strokeWidth = strokeWidth, + ) + } + } + } + } +} + +@Composable +private fun BottomClickableText( + onEnterCodeManuallyClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val cannotScanText = stringResource(id = R.string.cannot_scan_qr_code) + val enterKeyText = stringResource(id = R.string.enter_key_manually) + val clickableStyle = clickableSpanStyle() + val manualTextColor = LocalNonMaterialColors.current.qrCodeClickableText + + val customTitleLineBreak = LineBreak( + strategy = LineBreak.Strategy.Balanced, + strictness = LineBreak.Strictness.Strict, + wordBreak = LineBreak.WordBreak.Phrase, + ) + + val annotatedString = remember { + buildAnnotatedString { + withStyle(style = clickableStyle.copy(color = Color.White)) { + pushStringAnnotation( + tag = cannotScanText, + annotation = cannotScanText, + ) + append(cannotScanText) + } + + append(" ") + + withStyle(style = clickableStyle.copy(color = manualTextColor)) { + pushStringAnnotation(tag = enterKeyText, annotation = enterKeyText) + append(enterKeyText) + } + } + } + + ClickableText( + text = annotatedString, + style = MaterialTheme.typography.bodyMedium.copy( + textAlign = TextAlign.Center, + lineBreak = customTitleLineBreak, + ), + onClick = { offset -> + annotatedString + .getStringAnnotations( + tag = enterKeyText, + start = offset, + end = offset, + ) + .firstOrNull() + ?.let { onEnterCodeManuallyClick.invoke() } + }, + modifier = modifier + .semantics { + CustomAccessibilityAction( + label = enterKeyText, + action = { + onEnterCodeManuallyClick.invoke() + true + }, + ) + }, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt new file mode 100644 index 0000000000..1ac07290d4 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt @@ -0,0 +1,309 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan + +import android.net.Uri +import android.os.Parcelable +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.toUpperCase +import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult +import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.isBase32 +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +/** + * Handles [QrCodeScanAction], + * and launches [QrCodeScanEvent] for the [QrCodeScanScreen]. + */ +@HiltViewModel +@Suppress("TooManyFunctions") +class QrCodeScanViewModel @Inject constructor( + private val authenticatorBridgeManager: AuthenticatorBridgeManager, + private val authenticatorRepository: AuthenticatorRepository, + private val settingsRepository: SettingsRepository, +) : BaseViewModel( + initialState = QrCodeScanState(dialog = null), +) { + + /** + * Keeps track of a pending successful scan to support the case where the user is choosing + * default save location. + */ + private var pendingSuccessfulScan: TotpCodeResult.TotpCodeScan? = null + + override fun handleAction(action: QrCodeScanAction) { + when (action) { + is QrCodeScanAction.CloseClick -> handleCloseClick() + is QrCodeScanAction.ManualEntryTextClick -> handleManualEntryTextClick() + is QrCodeScanAction.CameraSetupErrorReceive -> handleCameraErrorReceive() + is QrCodeScanAction.QrCodeScanReceive -> handleQrCodeScanReceive(action) + QrCodeScanAction.SaveToBitwardenErrorDismiss -> handleSaveToBitwardenDismiss() + is QrCodeScanAction.SaveLocallyClick -> handleSaveLocallyClick(action) + is QrCodeScanAction.SaveToBitwardenClick -> handleSaveToBitwardenClick(action) + } + } + + private fun handleSaveToBitwardenClick(action: QrCodeScanAction.SaveToBitwardenClick) { + if (action.saveAsDefault) { + settingsRepository.defaultSaveOption = DefaultSaveOption.BITWARDEN_APP + } + pendingSuccessfulScan?.let { + saveCodeToBitwardenAndNavigateBack(it) + } + pendingSuccessfulScan = null + } + + private fun handleSaveLocallyClick(action: QrCodeScanAction.SaveLocallyClick) { + if (action.saveAsDefault) { + settingsRepository.defaultSaveOption = DefaultSaveOption.LOCAL + } + pendingSuccessfulScan?.let { + saveCodeLocallyAndNavigateBack(it) + } + pendingSuccessfulScan = null + } + + private fun handleSaveToBitwardenDismiss() { + mutableStateFlow.update { + it.copy(dialog = null) + } + } + + private fun handleCloseClick() { + sendEvent( + QrCodeScanEvent.NavigateBack, + ) + } + + private fun handleManualEntryTextClick() { + sendEvent( + QrCodeScanEvent.NavigateToManualCodeEntry, + ) + } + + private fun handleQrCodeScanReceive(action: QrCodeScanAction.QrCodeScanReceive) { + val scannedCode = action.qrCode + if (scannedCode.startsWith(TotpCodeManager.TOTP_CODE_PREFIX)) { + handleTotpUriReceive(scannedCode) + } else if (scannedCode.startsWith(TotpCodeManager.GOOGLE_EXPORT_PREFIX)) { + handleGoogleExportUriReceive(scannedCode) + } else { + authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) + sendEvent(QrCodeScanEvent.NavigateBack) + return + } + } + + // For more information: https://bitwarden.com/help/authenticator-keys/#support-for-more-parameters + private fun handleTotpUriReceive(scannedCode: String) { + val result = TotpCodeResult.TotpCodeScan(scannedCode) + val scannedCodeUri = Uri.parse(scannedCode) + val secretValue = scannedCodeUri + .getQueryParameter(TotpCodeManager.SECRET_PARAM) + .orEmpty() + .toUpperCase(Locale.current) + + if (secretValue.isEmpty() || !secretValue.isBase32()) { + authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) + sendEvent(QrCodeScanEvent.NavigateBack) + return + } + + val values = scannedCodeUri.queryParameterNames + // If the parameters are not valid, + if (!areParametersValid(scannedCode, values)) { + authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) + sendEvent(QrCodeScanEvent.NavigateBack) + return + } + if (authenticatorRepository.sharedCodesStateFlow.value.isSyncWithBitwardenEnabled) { + when (settingsRepository.defaultSaveOption) { + DefaultSaveOption.BITWARDEN_APP -> saveCodeToBitwardenAndNavigateBack(result) + DefaultSaveOption.LOCAL -> saveCodeLocallyAndNavigateBack(result) + + DefaultSaveOption.NONE -> { + pendingSuccessfulScan = result + mutableStateFlow.update { + it.copy( + dialog = QrCodeScanState.DialogState.ChooseSaveLocation, + ) + } + } + } + } else { + // Syncing with Bitwarden not enabled, save code locally: + saveCodeLocallyAndNavigateBack(result) + } + } + + private fun handleGoogleExportUriReceive(scannedCode: String) { + val uri = Uri.parse(scannedCode) + val encodedData = uri.getQueryParameter(TotpCodeManager.DATA_PARAM) + val result: TotpCodeResult = if (encodedData.isNullOrEmpty()) { + TotpCodeResult.CodeScanningError + } else { + TotpCodeResult.GoogleExportScan(encodedData) + } + authenticatorRepository.emitTotpCodeResult(result) + sendEvent(QrCodeScanEvent.NavigateBack) + } + + private fun handleCameraErrorReceive() { + sendEvent( + QrCodeScanEvent.NavigateToManualCodeEntry, + ) + } + + @Suppress("NestedBlockDepth", "ReturnCount", "MagicNumber") + private fun areParametersValid(scannedCode: String, parameters: Set): Boolean { + parameters.forEach { parameter -> + Uri.parse(scannedCode).getQueryParameter(parameter)?.let { value -> + when (parameter) { + TotpCodeManager.DIGITS_PARAM -> { + val digit = value.toInt() + if (digit > 10 || digit < 1) { + return false + } + } + + TotpCodeManager.PERIOD_PARAM -> { + val period = value.toInt() + if (period < 1) { + return false + } + } + + TotpCodeManager.ALGORITHM_PARAM -> { + val lowercaseAlgo = value.lowercase() + if (lowercaseAlgo != "sha1" && + lowercaseAlgo != "sha256" && + lowercaseAlgo != "sha512" + ) { + return false + } + } + } + } + } + return true + } + + private fun saveCodeToBitwardenAndNavigateBack(result: TotpCodeResult.TotpCodeScan) { + val didLaunchAddToBitwarden = + authenticatorBridgeManager.startAddTotpLoginItemFlow(result.code) + if (didLaunchAddToBitwarden) { + sendEvent(QrCodeScanEvent.NavigateBack) + } else { + mutableStateFlow.update { + it.copy(dialog = QrCodeScanState.DialogState.SaveToBitwardenError) + } + } + } + + private fun saveCodeLocallyAndNavigateBack(result: TotpCodeResult.TotpCodeScan) { + authenticatorRepository.emitTotpCodeResult(result) + sendEvent(QrCodeScanEvent.NavigateBack) + } +} + +/** + * Models state for [QrCodeScanViewModel]. + * + * @param dialog Dialog to be shown, or `null` if no dialog should be shown. + */ +@Parcelize +data class QrCodeScanState( + val dialog: DialogState?, +) : Parcelable { + + /** + * Models dialogs that can be shown on the QR Scan screen. + */ + sealed class DialogState : Parcelable { + /** + * Displays a prompt to choose save location for a newly scanned code. + */ + @Parcelize + data object ChooseSaveLocation : DialogState() + + /** + * Displays an error letting the user know that saving to bitwarden failed. + */ + @Parcelize + data object SaveToBitwardenError : DialogState() + } +} + +/** + * Models events for the [QrCodeScanScreen]. + */ +sealed class QrCodeScanEvent { + + /** + * Navigate back. + */ + data object NavigateBack : QrCodeScanEvent() + + /** + * Navigate to manual code entry screen. + */ + data object NavigateToManualCodeEntry : QrCodeScanEvent() + + /** + * Show a toast with the given [message]. + */ + data class ShowToast(val message: Text) : QrCodeScanEvent() +} + +/** + * Models actions for the [QrCodeScanScreen]. + */ +sealed class QrCodeScanAction { + + /** + * User clicked close. + */ + data object CloseClick : QrCodeScanAction() + + /** + * The user has scanned a QR code. + */ + data class QrCodeScanReceive(val qrCode: String) : QrCodeScanAction() + + /** + * The text to switch to manual entry is clicked. + */ + data object ManualEntryTextClick : QrCodeScanAction() + + /** + * The Camera is unable to be setup. + */ + data object CameraSetupErrorReceive : QrCodeScanAction() + + /** + * The user dismissed the Save to Bitwarden error dialog. + */ + data object SaveToBitwardenErrorDismiss : QrCodeScanAction() + + /** + * User clicked save to Bitwarden on the choose save location dialog. + * + * @param saveAsDefault Whether or not he user checked "Save as default". + */ + data class SaveToBitwardenClick(val saveAsDefault: Boolean) : QrCodeScanAction() + + /** + * User clicked save locally on the save to Bitwarden dialog. + * + * @param saveAsDefault Whether or not he user checked "Save as default". + */ + data class SaveLocallyClick(val saveAsDefault: Boolean) : QrCodeScanAction() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/util/QrCodeAnalyzer.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/util/QrCodeAnalyzer.kt new file mode 100644 index 0000000000..2711d03eff --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/util/QrCodeAnalyzer.kt @@ -0,0 +1,16 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.util + +import androidx.camera.core.ImageAnalysis +import androidx.compose.runtime.Stable + +/** + * An interface that is used to help scan QR codes. + */ +@Stable +interface QrCodeAnalyzer : ImageAnalysis.Analyzer { + + /** + * The method that is called once the code is scanned. + */ + var onQrCodeScanned: (String) -> Unit +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/util/QrCodeAnalyzerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/util/QrCodeAnalyzerImpl.kt new file mode 100644 index 0000000000..2decaeeec9 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/util/QrCodeAnalyzerImpl.kt @@ -0,0 +1,67 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.util + +import androidx.camera.core.ImageProxy +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.common.HybridBinarizer +import java.nio.ByteBuffer + +/** + * A class setup to handle image analysis so that we can use the Zxing library + * to scan QR codes and convert them to a string. + */ +class QrCodeAnalyzerImpl : QrCodeAnalyzer { + + /** + * This will ensure the result is only sent once as multiple images with a valid + * QR code can be sent for analysis. + */ + private var qrCodeRead = false + + override lateinit var onQrCodeScanned: (String) -> Unit + + override fun analyze(image: ImageProxy) { + if (qrCodeRead) { + return + } + + val source = PlanarYUVLuminanceSource( + image.planes[0].buffer.toByteArray(), + image.planes[0].rowStride, + image.height, + 0, + 0, + image.width, + image.height, + false, + ) + + val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) + try { + val result = MultiFormatReader().decode( + /* image = */ binaryBitmap, + /* hints = */ + mapOf( + DecodeHintType.POSSIBLE_FORMATS to arrayListOf(BarcodeFormat.QR_CODE), + DecodeHintType.ALSO_INVERTED to true, + ), + ) + + qrCodeRead = true + onQrCodeScanned(result.text) + } catch (ignored: NotFoundException) { + } finally { + image.close() + } + } +} + +/** + * This function helps us prepare the byte buffer to be read. + */ +private fun ByteBuffer.toByteArray(): ByteArray = + ByteArray(rewind().remaining()).also { get(it) } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchContent.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchContent.kt new file mode 100644 index 0000000000..3e4fbebb97 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchContent.kt @@ -0,0 +1,50 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.search + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.ui.authenticator.feature.search.handlers.SearchHandlers + +/** + * The content state for the item search screen. + */ +@Composable +fun ItemSearchContent( + viewState: ItemSearchState.ViewState.Content, + searchHandlers: SearchHandlers, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier) { + items(viewState.displayItems) { + VaultVerificationCodeItem( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + // There is some built-in padding to the menu button that makes up + // the visual difference here. + end = 12.dp, + ), + authCode = it.authCode, + issuer = it.title, + periodSeconds = it.periodSeconds, + timeLeftSeconds = it.timeLeftSeconds, + alertThresholdSeconds = it.alertThresholdSeconds, + supportingLabel = it.subtitle, + startIcon = it.startIcon, + onCopyClick = { searchHandlers.onItemClick(it.authCode) }, + onItemClick = { searchHandlers.onItemClick(it.authCode) }, + ) + } + + item { + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchEmptyContent.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchEmptyContent.kt new file mode 100644 index 0000000000..e6953a915a --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchEmptyContent.kt @@ -0,0 +1,62 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.search + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.R + +/** + * The empty state for the item search screen. + */ +@Composable +fun ItemSearchEmptyContent( + viewState: ItemSearchState.ViewState.Empty, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_search_24px), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(74.dp) + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + viewState.message?.let { + Text( + textAlign = TextAlign.Center, + modifier = Modifier + .semantics { testTag = "NoSearchResultsLabel" } + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = it(), + style = MaterialTheme.typography.bodyMedium, + ) + } + + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchNavigation.kt new file mode 100644 index 0000000000..929c9b3d10 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchNavigation.kt @@ -0,0 +1,29 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.search + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import com.bitwarden.authenticator.ui.platform.base.util.composableWithSlideTransitions + +const val ITEM_SEARCH_ROUTE = "item_search" + +/** + * Add item search destination to the nav graph. + */ +fun NavGraphBuilder.itemSearchDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions( + route = ITEM_SEARCH_ROUTE, + ) { + ItemSearchScreen( + onNavigateBack = onNavigateBack, + ) + } +} + +/** + * Navigate to the item search screen. + */ +fun NavController.navigateToSearch() { + navigate(route = ITEM_SEARCH_ROUTE) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchScreen.kt new file mode 100644 index 0000000000..beb3e029ea --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchScreen.kt @@ -0,0 +1,105 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.search + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.authenticator.feature.search.handlers.SearchHandlers +import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.base.util.bottomDivider +import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenSearchTopAppBar +import com.bitwarden.authenticator.ui.platform.components.appbar.NavigationIcon +import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold + +/** + * The search screen for authenticator items. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ItemSearchScreen( + viewModel: ItemSearchViewModel = hiltViewModel(), + onNavigateBack: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val searchHandlers = remember(viewModel) { SearchHandlers.create(viewModel) } + val context = LocalContext.current + val resources = context.resources + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is ItemSearchEvent.NavigateBack -> onNavigateBack() + is ItemSearchEvent.ShowToast -> { + Toast + .makeText(context, event.message(resources), Toast.LENGTH_SHORT) + .show() + } + } + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenSearchTopAppBar( + modifier = Modifier + .semantics { testTag = "SearchFieldEntry" } + .bottomDivider(), + searchTerm = state.searchTerm, + placeholder = stringResource(id = R.string.search_codes), + onSearchTermChange = searchHandlers.onSearchTermChange, + scrollBehavior = scrollBehavior, + navigationIcon = NavigationIcon( + navigationIcon = painterResource(id = R.drawable.ic_back), + navigationIconContentDescription = stringResource(id = R.string.back), + onNavigationIconClick = searchHandlers.onBackClick, + ), + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + val innerModifier = Modifier + .fillMaxSize() + .imePadding() + + when (val viewState = state.viewState) { + is ItemSearchState.ViewState.Content -> { + ItemSearchContent( + viewState = viewState, + searchHandlers = searchHandlers, + modifier = innerModifier, + ) + } + + is ItemSearchState.ViewState.Empty -> { + ItemSearchEmptyContent( + viewState = viewState, + modifier = innerModifier, + ) + } + } + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModel.kt new file mode 100644 index 0000000000..5ddae3bdc1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModel.kt @@ -0,0 +1,304 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.search + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.authenticator.repository.util.itemsOrEmpty +import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager +import com.bitwarden.authenticator.data.platform.repository.model.DataState +import com.bitwarden.authenticator.data.platform.util.SpecialCharWithPrecedenceComparator +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.base.util.removeDiacritics +import com.bitwarden.authenticator.ui.platform.components.model.IconData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * View model for the item search screen. + */ +@Suppress("TooManyFunctions") +@HiltViewModel +class ItemSearchViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val clipboardManager: BitwardenClipboardManager, + private val authenticatorRepository: AuthenticatorRepository, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: ItemSearchState( + searchTerm = "", + viewState = ItemSearchState.ViewState.Empty(message = null), + ), +) { + + init { + combine( + authenticatorRepository.getLocalVerificationCodesFlow(), + authenticatorRepository.sharedCodesStateFlow, + ) { localItems, sharedItems -> + ItemSearchAction.Internal.AuthenticatorDataReceive(localItems, sharedItems) + } + .onEach(::sendAction) + .launchIn(viewModelScope) + } + + override fun handleAction(action: ItemSearchAction) { + when (action) { + is ItemSearchAction.BackClick -> { + sendEvent(ItemSearchEvent.NavigateBack) + } + + is ItemSearchAction.SearchTermChange -> { + mutableStateFlow.update { it.copy(searchTerm = action.searchTerm) } + recalculateViewState() + } + + is ItemSearchAction.ItemClick -> { + clipboardManager.setText(action.authCode) + sendEvent( + event = ItemSearchEvent.ShowToast( + message = R.string.value_has_been_copied.asText(action.authCode), + ), + ) + } + + is ItemSearchAction.Internal.AuthenticatorDataReceive -> { + handleAuthenticatorDataReceive(action) + } + } + } + + private fun handleAuthenticatorDataReceive( + action: ItemSearchAction.Internal.AuthenticatorDataReceive, + ) { + action.localData.data?.let { localItems -> + val allItems = localItems + action.sharedData.itemsOrEmpty + updateStateWithAuthenticatorData(allItems) + } + } + + //region Utility Functions + private fun recalculateViewState() { + authenticatorRepository.getLocalVerificationCodesFlow() + .value + .data + ?.let { authenticatorData -> + val allItems = authenticatorData + + authenticatorRepository.sharedCodesStateFlow.value.itemsOrEmpty + updateStateWithAuthenticatorData( + authenticatorData = allItems, + ) + } + } + + private fun updateStateWithAuthenticatorData( + authenticatorData: List, + ) { + mutableStateFlow.update { currentState -> + currentState.copy( + searchTerm = currentState.searchTerm, + viewState = authenticatorData + .filterAndOrganize(state.searchTerm) + .toViewState(searchTerm = state.searchTerm), + ) + } + } + + private fun List.filterAndOrganize(searchTerm: String) = + if (searchTerm.isBlank()) { + emptyList() + } else { + this + .groupBy { it.matchedSearch(searchTerm) } + .flatMap { (priority, items) -> + when (priority) { + SortPriority.HIGH -> items.sortedBy { it.otpAuthUriLabel } + SortPriority.LOW -> items.sortedBy { it.otpAuthUriLabel } + null -> emptyList() + } + } + } + + @Suppress("MagicNumber") + private fun VerificationCodeItem.matchedSearch(searchTerm: String): SortPriority? { + val term = searchTerm.removeDiacritics() + val itemName = otpAuthUriLabel?.removeDiacritics() + val itemId = id.takeIf { term.length > 8 }.orEmpty().removeDiacritics() + val itemIssuer = issuer.orEmpty().removeDiacritics() + return when { + itemName?.contains(other = term, ignoreCase = true) ?: false -> SortPriority.HIGH + itemId.contains(other = term, ignoreCase = true) -> SortPriority.LOW + itemIssuer.contains(other = term, ignoreCase = true) -> SortPriority.LOW + else -> null + } + } + + private fun List.toViewState( + searchTerm: String, + ): ItemSearchState.ViewState = + when { + searchTerm.isEmpty() -> { + ItemSearchState.ViewState.Empty(message = null) + } + + isNotEmpty() -> { + ItemSearchState.ViewState.Content( + displayItems = toDisplayItemList() + .sortAlphabetically(), + ) + } + + else -> { + ItemSearchState.ViewState.Empty( + message = R.string.there_are_no_items_that_match_the_search.asText(), + ) + } + } + + private fun List.toDisplayItemList(): List = + this.map { + it.toDisplayItem() + } + + private fun VerificationCodeItem.toDisplayItem(): ItemSearchState.DisplayItem = + ItemSearchState.DisplayItem( + id = id, + authCode = code, + title = issuer ?: label ?: "--", + subtitle = if (issuer != null) { + // Only show label if it is not being used as the primary title: + label + } else { + null + }, + periodSeconds = periodSeconds, + timeLeftSeconds = timeLeftSeconds, + alertThresholdSeconds = 7, + startIcon = IconData.Local(iconRes = R.drawable.ic_login_item), + ) + + /** + * Sort a list of [ItemSearchState.DisplayItem] by their titles alphabetically giving digits and + * special characters higher precedence. + */ + @Suppress("MaxLineLength") + private fun List.sortAlphabetically() = + this.sortedWith { item1, item2 -> + SpecialCharWithPrecedenceComparator.compare( + item1.title.orEmpty(), + item2.title.orEmpty(), + ) + } + //endregion Utility Functions +} + +/** + * Represents the overall state for the [ItemSearchScreen]. + */ +@Parcelize +data class ItemSearchState( + val searchTerm: String, + val viewState: ViewState, +) : Parcelable { + /** + * Represents the specific view state for the search screen. + */ + sealed class ViewState : Parcelable { + + /** + * Show the populated state. + */ + @Parcelize + data class Content( + val displayItems: List, + ) : ViewState() + + /** + * Show the empty state. + */ + @Parcelize + data class Empty(val message: Text?) : ViewState() + } + + /** + * An item to be displayed. + */ + @Parcelize + data class DisplayItem( + val id: String, + val authCode: String, + val title: String, + val subtitle: String? = null, + val periodSeconds: Int, + val timeLeftSeconds: Int, + val alertThresholdSeconds: Int, + val startIcon: IconData, + ) : Parcelable +} + +/** + * Models actions for the [ItemSearchScreen]. + */ +sealed class ItemSearchAction { + /** + * User clicked the back button. + */ + data object BackClick : ItemSearchAction() + + /** + * User updated the search term. + */ + data class SearchTermChange(val searchTerm: String) : ItemSearchAction() + + /** + * User clicked a row item. + */ + data class ItemClick(val authCode: String) : ItemSearchAction() + + /** + * Models actions that the [ItemSearchViewModel] itself might send. + */ + sealed class Internal : ItemSearchAction() { + + /** + * Indicates authenticate data was received. + */ + data class AuthenticatorDataReceive( + val localData: DataState>, + val sharedData: SharedVerificationCodesState, + ) : Internal() + } +} + +/** + * Models events for the [ItemSearchScreen]. + */ +sealed class ItemSearchEvent { + + /** + * Navigate back to the previous screen. + */ + data object NavigateBack : ItemSearchEvent() + + /** + * Show a toast with the given [message]. + */ + data class ShowToast(val message: Text) : ItemSearchEvent() +} + +private enum class SortPriority { + HIGH, + LOW, +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/VaultVerificationCodeItem.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/VaultVerificationCodeItem.kt new file mode 100644 index 0000000000..cb6126e7ce --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/VaultVerificationCodeItem.kt @@ -0,0 +1,146 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.search + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.icon.BitwardenIcon +import com.bitwarden.authenticator.ui.platform.components.indicator.BitwardenCircularCountdownIndicator +import com.bitwarden.authenticator.ui.platform.components.model.IconData +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme + +/** + * The verification code item displayed to the user. + * + * @param authCode The code for the item. + * @param issuer The label for the item. + * @param periodSeconds The times span where the code is valid. + * @param timeLeftSeconds The seconds remaining until a new code is needed. + * @param startIcon The leading icon for the item. + * @param onCopyClick The lambda function to be invoked when the copy button is clicked. + * @param onItemClick The lambda function to be invoked when the item is clicked. + * @param modifier The modifier for the item. + * @param supportingLabel The supporting label for the item. + */ +@Suppress("LongMethod", "MagicNumber") +@Composable +fun VaultVerificationCodeItem( + authCode: String, + issuer: String?, + periodSeconds: Int, + timeLeftSeconds: Int, + alertThresholdSeconds: Int, + startIcon: IconData, + onCopyClick: () -> Unit, + onItemClick: () -> Unit, + modifier: Modifier = Modifier, + supportingLabel: String? = null, +) { + Row( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(color = MaterialTheme.colorScheme.primary), + onClick = onItemClick, + ) + .defaultMinSize(minHeight = 72.dp) + .padding(vertical = 8.dp) + .then(modifier), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + BitwardenIcon( + iconData = startIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.weight(1f), + ) { + if (!issuer.isNullOrEmpty()) { + Text( + text = issuer, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (!supportingLabel.isNullOrEmpty()) { + Text( + text = supportingLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + BitwardenCircularCountdownIndicator( + timeLeftSeconds = timeLeftSeconds, + periodSeconds = periodSeconds, + alertThresholdSeconds = alertThresholdSeconds, + ) + + Text( + text = authCode.chunked(3).joinToString(" "), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + IconButton( + onClick = onCopyClick, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_copy), + contentDescription = stringResource(id = R.string.copy), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + } + } +} + +@Suppress("MagicNumber") +@Preview(showBackground = true) +@Composable +private fun VerificationCodeItem_preview() { + AuthenticatorTheme { + VaultVerificationCodeItem( + startIcon = IconData.Local(R.drawable.ic_login_item), + issuer = "Sample Label", + supportingLabel = "Supporting Label", + authCode = "1234567890".chunked(3).joinToString(" "), + timeLeftSeconds = 15, + periodSeconds = 30, + onCopyClick = {}, + onItemClick = {}, + modifier = Modifier.padding(horizontal = 16.dp), + alertThresholdSeconds = 7, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/handlers/SearchHandlers.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/handlers/SearchHandlers.kt new file mode 100644 index 0000000000..b2c8a827e6 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/handlers/SearchHandlers.kt @@ -0,0 +1,33 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.search.handlers + +import com.bitwarden.authenticator.ui.authenticator.feature.search.ItemSearchAction +import com.bitwarden.authenticator.ui.authenticator.feature.search.ItemSearchViewModel + +/** + * A collection of delegate functions for managing actions within the context of the search screen. + */ +class SearchHandlers( + val onBackClick: () -> Unit, + val onItemClick: (String) -> Unit, + val onSearchTermChange: (String) -> Unit, +) { + /** + * Creates an instance of [SearchHandlers] by binding actions to the provided + * [ItemSearchViewModel]. + */ + @Suppress("UndocumentedPublicClass") + companion object { + /** + * Creates an instance of [SearchHandlers] by binding actions to the provided + * [ItemSearchViewModel]. + */ + fun create(viewModel: ItemSearchViewModel): SearchHandlers = + SearchHandlers( + onBackClick = { viewModel.trySendAction(ItemSearchAction.BackClick) }, + onItemClick = { viewModel.trySendAction(ItemSearchAction.ItemClick(it)) }, + onSearchTermChange = { + viewModel.trySendAction(ItemSearchAction.SearchTermChange(it)) + }, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/BaseViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/BaseViewModel.kt new file mode 100644 index 0000000000..ea05f98b59 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/BaseViewModel.kt @@ -0,0 +1,92 @@ +package com.bitwarden.authenticator.ui.platform.base + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +/** + * A base [ViewModel] that helps enforce the unidirectional data flow pattern and associated + * responsibilities of a typical ViewModel: + * + * - Maintaining and emitting a current state (of type [S]) with the given `initialState`. + * - Emitting one-shot events as needed (of type [E]). These should be rare and are typically + * reserved for things such as non-state based navigation. + * - Receiving actions (of type [A]) that may induce changes in the current state, trigger an + * event emission, or both. + */ +abstract class BaseViewModel( + initialState: S, +) : ViewModel() { + protected val mutableStateFlow: MutableStateFlow = MutableStateFlow(initialState) + private val eventChannel: Channel = Channel(capacity = Channel.UNLIMITED) + private val internalActionChannel: Channel = Channel(capacity = Channel.UNLIMITED) + + /** + * A helper that returns the current state of the view model. + */ + protected val state: S get() = mutableStateFlow.value + + /** + * A [StateFlow] representing state updates. + */ + val stateFlow: StateFlow = mutableStateFlow.asStateFlow() + + /** + * A [Flow] of one-shot events. These may be received and consumed by only a single consumer. + * Any additional consumers will receive no events. + */ + val eventFlow: Flow = eventChannel.receiveAsFlow() + + /** + * A [SendChannel] for sending actions to the ViewModel for processing. + */ + @Suppress("MemberVisibilityCanBePrivate") + val actionChannel: SendChannel = internalActionChannel + + init { + viewModelScope.launch { + internalActionChannel + .consumeAsFlow() + .collect { action -> + handleAction(action) + } + } + } + + /** + * Handles the given [action] in a synchronous manner. + * + * Any changes to internal state that first require asynchronous work should post a follow-up + * action that may be used to then update the state synchronously. + */ + protected abstract fun handleAction(action: A): Unit + + /** + * Convenience method for sending an action to the [actionChannel]. + */ + fun trySendAction(action: A) { + actionChannel.trySend(action) + } + + /** + * Helper method for sending an internal action. + */ + protected suspend fun sendAction(action: A) { + actionChannel.send(action) + } + + /** + * Helper method for sending an event. + */ + protected fun sendEvent(event: E) { + viewModelScope.launch { eventChannel.send(event) } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/DensityExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/DensityExtensions.kt new file mode 100644 index 0000000000..a0fccdea3d --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/DensityExtensions.kt @@ -0,0 +1,17 @@ +package com.bitwarden.authenticator.ui.platform.base.util + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp + +/** + * A function for converting pixels to [Dp] within a composable function. + */ +@Composable +fun Int.toDp(): Dp = with(LocalDensity.current) { this@toDp.toDp() } + +/** + * A function for converting [Dp] to pixels within a composable function. + */ +@Composable +fun Dp.toPx(): Float = with(LocalDensity.current) { this@toPx.toPx() } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/EventsEffect.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/EventsEffect.kt new file mode 100644 index 0000000000..8fe45fa707 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/EventsEffect.kt @@ -0,0 +1,22 @@ +package com.bitwarden.authenticator.ui.platform.base.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Convenience method for observing event flow from [BaseViewModel]. + */ +@Composable +fun EventsEffect( + viewModel: BaseViewModel<*, E, *>, + handler: suspend (E) -> Unit, +) { + LaunchedEffect(key1 = Unit) { + viewModel.eventFlow + .onEach { handler.invoke(it) } + .launchIn(this) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/ModifierExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/ModifierExtensions.kt new file mode 100644 index 0000000000..3bf9de4d9e --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/ModifierExtensions.kt @@ -0,0 +1,77 @@ +package com.bitwarden.authenticator.ui.platform.base.util + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DividerDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.data.platform.annotation.OmitFromCoverage +import com.bitwarden.authenticator.ui.platform.util.isPortrait + +/** + * This is a [Modifier] extension for drawing a divider at the bottom of the composable. + */ +@Stable +@Composable +fun Modifier.bottomDivider( + paddingStart: Dp = 0.dp, + paddingEnd: Dp = 0.dp, + thickness: Dp = DividerDefaults.Thickness, + color: Color = DividerDefaults.color, + enabled: Boolean = true, +): Modifier = drawWithCache { + onDrawWithContent { + drawContent() + if (enabled) { + drawLine( + color = color, + strokeWidth = thickness.toPx(), + start = Offset( + x = paddingStart.toPx(), + y = size.height - thickness.toPx() / 2, + ), + end = Offset( + x = size.width - paddingEnd.toPx(), + y = size.height - thickness.toPx() / 2, + ), + ) + } + } +} + +/** + * This is a [Modifier] extension for mirroring the contents of a composable when the layout + * direction is set to [LayoutDirection.Rtl]. Primarily used for directional icons, such as the + * up button and chevrons. + */ +@Stable +@Composable +fun Modifier.mirrorIfRtl(): Modifier = + if (LocalLayoutDirection.current == LayoutDirection.Rtl) { + scale(scaleX = -1f, scaleY = 1f) + } else { + this + } + +/** + * This is a [Modifier] extension for ensuring that the content uses the standard horizontal margin. + */ +@OmitFromCoverage +@Stable +@Composable +fun Modifier.standardHorizontalMargin( + portrait: Dp = 16.dp, + landscape: Dp = 48.dp, +): Modifier { + val config = LocalConfiguration.current + return this.padding(horizontal = if (config.isPortrait) portrait else landscape) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/NavGraphBuilderExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/NavGraphBuilderExtensions.kt new file mode 100644 index 0000000000..b9ba7cafdb --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/NavGraphBuilderExtensions.kt @@ -0,0 +1,98 @@ +package com.bitwarden.authenticator.ui.platform.base.util + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.runtime.Composable +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDeepLink +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.bitwarden.authenticator.ui.platform.theme.TransitionProviders + +/** + * A wrapper around [NavGraphBuilder.composable] that supplies slide up/down transitions. + */ +fun NavGraphBuilder.composableWithSlideTransitions( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + this.composable( + route = route, + arguments = arguments, + deepLinks = deepLinks, + enterTransition = TransitionProviders.Enter.slideUp, + exitTransition = TransitionProviders.Exit.stay, + popEnterTransition = TransitionProviders.Enter.stay, + popExitTransition = TransitionProviders.Exit.slideDown, + content = content, + ) +} + +/** + * A wrapper around [NavGraphBuilder.composable] that supplies "stay" transitions. + */ +fun NavGraphBuilder.composableWithStayTransitions( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + this.composable( + route = route, + arguments = arguments, + deepLinks = deepLinks, + enterTransition = TransitionProviders.Enter.stay, + exitTransition = TransitionProviders.Exit.stay, + popEnterTransition = TransitionProviders.Enter.stay, + popExitTransition = TransitionProviders.Exit.stay, + content = content, + ) +} + +/** + * A wrapper around [NavGraphBuilder.composable] that supplies push transitions. + * + * This is suitable for screens deeper within a hierarchy that uses push transitions; the root + * screen of such a hierarchy should use [composableWithRootPushTransitions]. + */ +fun NavGraphBuilder.composableWithPushTransitions( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + this.composable( + route = route, + arguments = arguments, + deepLinks = deepLinks, + enterTransition = TransitionProviders.Enter.pushLeft, + exitTransition = TransitionProviders.Exit.stay, + popEnterTransition = TransitionProviders.Enter.stay, + popExitTransition = TransitionProviders.Exit.pushRight, + content = content, + ) +} + +/** + * A wrapper around [NavGraphBuilder.composable] that supplies push transitions to the root screen + * in a nested graph that uses push transitions. + */ +fun NavGraphBuilder.composableWithRootPushTransitions( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + this.composable( + route = route, + arguments = arguments, + deepLinks = deepLinks, + enterTransition = TransitionProviders.Enter.stay, + exitTransition = TransitionProviders.Exit.pushLeft, + popEnterTransition = TransitionProviders.Enter.pushRight, + popExitTransition = TransitionProviders.Exit.fadeOut, + content = content, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/PaddingValuesExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/PaddingValuesExtensions.kt new file mode 100644 index 0000000000..f02ed56aa3 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/PaddingValuesExtensions.kt @@ -0,0 +1,48 @@ +package com.bitwarden.authenticator.ui.platform.base.util + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection + +/** + * Compares the top, bottom, start, and end values to another [PaddingValues] and returns a new + * 'PaddingValues' using the maximum values of each property respectively. + * + * @param other The other values to compare against. + */ +fun PaddingValues.max( + other: PaddingValues, + direction: LayoutDirection, +): PaddingValues = PaddingValues( + top = maxOf(calculateTopPadding(), other.calculateTopPadding()), + bottom = maxOf(calculateBottomPadding(), other.calculateBottomPadding()), + start = maxOf(calculateStartPadding(direction), other.calculateStartPadding(direction)), + end = maxOf(calculateEndPadding(direction), other.calculateEndPadding(direction)), +) + +/** + * Compares the top, bottom, start, and end values to a [WindowInsets] and returns a new + * 'PaddingValues' using the maximum values of each property respectively. + * + * @param windowInsets The [WindowInsets] to compare against. + */ +@Composable +fun PaddingValues.max( + windowInsets: WindowInsets, +): PaddingValues = max(windowInsets.asPaddingValues()) + +/** + * Compares the top, bottom, start, and end values to another [PaddingValues] and returns a new + * 'PaddingValues' using the maximum values of each property respectively. + * + * @param other The other [PaddingValues] to compare against. + */ +@Composable +fun PaddingValues.max( + other: PaddingValues, +): PaddingValues = max(other, LocalLayoutDirection.current) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/StringExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/StringExtensions.kt new file mode 100644 index 0000000000..481f765421 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/StringExtensions.kt @@ -0,0 +1,82 @@ +package com.bitwarden.authenticator.ui.platform.base.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import java.text.Normalizer +import kotlin.math.floor + +/** + * This character takes up no space but can be used to ensure a string is not empty. It can also + * be used to insert "safe" line-break positions in a string. + * + * Note: Is a string only contains this charactor, it is _not_ considered blank. + */ +const val ZERO_WIDTH_CHARACTER: String = "\u200B" +/** + * Returns the original [String] only if: + * + * - it is non-null + * - it is not blank (where blank refers to empty strings of those containing only white space) + * + * Otherwise `null` is returned. + */ +fun String?.orNullIfBlank(): String? = this?.takeUnless { it.isBlank() } + +/** + * Returns a new [String] that includes line breaks after [widthPx] worth of text. This is useful + * for long values that need to smoothly flow onto the next line without the OS inserting line + * breaks earlier at special characters. + * + * Note that the internal calculation used assumes that [monospacedTextStyle] is based on a + * monospaced font like Roboto Mono. + */ +@Composable +fun String.withLineBreaksAtWidth( + widthPx: Float, + monospacedTextStyle: TextStyle, +): String { + val measurer = rememberTextMeasurer() + return remember(this, widthPx, monospacedTextStyle) { + val characterSizePx = measurer + .measure("*", monospacedTextStyle) + .size + .width + val perLineCharacterLimit = floor(widthPx / characterSizePx).toInt() + if (widthPx > 0) { + this + .chunked(perLineCharacterLimit) + .joinToString(separator = "\n") + } else { + this + } + } +} + +/** + * Returns the [String] as an [AnnotatedString]. + */ +fun String.toAnnotatedString(): AnnotatedString = AnnotatedString(text = this) + +/** + * Normalizes the [String] by removing diacritics, such as an umlaut. + * + * Example: áéíóů --> aeiou + */ +fun String.removeDiacritics(): String = + "\\p{InCombiningDiacriticalMarks}+" + .toRegex() + .replace( + Normalizer.normalize(this, Normalizer.Form.NFKD), + "", + ) + +/** + * Checks if a string is using base32 digits. + */ +fun String.isBase32(): Boolean { + val regex = ("^[A-Z2-7]+=*$").toRegex() + return regex.matches(this) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/Text.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/Text.kt new file mode 100644 index 0000000000..52e5473802 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/Text.kt @@ -0,0 +1,119 @@ +package com.bitwarden.authenticator.ui.platform.base.util + +import android.content.res.Resources +import android.os.Parcelable +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.platform.LocalContext +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue + +/** + * Interface for sending strings to the UI layer. + */ +@Immutable +interface Text : Parcelable { + /** + * Returns the string representation of [Text]. + */ + @Composable + operator fun invoke(): String { + return toString(LocalContext.current.resources) + } + + /** + * Returns the string representation of [Text]. + */ + operator fun invoke(res: Resources): CharSequence + + /** + * Helper method to call [invoke] and then [toString]. + */ + fun toString(res: Resources): String = invoke(res).toString() +} + +/** + * Implementation of [Text] backed by a string resource. + */ +@Parcelize +private data class ResText(@StringRes private val id: Int) : Text { + override fun invoke(res: Resources): CharSequence = res.getText(id) +} + +/** + * Implementation of [Text] backed by an array of [Text]s. This makes it easy to concatenate texts. + */ +@Parcelize +private data class TextConcatenation(private val args: List) : Text { + override fun invoke( + res: Resources, + ): CharSequence = args.joinToString(separator = "") { it.invoke(res) } +} + +/** + * Implementation of [Text] that formats a string resource with arguments. + */ +@Parcelize +private data class ResArgsText( + @StringRes + private val id: Int, + private val args: @RawValue List, +) : Text { + override fun invoke(res: Resources): String = + res.getString(id, *convertArgs(res, args).toTypedArray()) + + override fun toString(): String = "ResArgsText(id=$id, args=${args.contentToString()})" +} + +/** + * Implementation of [Text] that formats a plurals resource. + */ +@Parcelize +@Suppress("UnusedPrivateClass") +private data class PluralsText( + @PluralsRes + private val id: Int, + private val quantity: Int, + private val args: @RawValue List, +) : Text { + override fun invoke(res: Resources): String = + res.getQuantityString(id, quantity, *convertArgs(res, args).toTypedArray()) + + override fun toString(): String = + "PluralsText(id=$id, quantity=$quantity, args=${args.contentToString()})" +} + +private fun List.contentToString() = joinToString(separator = ",", prefix = "(", postfix = ")") + +private fun convertArgs(res: Resources, args: List): List = + args.map { if (it is Text) it.invoke(res) else it } + +/** + * Implementation of [Text] backed by a raw string. For use with server responses. + */ +@Parcelize +private data class StringText(private val string: String) : Text { + override fun invoke(res: Resources): String = string +} + +/** + * Convert a [String] to [Text]. + */ +fun String.asText(): Text = StringText(this) + +/** + * Concatenates multiple [Text]s into a singular [Text]. + */ +fun Text.concat(vararg args: Text): Text = TextConcatenation(listOf(this, *args)) + +/** + * Convert a resource Id to [Text]. + */ +fun @receiver:StringRes Int.asText(): Text = ResText(this) + +/** + * Convert a resource Id to [Text] with format args. + */ +fun @receiver:StringRes Int.asText(vararg args: Any): Text = ResArgsText(this, args.asList()) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/BitwardenMediumTopAppBar.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/BitwardenMediumTopAppBar.kt new file mode 100644 index 0000000000..f8cba80f24 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/BitwardenMediumTopAppBar.kt @@ -0,0 +1,87 @@ +package com.bitwarden.authenticator.ui.platform.components.appbar + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.tooling.preview.Preview +import com.bitwarden.authenticator.R + +/** + * A custom Bitwarden-themed medium top app bar with support for actions. + * + * This app bar wraps around Material 3's [MediumTopAppBar] and customizes its appearance + * and behavior according to the app theme. + * It provides a title and an optional set of actions on the trailing side. + * These actions are arranged within a custom action row tailored to the app's design requirements. + * + * @param title The text to be displayed as the title of the app bar. + * @param scrollBehavior Defines the scrolling behavior of the app bar. It controls how the app bar + * behaves in conjunction with scrolling content. + * @param actions A lambda containing the set of actions (usually icons or similar) to display + * in the app bar's trailing side. This lambda extends [RowScope], allowing flexibility in + * defining the layout of the actions. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BitwardenMediumTopAppBar( + title: String, + scrollBehavior: TopAppBarScrollBehavior, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {}, +) { + MediumTopAppBar( + colors = TopAppBarDefaults.largeTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + scrollBehavior = scrollBehavior, + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.semantics { testTag = "PageTitleLabel" }, + ) + }, + modifier = modifier.semantics { testTag = "HeaderBarComponent" }, + actions = actions, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Composable +private fun BitwardenMediumTopAppBar_preview() { + MaterialTheme { + BitwardenMediumTopAppBar( + title = "Preview Title", + scrollBehavior = TopAppBarDefaults + .exitUntilCollapsedScrollBehavior( + rememberTopAppBarState(), + ), + actions = { + IconButton(onClick = { }) { + Icon( + painter = painterResource(id = R.drawable.ic_more), + contentDescription = "", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/BitwardenSearchTopAppBar.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/BitwardenSearchTopAppBar.kt new file mode 100644 index 0000000000..3e77177fd2 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/BitwardenSearchTopAppBar.kt @@ -0,0 +1,103 @@ +package com.bitwarden.authenticator.ui.platform.components.appbar + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.input.ImeAction +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.mirrorIfRtl + +/** + * Represents a Bitwarden styled [TopAppBar] that assumes the following components: + * + * - an optional single navigation control in the upper-left defined by [navigationIcon]. + * - an editable [TextField] populated by a [searchTerm] in the middle. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BitwardenSearchTopAppBar( + searchTerm: String, + placeholder: String, + onSearchTermChange: (String) -> Unit, + scrollBehavior: TopAppBarScrollBehavior, + navigationIcon: NavigationIcon?, + modifier: Modifier = Modifier, + autoFocus: Boolean = true, +) { + val focusRequester = remember { FocusRequester() } + TopAppBar( + modifier = modifier.semantics { testTag = "HeaderBarComponent" }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + scrollBehavior = scrollBehavior, + navigationIcon = { + navigationIcon?.let { + IconButton( + onClick = it.onNavigationIconClick, + modifier = Modifier.semantics { testTag = "CloseButton" }, + ) { + Icon( + modifier = Modifier.mirrorIfRtl(), + painter = it.navigationIcon, + contentDescription = it.navigationIconContentDescription, + ) + } + } + }, + title = { + TextField( + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + placeholder = { Text(text = placeholder) }, + value = searchTerm, + onValueChange = onSearchTermChange, + trailingIcon = { + IconButton( + onClick = { onSearchTermChange("") }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = stringResource(id = R.string.clear), + ) + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + modifier = Modifier + .focusRequester(focusRequester) + .fillMaxWidth(), + ) + }, + ) + if (autoFocus) { + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/BitwardenTopAppBar.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/BitwardenTopAppBar.kt new file mode 100644 index 0000000000..280bc96203 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/BitwardenTopAppBar.kt @@ -0,0 +1,147 @@ +package com.bitwarden.authenticator.ui.platform.components.appbar + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.mirrorIfRtl +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme + +/** + * Represents a Bitwarden styled [TopAppBar] that assumes the following components: + * + * - a single navigation control in the upper-left defined by [navigationIcon], + * [navigationIconContentDescription], and [onNavigationIconClick]. + * - a [title] in the middle. + * - a [actions] lambda containing the set of actions (usually icons or similar) to display + * in the app bar's trailing side. This lambda extends [RowScope], allowing flexibility in + * defining the layout of the actions. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BitwardenTopAppBar( + title: String, + scrollBehavior: TopAppBarScrollBehavior, + navigationIcon: Painter, + navigationIconContentDescription: String, + onNavigationIconClick: () -> Unit, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = { }, +) { + BitwardenTopAppBar( + title = title, + scrollBehavior = scrollBehavior, + navigationIcon = NavigationIcon( + navigationIcon = navigationIcon, + navigationIconContentDescription = navigationIconContentDescription, + onNavigationIconClick = onNavigationIconClick, + ), + modifier = modifier, + actions = actions, + ) +} + +/** + * Represents a Bitwarden styled [TopAppBar] that assumes the following components: + * + * - an optional single navigation control in the upper-left defined by [navigationIcon]. + * - a [title] in the middle. + * - a [actions] lambda containing the set of actions (usually icons or similar) to display + * in the app bar's trailing side. This lambda extends [RowScope], allowing flexibility in + * defining the layout of the actions. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BitwardenTopAppBar( + title: String, + scrollBehavior: TopAppBarScrollBehavior, + navigationIcon: NavigationIcon?, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {}, +) { + TopAppBar( + colors = TopAppBarDefaults.largeTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + scrollBehavior = scrollBehavior, + navigationIcon = { + navigationIcon?.let { + IconButton( + onClick = it.onNavigationIconClick, + modifier = Modifier.semantics { testTag = "CloseButton" }, + ) { + Icon( + modifier = Modifier.mirrorIfRtl(), + painter = it.navigationIcon, + contentDescription = it.navigationIconContentDescription, + ) + } + } + }, + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.semantics { testTag = "PageTitleLabel" }, + ) + }, + modifier = modifier.semantics { testTag = "HeaderBarComponent" }, + actions = actions, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun BitwardenTopAppBar_preview() { + AuthenticatorTheme { + BitwardenTopAppBar( + title = "Title", + scrollBehavior = TopAppBarDefaults + .exitUntilCollapsedScrollBehavior( + rememberTopAppBarState(), + ), + navigationIcon = NavigationIcon( + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = { }, + ), + ) + } +} + +/** + * Represents all data required to display a [navigationIcon]. + * + * @property navigationIcon The [Painter] displayed as part of the icon. + * @property navigationIconContentDescription The content description associated with the icon. + * @property onNavigationIconClick The click action that is invoked when the icon is tapped. + */ +data class NavigationIcon( + val navigationIcon: Painter, + val navigationIconContentDescription: String, + val onNavigationIconClick: () -> Unit, +) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/action/BitwardenSearchActionItem.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/action/BitwardenSearchActionItem.kt new file mode 100644 index 0000000000..dc5cba9026 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/action/BitwardenSearchActionItem.kt @@ -0,0 +1,45 @@ +package com.bitwarden.authenticator.ui.platform.components.appbar.action + +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.util.rememberVectorPainter + +/** + * Represents the Bitwarden search action item. + * + * This is an [Icon] composable tailored specifically for the search functionality + * in the Bitwarden app. + * It presents the search icon and offers an `onClick` callback for when the icon is tapped. + * + * @param contentDescription A description of the UI element, used for accessibility purposes. + * @param onClick A callback to be invoked when this action item is clicked. + */ +@Composable +fun BitwardenSearchActionItem( + contentDescription: String, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = Modifier.testTag("SearchButton"), + ) { + Icon( + painter = rememberVectorPainter(id = R.drawable.ic_search_24px), + contentDescription = contentDescription, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenSearchActionItem_preview() { + BitwardenSearchActionItem( + contentDescription = "Search", + onClick = {}, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenFIlledButton.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenFIlledButton.kt new file mode 100644 index 0000000000..ab096a6705 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenFIlledButton.kt @@ -0,0 +1,64 @@ +package com.bitwarden.authenticator.ui.platform.components.button + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Represents a Bitwarden-styled filled [Button]. + * + * @param label The label for the button. + * @param onClick The callback when the button is clicked. + * @param modifier The [Modifier] to be applied to the button. + * @param isEnabled Whether or not the button is enabled. + */ +@Composable +fun BitwardenFilledButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + Button( + onClick = onClick, + modifier = modifier.semantics(mergeDescendants = true) {}, + enabled = isEnabled, + contentPadding = PaddingValues( + vertical = 10.dp, + horizontal = 24.dp, + ), + colors = ButtonDefaults.buttonColors(), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Preview +@Composable +private fun BitwardenFilledButton_preview_isEnabled() { + BitwardenFilledButton( + label = "Label", + onClick = {}, + isEnabled = true, + ) +} + +@Preview +@Composable +private fun BitwardenFilledButton_preview_isNotEnabled() { + BitwardenFilledButton( + label = "Label", + onClick = {}, + isEnabled = false, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenFilledTonalButton.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenFilledTonalButton.kt new file mode 100644 index 0000000000..f14640bb2b --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenFilledTonalButton.kt @@ -0,0 +1,61 @@ +package com.bitwarden.authenticator.ui.platform.components.button + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme + +/** + * A filled tonal button for the Bitwarden UI with a customized appearance. + * + * This button uses the `secondaryContainer` color from the current [MaterialTheme.colorScheme] + * for its background and the `onSecondaryContainer` color for its label text. + * + * @param label The text to be displayed on the button. + * @param onClick A lambda which will be invoked when the button is clicked. + * @param isEnabled Whether or not the button is enabled. + * @param modifier A [Modifier] for this composable, allowing for adjustments to its appearance + * or behavior. This can be used to apply padding, layout, and other Modifiers. + */ +@Composable +fun BitwardenFilledTonalButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + Button( + onClick = onClick, + contentPadding = PaddingValues( + vertical = 10.dp, + horizontal = 24.dp, + ), + enabled = isEnabled, + colors = ButtonDefaults.filledTonalButtonColors(), + modifier = modifier, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenFilledTonalButton_preview() { + AuthenticatorTheme { + BitwardenFilledTonalButton( + label = "Sample Text", + onClick = {}, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenOutlinedButton.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenOutlinedButton.kt new file mode 100644 index 0000000000..d64d2942bc --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenOutlinedButton.kt @@ -0,0 +1,65 @@ +package com.bitwarden.authenticator.ui.platform.components.button + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Represents a Bitwarden-styled filled [OutlinedButton]. + * + * @param label The label for the button. + * @param onClick The callback when the button is clicked. + * @param modifier The [Modifier] to be applied to the button. + * @param isEnabled Whether or not the button is enabled. + */ +@Composable +fun BitwardenOutlinedButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier + .semantics(mergeDescendants = true) { }, + enabled = isEnabled, + contentPadding = PaddingValues( + vertical = 10.dp, + horizontal = 24.dp, + ), + colors = ButtonDefaults.outlinedButtonColors(), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Preview +@Composable +private fun BitwardenOutlinedButton_preview_isEnabled() { + BitwardenOutlinedButton( + label = "Label", + onClick = {}, + isEnabled = true, + ) +} + +@Preview +@Composable +private fun BitwardenOutlinedButton_preview_isNotEnabled() { + BitwardenOutlinedButton( + label = "Label", + onClick = {}, + isEnabled = false, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenStandardIconButton.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenStandardIconButton.kt new file mode 100644 index 0000000000..f8f217d965 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenStandardIconButton.kt @@ -0,0 +1,58 @@ +package com.bitwarden.authenticator.ui.platform.components.button + +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.model.IconResource +import com.bitwarden.authenticator.ui.platform.components.util.rememberVectorPainter + +/** + * An icon button that displays an icon from the provided [IconResource]. + * + * @param iconRes Icon to display on the button. + * @param onClick Callback for when the icon button is clicked. + * @param isEnabled Whether or not the button should be enabled. + * @param modifier A [Modifier] for the composable. + */ +@Composable +fun BitwardenIconButtonWithResource( + iconRes: IconResource, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + FilledIconButton( + modifier = modifier.semantics(mergeDescendants = true) {}, + onClick = onClick, + colors = IconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .12f), + disabledContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + enabled = isEnabled, + ) { + Icon( + painter = iconRes.iconPainter, + contentDescription = iconRes.contentDescription, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenIconButtonWithResource_preview() { + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = rememberVectorPainter(id = R.drawable.ic_tooltip), + contentDescription = "Sample Icon", + ), + onClick = {}, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenTextButton.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenTextButton.kt new file mode 100644 index 0000000000..f618e822ea --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenTextButton.kt @@ -0,0 +1,63 @@ +package com.bitwarden.authenticator.ui.platform.components.button + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Represents a Bitwarden-styled [TextButton]. + * + * @param label The label for the button. + * @param onClick The callback when the button is clicked. + * @param modifier The [Modifier] to be applied to the button. + * @param labelTextColor The color for the label text. + */ +@Composable +fun BitwardenTextButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, + labelTextColor: Color? = null, +) { + val defaultColors = if (labelTextColor != null) { + ButtonDefaults.textButtonColors( + contentColor = labelTextColor, + ) + } else { + ButtonDefaults.textButtonColors() + } + + TextButton( + onClick = onClick, + modifier = modifier, + enabled = isEnabled, + colors = defaultColors, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .padding( + vertical = 10.dp, + horizontal = 12.dp, + ), + ) + } +} + +@Preview +@Composable +private fun BitwardenTextButton_preview() { + BitwardenTextButton( + label = "Label", + onClick = {}, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/card/BitwardenActionCard.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/card/BitwardenActionCard.kt new file mode 100644 index 0000000000..e273f3cac4 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/card/BitwardenActionCard.kt @@ -0,0 +1,131 @@ +package com.bitwarden.authenticator.ui.platform.components.card + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.VectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.util.rememberVectorPainter + +/** + * A reusable card for displaying actions to the user. + */ +@Composable +fun BitwardenActionCard( + actionIcon: VectorPainter, + titleText: String, + actionText: String, + callToActionText: String, + onCardClicked: () -> Unit, + modifier: Modifier = Modifier, + trailingContent: (@Composable BoxScope.() -> Unit)? = null, +) { + Card( + onClick = onCardClicked, + shape = RoundedCornerShape(size = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + modifier = modifier, + elevation = CardDefaults.elevatedCardElevation(), + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + ) { + Icon( + painter = actionIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(start = 16.dp, top = 16.dp) + .size(24.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier + .weight(weight = 1f) + .padding(vertical = 16.dp), + ) { + Text( + text = titleText, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = actionText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = callToActionText, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Box { + trailingContent?.invoke(this) + } + } + } +} + +@Preview +@Composable +private fun ActionCardPreview() { + BitwardenActionCard( + actionIcon = rememberVectorPainter(id = R.drawable.ic_close), + actionText = "This is an action.", + callToActionText = "Take action", + titleText = "This is a title", + onCardClicked = { }, + ) +} + +@Preview +@Composable +private fun ActionCardWithTrailingPreview() { + BitwardenActionCard( + actionIcon = rememberVectorPainter(id = R.drawable.ic_bitwarden), + actionText = "An action with trailing content", + titleText = "This is a title", + callToActionText = "Take action", + onCardClicked = {}, + trailingContent = { + IconButton( + onClick = {}, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = stringResource(id = R.string.close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(24.dp), + ) + } + }, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/content/BitwardenErrorContent.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/content/BitwardenErrorContent.kt new file mode 100644 index 0000000000..b554eb6423 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/content/BitwardenErrorContent.kt @@ -0,0 +1,58 @@ +package com.bitwarden.authenticator.ui.platform.components.content + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenTextButton + +/** + * A Bitwarden-themed, re-usable error state. + * + * Note that when [onTryAgainClick] is absent, there will be no "Try again" button displayed. + */ +@Composable +fun BitwardenErrorContent( + message: String, + modifier: Modifier = Modifier, + onTryAgainClick: (() -> Unit)? = null, +) { + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = message, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + onTryAgainClick?.let { + Spacer(modifier = Modifier.height(16.dp)) + BitwardenTextButton( + label = stringResource(id = R.string.try_again), + onClick = it, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/content/BitwardenLoadingContent.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/content/BitwardenLoadingContent.kt new file mode 100644 index 0000000000..308d1dfa50 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/content/BitwardenLoadingContent.kt @@ -0,0 +1,27 @@ +package com.bitwarden.authenticator.ui.platform.components.content + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +/** + * A Bitwarden-themed, re-usable loading state. + */ +@Composable +fun BitwardenLoadingContent( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenBasicDialog.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenBasicDialog.kt new file mode 100644 index 0000000000..b84315855f --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenBasicDialog.kt @@ -0,0 +1,91 @@ +package com.bitwarden.authenticator.ui.platform.components.dialog + +import android.os.Parcelable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenTextButton +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme +import kotlinx.parcelize.Parcelize + +/** + * Represents a Bitwarden-styled dialog that is hidden or shown based on [visibilityState]. + * + * @param visibilityState the [BasicDialogState] used to populate the dialog. + * @param onDismissRequest called when the user has requested to dismiss the dialog, whether by + * tapping "OK", tapping outside the dialog, or pressing the back button. + */ +@Composable +fun BitwardenBasicDialog( + visibilityState: BasicDialogState, + onDismissRequest: () -> Unit, +): Unit = when (visibilityState) { + BasicDialogState.Hidden -> Unit + is BasicDialogState.Shown -> { + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + BitwardenTextButton( + label = stringResource(id = R.string.ok), + onClick = onDismissRequest, + ) + }, + title = visibilityState.title?.let { + { + Text( + text = it(), + style = MaterialTheme.typography.headlineSmall, + ) + } + }, + text = { + Text( + text = visibilityState.message(), + style = MaterialTheme.typography.bodyMedium, + ) + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) + } +} + +@Preview +@Composable +private fun BitwardenBasicDialog_preview() { + AuthenticatorTheme { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = "An error has occurred.".asText(), + message = "Username or password is incorrect. Try again.".asText(), + ), + onDismissRequest = {}, + ) + } +} + +/** + * Models display of a [BitwardenBasicDialog]. + */ +sealed class BasicDialogState : Parcelable { + + /** + * Hide the dialog. + */ + @Parcelize + data object Hidden : BasicDialogState() + + /** + * Show the dialog with the given values. + */ + @Parcelize + data class Shown( + val title: Text?, + val message: Text, + ) : BasicDialogState() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenLoadingDialog.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenLoadingDialog.kt new file mode 100644 index 0000000000..1f5ffe45db --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenLoadingDialog.kt @@ -0,0 +1,107 @@ +package com.bitwarden.authenticator.ui.platform.components.dialog + +import android.os.Parcelable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme +import kotlinx.parcelize.Parcelize + +/** + * Represents a Bitwarden-styled loading dialog that shows text and a circular progress indicator. + * + * @param visibilityState the [LoadingDialogState] used to populate the dialog. + */ +@Composable +fun BitwardenLoadingDialog( + visibilityState: LoadingDialogState, +) { + when (visibilityState) { + is LoadingDialogState.Hidden -> Unit + is LoadingDialogState.Shown -> { + Dialog( + onDismissRequest = {}, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) { + Card( + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = visibilityState.text(), + modifier = Modifier.padding( + top = 24.dp, + bottom = 8.dp, + ), + ) + CircularProgressIndicator( + modifier = Modifier.padding( + top = 8.dp, + bottom = 24.dp, + ), + ) + } + } + } + } + } +} + +@Preview +@Composable +private fun BitwardenLoadingDialog_preview() { + AuthenticatorTheme { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown( + text = "Loading...".asText(), + ), + ) + } +} + +/** + * Models display of a [BitwardenLoadingDialog]. + */ +sealed class LoadingDialogState : Parcelable { + /** + * Hide the dialog. + */ + @Parcelize + data object Hidden : LoadingDialogState() + + /** + * Show the dialog with the given values. + */ + @Parcelize + data class Shown(val text: Text) : LoadingDialogState() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenSelectionDialog.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenSelectionDialog.kt new file mode 100644 index 0000000000..ed711af3c4 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenSelectionDialog.kt @@ -0,0 +1,122 @@ +package com.bitwarden.authenticator.ui.platform.components.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenTextButton +import com.bitwarden.authenticator.ui.platform.components.util.maxDialogHeight + +/** + * Displays a dialog with a title and "Cancel" button. + * + * @param title Title to display. + * @param subtitle Optional subtitle to display below the title. + * @param dismissLabel Label to show on the dismiss button at the bottom of the dialog. + * @param onDismissRequest Invoked when the user dismisses the dialog. + * @param onDismissActionClick Invoked when the user dismisses the via the dismiss action button. + * By default, this just defers to onDismissRequest. + * @param selectionItems Lambda containing selection items to show to the user. See + * [BitwardenSelectionRow]. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun BitwardenSelectionDialog( + title: String, + subtitle: String? = null, + dismissLabel: String = stringResource(R.string.cancel), + onDismissRequest: () -> Unit, + onDismissActionClick: () -> Unit = onDismissRequest, + selectionItems: @Composable ColumnScope.() -> Unit = {}, +) { + Dialog( + onDismissRequest = onDismissRequest, + ) { + val configuration = LocalConfiguration.current + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .semantics { testTagsAsResourceId = true } + .requiredHeightIn( + max = configuration.maxDialogHeight, + ) + // This background is necessary for the dialog to not be transparent. + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(28.dp), + ), + horizontalAlignment = Alignment.End, + ) { + Text( + modifier = Modifier + .padding(24.dp) + .fillMaxWidth(), + text = title, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + ) + subtitle?.let { + Text( + modifier = Modifier + .padding( + start = 24.dp, + end = 24.dp, + bottom = 24.dp, + ) + .fillMaxWidth(), + text = subtitle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } + if (scrollState.canScrollBackward) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.outlineVariant), + ) + } + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(scrollState), + content = selectionItems, + ) + if (scrollState.canScrollForward) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.outlineVariant), + ) + } + BitwardenTextButton( + modifier = Modifier.padding(24.dp), + label = dismissLabel, + onClick = onDismissActionClick, + ) + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenSelectionRow.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenSelectionRow.kt new file mode 100644 index 0000000000..34b0815a7f --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenSelectionRow.kt @@ -0,0 +1,52 @@ +package com.bitwarden.authenticator.ui.platform.components.dialog + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.ui.platform.base.util.Text + +/** + * A clickable item that displays a radio button and text. + * + * @param text The text to display. + * @param onClick Invoked when either the radio button or text is clicked. + * @param isSelected Whether or not the radio button should be checked. + */ +@Composable +fun BitwardenSelectionRow( + text: Text, + onClick: () -> Unit, + isSelected: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .semantics(mergeDescendants = true) { + selected = isSelected + }, + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + modifier = Modifier.padding(16.dp), + selected = isSelected, + onClick = null, + ) + Text( + text = text(), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenTwoButtonDialog.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenTwoButtonDialog.kt new file mode 100644 index 0000000000..9950da7282 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/BitwardenTwoButtonDialog.kt @@ -0,0 +1,68 @@ +package com.bitwarden.authenticator.ui.platform.components.dialog + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenTextButton + +/** + * Represents a Bitwarden-styled dialog with two buttons. + * + * @param title the optional title to show. + * @param message message to show. + * @param confirmButtonText text to show on confirm button. + * @param dismissButtonText text to show on dismiss button. + * @param onConfirmClick called when the confirm button is clicked. + * @param onDismissClick called when the dismiss button is clicked. + * @param onDismissRequest called when the user attempts to dismiss the dialog (for example by + * tapping outside of it). + * @param confirmTextColor The color of the confirm text. + * @param dismissTextColor The color of the dismiss text. + */ +@Composable +fun BitwardenTwoButtonDialog( + title: String?, + message: String, + confirmButtonText: String, + dismissButtonText: String, + onConfirmClick: () -> Unit, + onDismissClick: () -> Unit, + onDismissRequest: () -> Unit, + confirmTextColor: Color? = null, + dismissTextColor: Color? = null, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + dismissButton = { + BitwardenTextButton( + label = dismissButtonText, + labelTextColor = dismissTextColor, + onClick = onDismissClick, + ) + }, + confirmButton = { + BitwardenTextButton( + label = confirmButtonText, + labelTextColor = confirmTextColor, + onClick = onConfirmClick, + ) + }, + title = title?.let { + { + Text( + text = it, + style = MaterialTheme.typography.headlineSmall, + ) + } + }, + text = { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + ) + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/row/BitwardenBasicDialogRow.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/row/BitwardenBasicDialogRow.kt new file mode 100644 index 0000000000..ee48f23eea --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/row/BitwardenBasicDialogRow.kt @@ -0,0 +1,45 @@ +package com.bitwarden.authenticator.ui.platform.components.dialog.row + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenSelectionDialog + +/** + * A simple clickable row for use within a [BitwardenSelectionDialog] as an alternative to a + * [BitwardenSelectionRow]. + * + * @param text The text to display in the row. + * @param onClick A callback to be invoked when the row is clicked. + * @param modifier A [Modifier] for the composable. + */ +@Composable +fun BitwardenBasicDialogRow( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + modifier = modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(color = MaterialTheme.colorScheme.primary), + onClick = onClick, + ) + .padding( + vertical = 16.dp, + horizontal = 24.dp, + ) + .fillMaxWidth(), + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/row/BitwardenSelectionRow.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/row/BitwardenSelectionRow.kt new file mode 100644 index 0000000000..be0a900e73 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dialog/row/BitwardenSelectionRow.kt @@ -0,0 +1,52 @@ +package com.bitwarden.authenticator.ui.platform.components.dialog.row + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.ui.platform.base.util.Text + +/** + * A clickable item that displays a radio button and text. + * + * @param text The text to display. + * @param onClick Invoked when either the radio button or text is clicked. + * @param isSelected Whether or not the radio button should be checked. + */ +@Composable +fun BitwardenSelectionRow( + text: Text, + onClick: () -> Unit, + isSelected: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .semantics(mergeDescendants = true) { + selected = isSelected + }, + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + modifier = Modifier.padding(16.dp), + selected = isSelected, + onClick = null, + ) + Text( + text = text(), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/divider/BitwardenHorizontalDivider.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/divider/BitwardenHorizontalDivider.kt new file mode 100644 index 0000000000..4ba8d807db --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/divider/BitwardenHorizontalDivider.kt @@ -0,0 +1,30 @@ +package com.bitwarden.authenticator.ui.platform.components.divider + +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * A divider line. + * + * @param modifier The [Modifier] to be applied to this divider. + * @param thickness The thickness of this divider. Using [Dp.Hairline] will produce a single pixel + * divider regardless of screen density. + * @param color The color of this divider. + */ +@Composable +fun BitwardenHorizontalDivider( + modifier: Modifier = Modifier, + thickness: Dp = 1.dp, + color: Color = MaterialTheme.colorScheme.outline, +) { + HorizontalDivider( + modifier = modifier, + thickness = thickness, + color = color, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dropdown/BitwardenMultiSelectButton.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dropdown/BitwardenMultiSelectButton.kt new file mode 100644 index 0000000000..693246b635 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/dropdown/BitwardenMultiSelectButton.kt @@ -0,0 +1,186 @@ +package com.bitwarden.authenticator.ui.platform.components.dropdown + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenSelectionDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenSelectionRow +import com.bitwarden.authenticator.ui.platform.components.model.TooltipData +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +/** + * A custom composable representing a multi-select button. + * + * This composable displays an [OutlinedTextField] with a dropdown icon as a trailing icon. + * When the field is clicked, a dropdown menu appears with a list of options to select from. + * + * @param label The descriptive text label for the [OutlinedTextField]. + * @param options A list of strings representing the available options in the dialog. + * @param selectedOption The currently selected option that is displayed in the [OutlinedTextField] + * (or `null` if no option is selected). + * @param onOptionSelected A lambda that is invoked when an option + * is selected from the dropdown menu. + * @param isEnabled Whether or not the button is enabled. + * @param modifier A [Modifier] that you can use to apply custom modifications to the composable. + * @param supportingText A optional supporting text that will appear below the text field. + * @param tooltip A nullable [TooltipData], representing the tooltip icon. + */ +@Suppress("LongMethod") +@Composable +fun BitwardenMultiSelectButton( + label: String, + options: ImmutableList, + selectedOption: String?, + onOptionSelected: (String) -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, + supportingText: String? = null, + tooltip: TooltipData? = null, +) { + var shouldShowDialog by rememberSaveable { mutableStateOf(false) } + + OutlinedTextField( + modifier = modifier + .clearAndSetSemantics { + role = Role.DropdownList + contentDescription = supportingText + ?.let { "$selectedOption. $label. $it" } + ?: "$selectedOption. $label" + customActions = listOfNotNull( + tooltip?.let { + CustomAccessibilityAction( + label = it.contentDescription, + action = { + it.onClick() + true + }, + ) + }, + ) + } + .fillMaxWidth() + .clickable( + indication = null, + enabled = isEnabled, + interactionSource = remember { MutableInteractionSource() }, + ) { + shouldShowDialog = !shouldShowDialog + }, + textStyle = MaterialTheme.typography.bodyLarge, + readOnly = true, + label = { + Row { + Text( + text = label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + tooltip?.let { + Spacer(modifier = Modifier.width(3.dp)) + IconButton( + onClick = it.onClick, + enabled = isEnabled, + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.primary, + ), + modifier = Modifier.size(16.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_tooltip_small), + contentDescription = it.contentDescription, + ) + } + } + } + }, + value = selectedOption.orEmpty(), + onValueChange = onOptionSelected, + enabled = shouldShowDialog, + trailingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_region_select_dropdown), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + colors = OutlinedTextFieldDefaults.colors( + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledBorderColor = MaterialTheme.colorScheme.outline, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledSupportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + supportingText = supportingText?.let { + { + Text( + text = supportingText, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + ) + if (shouldShowDialog) { + BitwardenSelectionDialog( + title = label, + onDismissRequest = { shouldShowDialog = false }, + ) { + options.forEach { optionString -> + BitwardenSelectionRow( + text = optionString.asText(), + isSelected = optionString == selectedOption, + onClick = { + shouldShowDialog = false + onOptionSelected(optionString) + }, + ) + } + } + } +} + +@Preview +@Composable +private fun BitwardenMultiSelectButton_preview() { + AuthenticatorTheme { + BitwardenMultiSelectButton( + label = "Label", + options = persistentListOf("a", "b"), + selectedOption = "", + onOptionSelected = {}, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/fab/ExpandableFloatingActionButton.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/fab/ExpandableFloatingActionButton.kt new file mode 100644 index 0000000000..0adaba7ac8 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/fab/ExpandableFloatingActionButton.kt @@ -0,0 +1,210 @@ +package com.bitwarden.authenticator.ui.platform.components.fab + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.components.model.IconResource +import com.bitwarden.authenticator.ui.platform.theme.Typography + +/** + * A FAB that expands, when clicked, to display a collection of options that can be clicked. + * + * @param label [Text] displayed when the FAB is expanded. + * @param items [ExpandableFabOption] buttons displayed when the FAB is expanded. + * @param expandableFabState [ExpandableFabIcon] displayed in the FAB. + * @param onStateChange Lambda invoked when the FAB expanded state changes. + */ +@Suppress("LongMethod") +@Composable +fun ExpandableFloatingActionButton( + modifier: Modifier = Modifier, + label: Text?, + items: List, + expandableFabState: MutableState = rememberExpandableFabState(), + expandableFabIcon: ExpandableFabIcon, + onStateChange: (expandableFabState: ExpandableFabState) -> Unit = { }, +) { + val rotation by animateFloatAsState( + targetValue = if (expandableFabState.value == ExpandableFabState.Expanded) { + expandableFabIcon.iconRotation ?: 0f + } else { + 0f + }, + label = stringResource(R.string.add_item_rotation), + ) + Column( + modifier = modifier.wrapContentSize(), + horizontalAlignment = Alignment.End, + ) { + AnimatedVisibility( + visible = expandableFabState.value.isExpanded(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LazyColumn( + modifier = Modifier + .wrapContentSize() + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(items) { expandableFabOption -> + ExpandableFabOption( + onFabOptionClick = { + expandableFabState.value = expandableFabState.value.toggleValue() + onStateChange(expandableFabState.value) + expandableFabOption.onFabOptionClick() + }, + expandableFabOption = expandableFabOption, + ) + } + } + } + + ExtendedFloatingActionButton( + onClick = { + expandableFabState.value = expandableFabState.value.toggleValue() + onStateChange(expandableFabState.value) + }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + ) { + + if (label != null) { + AnimatedVisibility( + visible = expandableFabState.value.isExpanded(), + enter = fadeIn() + expandHorizontally(), + exit = fadeOut() + shrinkHorizontally(), + ) { + Text( + modifier = Modifier.padding(end = 8.dp), + text = label(), + ) + } + } + + Icon( + modifier = Modifier + .rotate(rotation) + .semantics { expandableFabIcon.iconData.testTag }, + painter = expandableFabIcon.iconData.iconPainter, + contentDescription = expandableFabIcon.iconData.contentDescription, + ) + } + } +} + +@Composable +private fun ExpandableFabOption( + expandableFabOption: T, + onFabOptionClick: (option: T) -> Unit, +) { + SmallFloatingActionButton( + onClick = { onFabOptionClick(expandableFabOption) }, + ) { + Row( + modifier = Modifier + .wrapContentSize() + .padding(end = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + expandableFabOption.label?.let { label -> + Text( + modifier = Modifier + .clip(RoundedCornerShape(size = 8.dp)) + .padding(all = 8.dp), + text = label(), + style = Typography.labelSmall, + ) + } + + Icon( + painter = expandableFabOption.iconData.iconPainter, + contentDescription = expandableFabOption.iconData.contentDescription, + ) + } + } +} + +@Composable +private fun rememberExpandableFabState() = + remember { mutableStateOf(ExpandableFabState.Collapsed) } + +/** + * Represents options displayed when the FAB is expanded. + */ +abstract class ExpandableFabOption( + val label: Text?, + val iconData: IconResource, + val onFabOptionClick: () -> Unit, +) + +/** + * Models data for an expandable FAB icon. + */ +data class ExpandableFabIcon( + val iconData: IconResource, + val iconRotation: Float?, +) + +/** + * Models the state of the expandable FAB. + */ +sealed class ExpandableFabState { + + /** + * Indicates if the FAB is expanded. + */ + fun isExpanded() = this is Expanded + + /** + * Invert the state of the FAB. + */ + fun toggleValue() = if (isExpanded()) { + Collapsed + } else { + Expanded + } + + /** + * Indicates the FAB is collapsed. + */ + data object Collapsed : ExpandableFabState() + + /** + * Indicates the FAB is expanded. + */ + data object Expanded : ExpandableFabState() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/field/BitwardenPasswordField.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/field/BitwardenPasswordField.kt new file mode 100644 index 0000000000..0f75ac961b --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/field/BitwardenPasswordField.kt @@ -0,0 +1,229 @@ +package com.bitwarden.authenticator.ui.platform.components.field + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.util.nonLetterColorVisualTransformation + +/** + * Represents a Bitwarden-styled password field that hoists show/hide password state to the caller. + * + * See overloaded [BitwardenPasswordField] for self managed show/hide state. + * + * @param label Label for the text field. + * @param value Current next on the text field. + * @param showPassword Whether or not password should be shown. + * @param showPasswordChange Lambda that is called when user request show/hide be toggled. + * @param onValueChange Callback that is triggered when the password changes. + * @param modifier Modifier for the composable. + * @param readOnly `true` if the input should be read-only and not accept user interactions. + * @param singleLine when `true`, this text field becomes a single line that horizontally scrolls + * instead of wrapping onto multiple lines. + * @param hint optional hint text that will appear below the text input. + * @param showPasswordTestTag The test tag to be used on the show password button (testing tool). + * @param autoFocus When set to true, the view will request focus after the first recomposition. + * Setting this to true on multiple fields at once may have unexpected consequences. + * @param keyboardType The type of keyboard the user has access to when inputting values into + * the password field. + * @param imeAction the preferred IME action for the keyboard to have. + */ +@Composable +fun BitwardenPasswordField( + label: String, + value: String, + showPassword: Boolean, + showPasswordChange: (Boolean) -> Unit, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + readOnly: Boolean = false, + singleLine: Boolean = true, + hint: String? = null, + showPasswordTestTag: String? = null, + autoFocus: Boolean = false, + keyboardType: KeyboardType = KeyboardType.Password, + imeAction: ImeAction = ImeAction.Default, + capitalization: KeyboardCapitalization = KeyboardCapitalization.None, +) { + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + modifier = modifier.focusRequester(focusRequester), + textStyle = MaterialTheme.typography.bodyLarge, + label = { Text(text = label) }, + value = value, + onValueChange = onValueChange, + visualTransformation = when { + !showPassword -> PasswordVisualTransformation() + readOnly -> nonLetterColorVisualTransformation() + else -> VisualTransformation.None + }, + singleLine = singleLine, + readOnly = readOnly, + keyboardOptions = KeyboardOptions( + capitalization = capitalization, + keyboardType = keyboardType, + imeAction = imeAction, + ), + supportingText = hint?.let { + { + Text( + text = hint, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + trailingIcon = { + IconButton( + onClick = { showPasswordChange.invoke(!showPassword) }, + ) { + @DrawableRes + val painterRes = if (showPassword) { + R.drawable.ic_visibility_off + } else { + R.drawable.ic_visibility + } + + @StringRes + val contentDescriptionRes = if (showPassword) R.string.hide else R.string.show + Icon( + modifier = Modifier.semantics { showPasswordTestTag?.let { testTag = it } }, + painter = painterResource(id = painterRes), + contentDescription = stringResource(id = contentDescriptionRes), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) + if (autoFocus) { + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } +} + +/** + * Represents a Bitwarden-styled password field that manages the state of a show/hide indicator + * internally. + * + * @param label Label for the text field. + * @param value Current next on the text field. + * @param onValueChange Callback that is triggered when the password changes. + * @param modifier Modifier for the composable. + * @param readOnly `true` if the input should be read-only and not accept user interactions. + * @param singleLine when `true`, this text field becomes a single line that horizontally scrolls + * instead of wrapping onto multiple lines. + * @param hint optional hint text that will appear below the text input. + * @param initialShowPassword The initial state of the show/hide password control. A value of + * `false` (the default) indicates that that password should begin in the hidden state. + * @param showPasswordTestTag The test tag to be used on the show password button (testing tool). + * @param autoFocus When set to true, the view will request focus after the first recomposition. + * Setting this to true on multiple fields at once may have unexpected consequences. + * @param keyboardType The type of keyboard the user has access to when inputting values into + * the password field. + * @param imeAction the preferred IME action for the keyboard to have. + */ +@Composable +fun BitwardenPasswordField( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + readOnly: Boolean = false, + singleLine: Boolean = true, + hint: String? = null, + initialShowPassword: Boolean = false, + showPasswordTestTag: String? = null, + autoFocus: Boolean = false, + keyboardType: KeyboardType = KeyboardType.Password, + imeAction: ImeAction = ImeAction.Default, + capitalization: KeyboardCapitalization = KeyboardCapitalization.None, +) { + var showPassword by rememberSaveable { mutableStateOf(initialShowPassword) } + BitwardenPasswordField( + modifier = modifier, + label = label, + value = value, + showPassword = showPassword, + showPasswordChange = { showPassword = !showPassword }, + onValueChange = onValueChange, + readOnly = readOnly, + singleLine = singleLine, + hint = hint, + showPasswordTestTag = showPasswordTestTag, + autoFocus = autoFocus, + keyboardType = keyboardType, + imeAction = imeAction, + capitalization = capitalization, + ) +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenPasswordField_preview_withInput_hidePassword() { + BitwardenPasswordField( + label = "Label", + value = "Password", + onValueChange = {}, + initialShowPassword = false, + hint = "Hint", + ) +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenPasswordField_preview_withInput_showPassword() { + BitwardenPasswordField( + label = "Label", + value = "Password", + onValueChange = {}, + initialShowPassword = true, + hint = "Hint", + ) +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenPasswordField_preview_withoutInput_hidePassword() { + BitwardenPasswordField( + label = "Label", + value = "", + onValueChange = {}, + initialShowPassword = false, + hint = "Hint", + ) +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenPasswordField_preview_withoutInput_showPassword() { + BitwardenPasswordField( + label = "Label", + value = "", + onValueChange = {}, + initialShowPassword = true, + hint = "Hint", + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/field/BitwardenTextField.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/field/BitwardenTextField.kt new file mode 100644 index 0000000000..6fbe253ec3 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/field/BitwardenTextField.kt @@ -0,0 +1,138 @@ +package com.bitwarden.authenticator.ui.platform.components.field + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.ui.platform.base.util.toPx +import com.bitwarden.authenticator.ui.platform.base.util.withLineBreaksAtWidth +import com.bitwarden.authenticator.ui.platform.components.model.IconResource + +/** + * Component that allows the user to input text. This composable will manage the state of + * the user's input. + * @param label label for the text field. + * @param value current next on the text field. + * @param modifier modifier for the composable. + * @param onValueChange callback that is triggered when the input of the text field changes. + * @param placeholder the optional placeholder to be displayed when the text field is in focus and + * the [value] is empty. + * @param leadingIconResource the optional resource for the leading icon on the text field. + * @param trailingIconContent the content for the trailing icon in the text field. + * @param hint optional hint text that will appear below the text input. + * @param singleLine when `true`, this text field becomes a single line that horizontally scrolls + * instead of wrapping onto multiple lines. + * @param readOnly `true` if the input should be read-only and not accept user interactions. + * @param enabled Whether or not the text field is enabled. + * @param textStyle An optional style that may be used to override the default used. + * @param shouldAddCustomLineBreaks If `true`, line breaks will be inserted to allow for filling + * an entire line before breaking. `false` by default. + * @param visualTransformation Transforms the visual representation of the input [value]. + * @param keyboardType the preferred type of keyboard input. + */ +@Composable +fun BitwardenTextField( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + leadingIconResource: IconResource? = null, + trailingIconContent: (@Composable () -> Unit)? = null, + hint: String? = null, + singleLine: Boolean = true, + readOnly: Boolean = false, + enabled: Boolean = true, + textStyle: TextStyle? = null, + shouldAddCustomLineBreaks: Boolean = false, + keyboardType: KeyboardType = KeyboardType.Text, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, +) { + var widthPx by remember { mutableIntStateOf(0) } + + val currentTextStyle = textStyle ?: LocalTextStyle.current + val formattedText = if (shouldAddCustomLineBreaks) { + value.withLineBreaksAtWidth( + // Adjust for built in padding + widthPx = widthPx - 16.dp.toPx(), + monospacedTextStyle = currentTextStyle, + ) + } else { + value + } + + OutlinedTextField( + modifier = modifier + .onGloballyPositioned { widthPx = it.size.width }, + enabled = enabled, + label = { Text(text = label) }, + value = formattedText, + leadingIcon = leadingIconResource?.let { iconResource -> + { + Icon( + painter = iconResource.iconPainter, + contentDescription = iconResource.contentDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + trailingIcon = trailingIconContent?.let { + trailingIconContent + }, + placeholder = placeholder?.let { + { Text(text = it) } + }, + supportingText = hint?.let { + { + Text( + text = hint, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + onValueChange = onValueChange, + singleLine = singleLine, + readOnly = readOnly, + textStyle = currentTextStyle, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = keyboardType), + isError = isError, + visualTransformation = visualTransformation, + ) +} + +@Preview +@Composable +private fun BitwardenTextField_preview_withInput() { + BitwardenTextField( + label = "Label", + value = "Input", + onValueChange = {}, + hint = "Hint", + ) +} + +@Preview +@Composable +private fun BitwardenTextField_preview_withoutInput() { + BitwardenTextField( + label = "Label", + value = "", + onValueChange = {}, + hint = "Hint", + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/field/BitwardenTextFieldWithActions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/field/BitwardenTextFieldWithActions.kt new file mode 100644 index 0000000000..84e9079ac5 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/field/BitwardenTextFieldWithActions.kt @@ -0,0 +1,100 @@ +package com.bitwarden.authenticator.ui.platform.components.field + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.row.BitwardenRowOfActions +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme + +/** + * Represents a Bitwarden-styled text field accompanied by a series of actions. + * This component allows for a more versatile design by accepting + * icons or actions next to the text field. + * + * @param label Label for the text field. + * @param value Current text in the text field. + * @param onValueChange Callback that is triggered when the text content changes. + * @param modifier [Modifier] applied to this layout composable. + * @param readOnly `true` if the input should be read-only and not accept user interactions. + * @param singleLine when `true`, this text field becomes a single line that horizontally scrolls + * instead of wrapping onto multiple lines. + * @param trailingIconContent the content for the trailing icon in the text field. + * @param actions A lambda containing the set of actions (usually icons or similar) to display + * next to the text field. This lambda extends [RowScope], + * providing flexibility in the layout definition. + * @param textFieldTestTag The test tag to be used on the text field. + */ +@Composable +fun BitwardenTextFieldWithActions( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + textStyle: TextStyle? = null, + shouldAddCustomLineBreaks: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + readOnly: Boolean = false, + singleLine: Boolean = true, + keyboardType: KeyboardType = KeyboardType.Text, + trailingIconContent: (@Composable () -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, + actionsTestTag: String? = null, + textFieldTestTag: String? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { }, + verticalAlignment = Alignment.CenterVertically, + ) { + BitwardenTextField( + modifier = Modifier + .semantics { textFieldTestTag?.let { testTag = it } } + .weight(1f), + label = label, + value = value, + readOnly = readOnly, + singleLine = singleLine, + onValueChange = onValueChange, + keyboardType = keyboardType, + trailingIconContent = trailingIconContent, + textStyle = textStyle, + shouldAddCustomLineBreaks = shouldAddCustomLineBreaks, + visualTransformation = visualTransformation, + ) + BitwardenRowOfActions( + modifier = Modifier.run { actionsTestTag?.let { semantics { testTag = it } } ?: this }, + actions = actions, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenTextFieldWithActions_preview() { + AuthenticatorTheme { + BitwardenTextFieldWithActions( + label = "Username", + value = "user@example.com", + onValueChange = {}, + actions = { + Icon( + painter = painterResource(id = R.drawable.ic_tooltip), + contentDescription = "Action 1", + ) + }, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/header/BitwardenListHeaderText.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/header/BitwardenListHeaderText.kt new file mode 100644 index 0000000000..434235aeff --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/header/BitwardenListHeaderText.kt @@ -0,0 +1,43 @@ +package com.bitwarden.authenticator.ui.platform.components.header + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme + +/** + * Represents a Bitwarden-styled label text. + * + * @param label The text content for the label. + * @param modifier The [Modifier] to be applied to the label. + */ +@Composable +fun BitwardenListHeaderText( + label: String, + modifier: Modifier = Modifier, +) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.padding( + top = 12.dp, + bottom = 4.dp, + ), + ) +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenListHeaderText_preview() { + AuthenticatorTheme { + BitwardenListHeaderText( + label = "Sample Label", + modifier = Modifier, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/header/BitwardenListHeaderTextWithSupportLabel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/header/BitwardenListHeaderTextWithSupportLabel.kt new file mode 100644 index 0000000000..5bb48f07c6 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/header/BitwardenListHeaderTextWithSupportLabel.kt @@ -0,0 +1,62 @@ +package com.bitwarden.authenticator.ui.platform.components.header + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme + +/** + * Represents a Bitwarden-styled label text. + * + * @param label The text content for the label. + * @param supportingLabel The text for the supporting label. + * @param modifier The [Modifier] to be applied to the label. + */ +@Composable +fun BitwardenListHeaderTextWithSupportLabel( + label: String, + supportingLabel: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .padding( + top = 12.dp, + bottom = 4.dp, + end = 8.dp, + ) + .semantics(mergeDescendants = true) { }, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = supportingLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenListHeaderTextWithSupportLabel_preview() { + AuthenticatorTheme { + BitwardenListHeaderTextWithSupportLabel( + label = "Sample Label", + supportingLabel = "0", + modifier = Modifier, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/icon/BitwardenIcon.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/icon/BitwardenIcon.kt new file mode 100644 index 0000000000..de6de82c8c --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/icon/BitwardenIcon.kt @@ -0,0 +1,50 @@ +package com.bitwarden.authenticator.ui.platform.components.icon + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import com.bitwarden.authenticator.ui.platform.components.model.IconData +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.bumptech.glide.integration.compose.placeholder + +/** + * Represents a Bitwarden icon that is either locally loaded or loaded using glide. + * + * @param iconData Label for the text field. + * @param tint the color to be applied as the tint for the icon. + * @param modifier A [Modifier] for the composable. + * @param contentDescription A description of the switch's UI for accessibility purposes. + */ +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +fun BitwardenIcon( + iconData: IconData, + tint: Color, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + when (iconData) { + is IconData.Network -> { + GlideImage( + model = iconData.uri, + failure = placeholder(iconData.fallbackIconRes), + contentDescription = contentDescription, + modifier = modifier, + ) { + it.placeholder(iconData.fallbackIconRes) + } + } + + is IconData.Local -> { + Icon( + painter = painterResource(id = iconData.iconRes), + contentDescription = contentDescription, + tint = tint, + modifier = modifier, + ) + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/icon/BitwardenIconButtonWithResource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/icon/BitwardenIconButtonWithResource.kt new file mode 100644 index 0000000000..6e4bc5d312 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/icon/BitwardenIconButtonWithResource.kt @@ -0,0 +1,61 @@ +package com.bitwarden.authenticator.ui.platform.components.icon + +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.model.IconResource +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme + +/** + * An icon button that displays an icon from the provided [IconResource]. + * + * @param iconRes Icon to display on the button. + * @param onClick Callback for when the icon button is clicked. + * @param isEnabled Whether or not the button should be enabled. + * @param modifier A [Modifier] for the composable. + */ +@Composable +fun BitwardenIconButtonWithResource( + iconRes: IconResource, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + FilledIconButton( + modifier = modifier.semantics(mergeDescendants = true) {}, + onClick = onClick, + colors = IconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .12f), + disabledContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + enabled = isEnabled, + ) { + Icon( + painter = iconRes.iconPainter, + contentDescription = iconRes.contentDescription, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenIconButtonWithResource_preview() { + AuthenticatorTheme { + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = painterResource(id = R.drawable.ic_tooltip), + contentDescription = "Sample Icon", + ), + onClick = {}, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/indicator/BitwardenCircularCountdownIndicator.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/indicator/BitwardenCircularCountdownIndicator.kt new file mode 100644 index 0000000000..347459ce78 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/indicator/BitwardenCircularCountdownIndicator.kt @@ -0,0 +1,70 @@ +package com.bitwarden.authenticator.ui.platform.components.indicator + +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.unit.dp + +/** + * A countdown timer displayed to the user. + * + * @param timeLeftSeconds The seconds left on the timer. + * @param periodSeconds The period for the timer countdown. + * @param modifier A [Modifier] for the composable. + */ +@Composable +fun BitwardenCircularCountdownIndicator( + modifier: Modifier = Modifier, + timeLeftSeconds: Int, + periodSeconds: Int, + alertThresholdSeconds: Int = -1, + alertIndicatorColor: Color = MaterialTheme.colorScheme.error, +) { + val progressAnimate by animateFloatAsState( + targetValue = timeLeftSeconds.toFloat() / periodSeconds, + animationSpec = tween( + durationMillis = periodSeconds, + delayMillis = 0, + easing = LinearOutSlowInEasing, + ), + label = "CircularCountDownAnimation", + ) + + Box( + contentAlignment = Alignment.Center, + modifier = modifier, + ) { + CircularProgressIndicator( + progress = { progressAnimate }, + modifier = Modifier.size(size = 30.dp), + color = if (timeLeftSeconds > alertThresholdSeconds) { + MaterialTheme.colorScheme.primary + } else { + alertIndicatorColor + }, + strokeWidth = 3.dp, + strokeCap = StrokeCap.Round, + ) + + Text( + text = timeLeftSeconds.toString(), + style = MaterialTheme.typography.bodySmall, + color = if (timeLeftSeconds > alertThresholdSeconds) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + alertIndicatorColor + }, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/BitwardenListItem.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/BitwardenListItem.kt new file mode 100644 index 0000000000..283e46b395 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/BitwardenListItem.kt @@ -0,0 +1,189 @@ +package com.bitwarden.authenticator.ui.platform.components.listitem + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenSelectionDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.row.BitwardenBasicDialogRow +import com.bitwarden.authenticator.ui.platform.components.icon.BitwardenIcon +import com.bitwarden.authenticator.ui.platform.components.model.IconData +import com.bitwarden.authenticator.ui.platform.components.model.IconResource +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +/** + * A Composable function that displays a row item. + * + * @param label The primary text label to display for the item. + * @param startIcon The [Painter] object used to draw the icon at the start of the item. + * @param onClick The lambda to be invoked when the item is clicked. + * @param selectionDataList A list of all the selection items to be displayed in the overflow + * dialog. + * @param modifier An optional [Modifier] for this Composable, defaulting to an empty Modifier. + * This allows the caller to specify things like padding, size, etc. + * @param labelTestTag The optional test tag for the [label]. + * @param optionsTestTag The optional test tag for the options button. + * @param supportingLabel An optional secondary text label to display beneath the label. + * @param supportingLabelTestTag The optional test tag for the [supportingLabel]. + * @param trailingLabelIcons An optional list of small icons to be displayed after the [label]. + */ +@Suppress("LongMethod") +@Composable +fun BitwardenListItem( + label: String, + startIcon: IconData, + onClick: () -> Unit, + selectionDataList: ImmutableList, + modifier: Modifier = Modifier, + labelTestTag: String? = null, + optionsTestTag: String? = null, + supportingLabel: String? = null, + supportingLabelTestTag: String? = null, + trailingLabelIcons: ImmutableList = persistentListOf(), +) { + var shouldShowDialog by rememberSaveable { mutableStateOf(false) } + Row( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(color = MaterialTheme.colorScheme.primary), + onClick = onClick, + ) + .defaultMinSize(minHeight = 72.dp) + .padding(vertical = 8.dp) + .then(modifier), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + BitwardenIcon( + iconData = startIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .semantics { labelTestTag?.let { testTag = it } } + .weight(weight = 1f, fill = false), + ) + + trailingLabelIcons.forEach { iconResource -> + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = iconResource.iconPainter, + contentDescription = iconResource.contentDescription, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .semantics { iconResource.testTag?.let { testTag = it } } + .size(16.dp), + ) + } + } + + supportingLabel?.let { supportLabel -> + Text( + text = supportLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.semantics { supportingLabelTestTag?.let { testTag = it } }, + ) + } + } + + if (selectionDataList.isNotEmpty()) { + IconButton( + onClick = { shouldShowDialog = true }, + modifier = Modifier.semantics { optionsTestTag?.let { testTag = it } }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_more_horizontal), + contentDescription = stringResource(id = R.string.options), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + } + } + + if (shouldShowDialog) { + BitwardenSelectionDialog( + title = label, + onDismissRequest = { shouldShowDialog = false }, + selectionItems = { + selectionDataList.forEach { itemData -> + BitwardenBasicDialogRow( + modifier = Modifier.semantics { itemData.testTag?.let { testTag = it } }, + text = itemData.text, + onClick = { + shouldShowDialog = false + itemData.onClick() + }, + ) + } + }, + ) + } +} + +/** + * Wrapper for the an individual selection item's data. + */ +data class SelectionItemData( + val text: String, + val onClick: () -> Unit, + val testTag: String? = null, +) + +@Preview(showBackground = true) +@Composable +private fun BitwardenListItem_preview() { + AuthenticatorTheme { + BitwardenListItem( + label = "Sample Label", + supportingLabel = "Jan 3, 2024, 10:35 AM", + startIcon = IconData.Local(R.drawable.ic_login_item), + onClick = {}, + selectionDataList = persistentListOf(), + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/model/IconData.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/model/IconData.kt new file mode 100644 index 0000000000..e6b0ab0abf --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/model/IconData.kt @@ -0,0 +1,32 @@ +package com.bitwarden.authenticator.ui.platform.components.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * A class to denote the type of icon being passed. + */ +sealed class IconData : Parcelable { + + /** + * Data class representing the resources required for an icon. + * + * @property iconRes the resource for the local icon. + */ + @Parcelize + data class Local( + val iconRes: Int, + ) : IconData() + + /** + * Data class representing the resources required for a network-based icon. + * + * @property uri the link for the icon. + * @property fallbackIconRes fallback resource if the image cannot be loaded. + */ + @Parcelize + data class Network( + val uri: String, + val fallbackIconRes: Int, + ) : IconData() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/model/IconResource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/model/IconResource.kt new file mode 100644 index 0000000000..ca8e8fd0cf --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/model/IconResource.kt @@ -0,0 +1,16 @@ +package com.bitwarden.authenticator.ui.platform.components.model + +import androidx.compose.ui.graphics.painter.Painter + +/** + * Data class representing the resources required for an icon. + * + * @property iconPainter Painter for the icon. + * @property contentDescription String for the icon's content description. + * @property testTag The optional test tag to associate with this icon. + */ +data class IconResource( + val iconPainter: Painter, + val contentDescription: String, + val testTag: String? = null, +) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/model/TooltipData.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/model/TooltipData.kt new file mode 100644 index 0000000000..028f10546b --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/model/TooltipData.kt @@ -0,0 +1,13 @@ +package com.bitwarden.authenticator.ui.platform.components.model + +/** + * Data class representing the data needed to create a tooltip icon in a composable. + * + * @property onClick A lambda function that defines the action to be performed when the tooltip icon + * is clicked. + * @property contentDescription A text description of the icon for accessibility purposes. + */ +data class TooltipData( + val onClick: () -> Unit, + val contentDescription: String, +) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenExternalLinkRow.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenExternalLinkRow.kt new file mode 100644 index 0000000000..82e07c3fe7 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenExternalLinkRow.kt @@ -0,0 +1,88 @@ +package com.bitwarden.authenticator.ui.platform.components.row + +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.mirrorIfRtl +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenTwoButtonDialog +import com.bitwarden.authenticator.ui.platform.components.util.rememberVectorPainter +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme + +/** + * Represents a row of text that can be clicked on and contains an external link. + * A confirmation dialog will always be displayed before [onConfirmClick] is invoked. + * + * @param text The label for the row as a [String]. + * @param onConfirmClick The callback when the confirm button of the dialog is clicked. + * @param modifier The modifier to be applied to the layout. + * @param withDivider Indicates if a divider should be drawn on the bottom of the row, defaults + * to `true`. + * @param dialogTitle The title of the dialog displayed when the user clicks this item. + * @param dialogMessage The message of the dialog displayed when the user clicks this item. + * @param dialogConfirmButtonText The text on the confirm button of the dialog displayed when the + * user clicks this item. + * @param dialogDismissButtonText The text on the dismiss button of the dialog displayed when the + * user clicks this item. + */ +@Composable +fun BitwardenExternalLinkRow( + text: String, + onConfirmClick: () -> Unit, + modifier: Modifier = Modifier, + withDivider: Boolean = true, + dialogTitle: String, + dialogMessage: String, + dialogConfirmButtonText: String = stringResource(id = R.string.continue_text), + dialogDismissButtonText: String = stringResource(id = R.string.cancel), +) { + var shouldShowDialog by rememberSaveable { mutableStateOf(false) } + BitwardenTextRow( + text = text, + onClick = { shouldShowDialog = true }, + modifier = modifier, + withDivider = withDivider, + ) { + Icon( + modifier = Modifier.mirrorIfRtl(), + painter = rememberVectorPainter(id = R.drawable.ic_external_link), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + if (shouldShowDialog) { + BitwardenTwoButtonDialog( + title = dialogTitle, + message = dialogMessage, + confirmButtonText = dialogConfirmButtonText, + dismissButtonText = dialogDismissButtonText, + onConfirmClick = { + shouldShowDialog = false + onConfirmClick() + }, + onDismissClick = { shouldShowDialog = false }, + onDismissRequest = { shouldShowDialog = false }, + ) + } +} + +@Preview +@Composable +private fun BitwardenExternalLinkRow_preview() { + AuthenticatorTheme { + BitwardenExternalLinkRow( + text = "Linked Text", + onConfirmClick = { }, + dialogTitle = "", + dialogMessage = "", + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenRowOfActions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenRowOfActions.kt new file mode 100644 index 0000000000..5beee18af1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenRowOfActions.kt @@ -0,0 +1,58 @@ +package com.bitwarden.authenticator.ui.platform.components.row + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme + +/** + * A composable function to display a row of actions. + * + * This function takes in a trailing lambda which provides a `RowScope` in order to + * layout individual actions. The actions will be arranged in a horizontal + * sequence, spaced by 8.dp, and are vertically centered. + * + * @param actions The composable actions to execute within the [RowScope]. Typically used to + * layout individual icons or buttons. + */ +@Composable +fun BitwardenRowOfActions( + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit, +) { + Row( + modifier = modifier.padding(start = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + content = actions, + ) +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenRowOfIconButtons_preview() { + AuthenticatorTheme { + BitwardenRowOfActions { + Icon( + painter = painterResource(id = R.drawable.ic_tooltip), + contentDescription = "Icon 1", + modifier = Modifier.size(24.dp), + ) + Icon( + painter = painterResource(id = R.drawable.ic_tooltip), + contentDescription = "Icon 2", + modifier = Modifier.size(24.dp), + ) + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenTextRow.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenTextRow.kt new file mode 100644 index 0000000000..637458df8d --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenTextRow.kt @@ -0,0 +1,90 @@ +package com.bitwarden.authenticator.ui.platform.components.row + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +/** + * Represents a clickable row of text and can contains an optional [content] that appears to the + * right of the [text]. + * + * @param text The label for the row as a [String]. + * @param onClick The callback when the row is clicked. + * @param modifier The modifier to be applied to the layout. + * @param description An optional description label to be displayed below the [text]. + * @param withDivider Indicates if a divider should be drawn on the bottom of the row, defaults + * to `false`. + * @param content The content of the [BitwardenTextRow]. + */ +@Composable +fun BitwardenTextRow( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + description: String? = null, + withDivider: Boolean = false, + content: (@Composable () -> Unit)? = null, +) { + Box( + contentAlignment = Alignment.BottomCenter, + modifier = modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(color = MaterialTheme.colorScheme.primary), + onClick = onClick, + ) + .semantics(mergeDescendants = true) { }, + ) { + Row( + modifier = Modifier + .defaultMinSize(minHeight = 56.dp) + .padding(start = 16.dp, end = 24.dp, top = 8.dp, bottom = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .padding(end = 16.dp) + .weight(1f), + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + content?.invoke() + } + if (withDivider) { + HorizontalDivider( + modifier = Modifier.padding(start = 16.dp), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/scaffold/BitwardenScaffold.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/scaffold/BitwardenScaffold.kt new file mode 100644 index 0000000000..ad599f4296 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/scaffold/BitwardenScaffold.kt @@ -0,0 +1,58 @@ +package com.bitwarden.authenticator.ui.platform.components.scaffold + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.material3.FabPosition +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId + +/** + * Direct passthrough to [Scaffold] but contains a few specific override values. Everything is + * still overridable if necessary. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun BitwardenScaffold( + modifier: Modifier = Modifier, + topBar: @Composable () -> Unit = { }, + bottomBar: @Composable () -> Unit = { }, + snackbarHost: @Composable () -> Unit = { }, + floatingActionButton: @Composable () -> Unit = { }, + floatingActionButtonPosition: FabPosition = FabPosition.End, + containerColor: Color = MaterialTheme.colorScheme.surface, + contentColor: Color = contentColorFor(containerColor), + contentWindowInsets: WindowInsets = ScaffoldDefaults + .contentWindowInsets + .exclude(WindowInsets.navigationBars), + content: @Composable (PaddingValues) -> Unit, +) { + Scaffold( + modifier = Modifier + .semantics { testTagsAsResourceId = true } + .then(modifier), + topBar = topBar, + bottomBar = bottomBar, + snackbarHost = snackbarHost, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition, + containerColor = containerColor, + contentColor = contentColor, + contentWindowInsets = contentWindowInsets, + content = { paddingValues -> + Box { + content(paddingValues) + } + }, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/scrim/BitwardenAnimatedScrim.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/scrim/BitwardenAnimatedScrim.kt new file mode 100644 index 0000000000..03f0002dce --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/scrim/BitwardenAnimatedScrim.kt @@ -0,0 +1,45 @@ +package com.bitwarden.authenticator.ui.platform.components.scrim + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +/** + * A scrim that animates its visibility. + * + * @param isVisible Whether or not the scrim should be visible. This controls the animation. + * @param onClick A callback that is triggered when the scrim is clicked. No ripple will be + * performed. + * @param modifier A [Modifier] for the scrim's content. + */ +@Composable +fun BitwardenAnimatedScrim( + isVisible: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = modifier + .background(Color.Black.copy(alpha = 0.40f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + // Clear the ripple + indication = null, + onClick = onClick, + ), + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/stepper/BitwardenStepper.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/stepper/BitwardenStepper.kt new file mode 100644 index 0000000000..61ccb328dc --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/stepper/BitwardenStepper.kt @@ -0,0 +1,110 @@ +package com.bitwarden.authenticator.ui.platform.components.stepper + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.input.KeyboardType +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.ZERO_WIDTH_CHARACTER +import com.bitwarden.authenticator.ui.platform.base.util.orNullIfBlank +import com.bitwarden.authenticator.ui.platform.components.field.BitwardenTextFieldWithActions +import com.bitwarden.authenticator.ui.platform.components.icon.BitwardenIconButtonWithResource +import com.bitwarden.authenticator.ui.platform.components.model.IconResource +import com.bitwarden.authenticator.ui.platform.components.util.rememberVectorPainter + +/** + * Displays a stepper that allows the user to increment and decrement an int value. + * + * @param label Label for the stepper. + * @param value Value to display. Null will display nothing. Will be clamped to [range] before + * display. + * @param onValueChange callback invoked when the user increments or decrements the count. Note + * that this will not be called if the attempts to move value outside of [range]. + * @param modifier Modifier. + * @param range Range of valid values. + * @param isIncrementEnabled whether or not the increment button should be enabled. + * @param isDecrementEnabled whether or not the decrement button should be enabled. + * @param textFieldReadOnly whether or not the text field should be read only. The stepper + * increment and decrement buttons function regardless of this value. + */ +@Suppress("LongMethod") +@Composable +fun BitwardenStepper( + label: String, + value: Int?, + onValueChange: (Int) -> Unit, + modifier: Modifier = Modifier, + range: ClosedRange = 1..Int.MAX_VALUE, + isIncrementEnabled: Boolean = true, + isDecrementEnabled: Boolean = true, + textFieldReadOnly: Boolean = true, + stepperActionsTestTag: String? = null, + increaseButtonTestTag: String? = null, + decreaseButtonTestTag: String? = null, +) { + val clampedValue = value?.coerceIn(range) + if (clampedValue != value && clampedValue != null) { + onValueChange(clampedValue) + } + BitwardenTextFieldWithActions( + label = label, + // We use the zero width character instead of an empty string to make sure label is shown + // small and above the input + value = clampedValue + ?.toString() + ?: ZERO_WIDTH_CHARACTER, + actionsTestTag = stepperActionsTestTag, + actions = { + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = rememberVectorPainter(id = R.drawable.ic_minus), + contentDescription = "\u2212", + ), + onClick = { + val decrementedValue = ((value ?: 0) - 1).coerceIn(range) + if (decrementedValue != value) { + onValueChange(decrementedValue) + } + }, + isEnabled = isDecrementEnabled, + modifier = Modifier.semantics { + if (decreaseButtonTestTag != null) { + testTag = decreaseButtonTestTag + } + }, + ) + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = rememberVectorPainter(id = R.drawable.ic_plus), + contentDescription = "+", + ), + onClick = { + val incrementedValue = ((value ?: 0) + 1).coerceIn(range) + if (incrementedValue != value) { + onValueChange(incrementedValue) + } + }, + isEnabled = isIncrementEnabled, + modifier = Modifier.semantics { + if (increaseButtonTestTag != null) { + testTag = increaseButtonTestTag + } + }, + ) + }, + readOnly = textFieldReadOnly, + keyboardType = KeyboardType.Number, + onValueChange = { newValue -> + onValueChange( + newValue + // Make sure the placeholder is gone, since it will mess up the int conversion + .replace(ZERO_WIDTH_CHARACTER, "") + .orNullIfBlank() + ?.let { it.toIntOrNull()?.coerceIn(range) ?: value } + ?: range.start, + ) + }, + modifier = modifier, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/toggle/BitwardenSwitch.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/toggle/BitwardenSwitch.kt new file mode 100644 index 0000000000..1ba749c8d0 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/toggle/BitwardenSwitch.kt @@ -0,0 +1,118 @@ +package com.bitwarden.authenticator.ui.platform.components.toggle + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Represents a Bitwarden-styled [Switch]. + * + * @param label The label for the switch. + * @param isChecked Whether or not the switch is currently checked. + * @param onCheckedChange A callback for when the checked state changes. + * @param modifier The [Modifier] to be applied to the button. + * @param description The description of the switch to be displayed below the [label]. + */ +@Composable +fun BitwardenSwitch( + label: String, + isChecked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + description: String? = null, +) { + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .run { + if (onCheckedChange != null) { + this.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(color = MaterialTheme.colorScheme.primary), + onClick = { onCheckedChange.invoke(!isChecked) }, + ) + } else { + this + } + } + .semantics(mergeDescendants = true) { + toggleableState = ToggleableState(isChecked) + } + .then(modifier), + ) { + Switch( + modifier = Modifier + .padding(vertical = 8.dp) + .height(32.dp) + .width(52.dp), + checked = isChecked, + onCheckedChange = null, + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column { + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenSwitch_preview_isChecked() { + BitwardenSwitch( + label = "Label", + description = "Description", + isChecked = true, + onCheckedChange = {}, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenSwitch_preview_isNotChecked() { + BitwardenSwitch( + label = "Label", + isChecked = false, + onCheckedChange = {}, + modifier = Modifier.fillMaxWidth(), + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/toggle/BitwardenWideSwitch.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/toggle/BitwardenWideSwitch.kt new file mode 100644 index 0000000000..7088a5df2a --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/toggle/BitwardenWideSwitch.kt @@ -0,0 +1,130 @@ +package com.bitwarden.authenticator.ui.platform.components.toggle + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme + +/** + * A wide custom switch composable + * + * @param label The descriptive text label to be displayed adjacent to the switch. + * @param isChecked The current state of the switch (either checked or unchecked). + * @param onCheckedChange A lambda that is invoked when the switch's state changes. + * @param modifier A [Modifier] that you can use to apply custom modifications to the composable. + * @param description An optional description label to be displayed below the [label]. + * @param contentDescription A description of the switch's UI for accessibility purposes. + * @param readOnly Disables the click functionality without modifying the other UI characteristics. + * @param enabled Whether or not this switch is enabled. This is similar to setting [readOnly] but + * comes with some additional visual changes. + */ +@Composable +fun BitwardenWideSwitch( + label: String, + isChecked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + description: String? = null, + contentDescription: String? = null, + readOnly: Boolean = false, + enabled: Boolean = true, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .wrapContentHeight() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(color = MaterialTheme.colorScheme.primary), + onClick = { onCheckedChange?.invoke(!isChecked) }, + enabled = !readOnly && enabled, + ) + .semantics(mergeDescendants = true) { + toggleableState = ToggleableState(isChecked) + contentDescription?.let { this.contentDescription = it } + } + .then(modifier), + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.outline + }, + ) + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.outline + }, + ) + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Switch( + modifier = Modifier + .height(56.dp), + checked = isChecked, + onCheckedChange = null, + ) + } +} + +@Preview +@Composable +private fun BitwardenWideSwitch_preview_isChecked() { + AuthenticatorTheme { + BitwardenWideSwitch( + label = "Label", + isChecked = true, + onCheckedChange = {}, + ) + } +} + +@Preview +@Composable +private fun BitwardenWideSwitch_preview_isNotChecked() { + AuthenticatorTheme { + BitwardenWideSwitch( + label = "Label", + isChecked = false, + onCheckedChange = {}, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/DialogExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/DialogExtensions.kt new file mode 100644 index 0000000000..11da852ad2 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/DialogExtensions.kt @@ -0,0 +1,35 @@ +package com.bitwarden.authenticator.ui.platform.components.util + +import android.content.res.Configuration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Provides the maximum height [Dp] common for all dialogs with a given [Configuration]. + */ +val Configuration.maxDialogHeight: Dp + get() = when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> 312.dp + Configuration.ORIENTATION_PORTRAIT -> 542.dp + Configuration.ORIENTATION_UNDEFINED -> Dp.Unspecified + @Suppress("DEPRECATION") + Configuration.ORIENTATION_SQUARE, + -> Dp.Unspecified + + else -> Dp.Unspecified + } + +/** + * Provides the maximum width [Dp] common for all dialogs with a given [Configuration]. + */ +val Configuration.maxDialogWidth: Dp + get() = when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> 542.dp + Configuration.ORIENTATION_PORTRAIT -> 312.dp + Configuration.ORIENTATION_UNDEFINED -> Dp.Unspecified + @Suppress("DEPRECATION") + Configuration.ORIENTATION_SQUARE, + -> Dp.Unspecified + + else -> Dp.Unspecified + } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/NonLetterColorVisualTransformation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/NonLetterColorVisualTransformation.kt new file mode 100644 index 0000000000..825eaba597 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/NonLetterColorVisualTransformation.kt @@ -0,0 +1,62 @@ +package com.bitwarden.authenticator.ui.platform.components.util + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.withStyle + +/** + * Returns the [VisualTransformation] that alters the output of the text in an input field by + * applying different colors to the digits and special characters, letters will remain unaffected. + */ +@Composable +fun nonLetterColorVisualTransformation(): VisualTransformation { + val digitColor = MaterialTheme.colorScheme.primary + val specialCharacterColor = MaterialTheme.colorScheme.error + return remember(digitColor, specialCharacterColor) { + NonLetterColorVisualTransformation( + digitColor = digitColor, + specialCharacterColor = specialCharacterColor, + ) + } +} + +/** + * Alters the visual output of the text in an input field. + * + * All numbers in the text will have the [digitColor] applied to it and special characters will + * have the [specialCharacterColor] applied. + */ +private class NonLetterColorVisualTransformation( + private val digitColor: Color, + private val specialCharacterColor: Color, +) : VisualTransformation { + + override fun filter(text: AnnotatedString): TransformedText = + TransformedText( + buildTransformedAnnotatedString(text.toString()), + OffsetMapping.Identity, + ) + + private fun buildTransformedAnnotatedString(text: String): AnnotatedString { + val builder = AnnotatedString.Builder() + text.toCharArray().forEach { char -> + when { + char.isDigit() -> builder.withStyle(SpanStyle(color = digitColor)) { append(char) } + + !char.isLetter() -> { + builder.withStyle(SpanStyle(color = specialCharacterColor)) { append(char) } + } + + else -> builder.append(char) + } + } + return builder.toAnnotatedString() + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/RememberVectorPainter.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/RememberVectorPainter.kt new file mode 100644 index 0000000000..1f63459471 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/RememberVectorPainter.kt @@ -0,0 +1,19 @@ +package com.bitwarden.authenticator.ui.platform.components.util + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.VectorPainter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource + +/** + * Returns a [VectorPainter] built from the given [id] to circumvent issues with painter resources + * recomposing unnecessarily. + */ +@Composable +fun rememberVectorPainter( + @DrawableRes id: Int, +): VectorPainter = rememberVectorPainter( + image = ImageVector.vectorResource(id), +) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuNavigation.kt new file mode 100644 index 0000000000..85f3303ae4 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuNavigation.kt @@ -0,0 +1,29 @@ +package com.bitwarden.authenticator.ui.platform.feature.debugmenu + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import com.bitwarden.authenticator.ui.platform.base.util.composableWithPushTransitions + +private const val DEBUG_MENU = "debug_menu" + +/** + * Navigate to the setup unlock screen. + */ +fun NavController.navigateToDebugMenuScreen() { + this.navigate(DEBUG_MENU) { + launchSingleTop = true + } +} + +/** + * Add the setup unlock screen to the nav graph. + */ +fun NavGraphBuilder.setupDebugMenuDestination( + onNavigateBack: () -> Unit, +) { + composableWithPushTransitions( + route = DEBUG_MENU, + ) { + DebugMenuScreen(onNavigateBack = onNavigateBack) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuScreen.kt new file mode 100644 index 0000000000..303ade4d1d --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuScreen.kt @@ -0,0 +1,148 @@ +package com.bitwarden.authenticator.ui.platform.feature.debugmenu + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.base.util.standardHorizontalMargin +import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.authenticator.ui.platform.components.appbar.NavigationIcon +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledButton +import com.bitwarden.authenticator.ui.platform.components.divider.BitwardenHorizontalDivider +import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderText +import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.components.util.rememberVectorPainter +import com.bitwarden.authenticator.ui.platform.feature.debugmenu.components.ListItemContent +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme + +/** + * Top level screen for the debug menu. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongMethod") +@Composable +fun DebugMenuScreen( + onNavigateBack: () -> Unit, + viewModel: DebugMenuViewModel = hiltViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + DebugMenuEvent.NavigateBack -> onNavigateBack() + } + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(R.string.debug_menu), + scrollBehavior = scrollBehavior, + navigationIcon = NavigationIcon( + navigationIcon = rememberVectorPainter(R.drawable.ic_back), + navigationIconContentDescription = stringResource(id = R.string.back), + onNavigationIconClick = remember(viewModel) { + { + viewModel.trySendAction(DebugMenuAction.NavigateBack) + } + }, + ), + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(innerPadding), + ) { + Spacer(modifier = Modifier.height(16.dp)) + FeatureFlagContent( + featureFlagMap = state.featureFlags, + onValueChange = remember(viewModel) { + { key, value -> + viewModel.trySendAction(DebugMenuAction.UpdateFeatureFlag(key, value)) + } + }, + onResetValues = remember(viewModel) { + { + viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues) + } + }, + ) + } + } +} + +@Composable +private fun FeatureFlagContent( + featureFlagMap: Map, Any>, + onValueChange: (key: FlagKey, value: Any) -> Unit, + onResetValues: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenListHeaderText( + label = stringResource(R.string.feature_flags), + modifier = Modifier.standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(8.dp)) + BitwardenHorizontalDivider() + featureFlagMap.forEach { featureFlag -> + featureFlag.key.ListItemContent( + currentValue = featureFlag.value, + onValueChange = onValueChange, + modifier = Modifier.standardHorizontalMargin(), + ) + BitwardenHorizontalDivider() + } + Spacer(modifier = Modifier.height(12.dp)) + BitwardenFilledButton( + label = stringResource(R.string.reset_values), + onClick = onResetValues, + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Preview(showBackground = true) +@Composable +private fun FeatureFlagContent_preview() { + AuthenticatorTheme { + FeatureFlagContent( + featureFlagMap = mapOf( + FlagKey.BitwardenAuthenticationEnabled to true, + FlagKey.PasswordManagerSync to false, + ), + onValueChange = { _, _ -> }, + onResetValues = { }, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuViewModel.kt new file mode 100644 index 0000000000..43557dc26a --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuViewModel.kt @@ -0,0 +1,119 @@ +package com.bitwarden.authenticator.ui.platform.feature.debugmenu + +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * ViewModel for the [DebugMenuScreen] + */ +@HiltViewModel +class DebugMenuViewModel @Inject constructor( + featureFlagManager: FeatureFlagManager, + private val debugMenuRepository: DebugMenuRepository, +) : BaseViewModel( + initialState = DebugMenuState(featureFlags = emptyMap()), +) { + + private var featureFlagResetJob: Job? = null + + init { + combine( + flows = FlagKey.activeFlags.map { flagKey -> + featureFlagManager.getFeatureFlagFlow(flagKey).map { flagKey to it } + }, + ) { DebugMenuAction.Internal.UpdateFeatureFlagMap(it.toMap()) } + .onEach(::sendAction) + .launchIn(viewModelScope) + } + + override fun handleAction(action: DebugMenuAction) { + when (action) { + is DebugMenuAction.UpdateFeatureFlag<*> -> handleUpdateFeatureFlag(action) + is DebugMenuAction.Internal.UpdateFeatureFlagMap -> handleUpdateFeatureFlagMap(action) + DebugMenuAction.NavigateBack -> handleNavigateBack() + DebugMenuAction.ResetFeatureFlagValues -> handleResetFeatureFlagValues() + } + } + + private fun handleResetFeatureFlagValues() { + featureFlagResetJob?.cancel() + featureFlagResetJob = viewModelScope.launch { + debugMenuRepository.resetFeatureFlagOverrides() + } + } + + private fun handleNavigateBack() { + sendEvent(DebugMenuEvent.NavigateBack) + } + + private fun handleUpdateFeatureFlagMap(action: DebugMenuAction.Internal.UpdateFeatureFlagMap) { + mutableStateFlow.update { + it.copy(featureFlags = action.newMap) + } + } + + private fun handleUpdateFeatureFlag(action: DebugMenuAction.UpdateFeatureFlag<*>) { + debugMenuRepository.updateFeatureFlag(action.flagKey, action.newValue) + } +} + +/** + * State for the [DebugMenuViewModel] + */ +data class DebugMenuState( + val featureFlags: Map, Any>, +) + +/** + * Models event for the [DebugMenuViewModel] to send to the UI. + */ +sealed class DebugMenuEvent { + /** + * Navigates back to previous screen. + */ + data object NavigateBack : DebugMenuEvent() +} + +/** + * Models action for the [DebugMenuViewModel] to handle. + */ +sealed class DebugMenuAction { + + /** + * Updates a feature flag for the given [FlagKey] to the given [newValue]. + */ + data class UpdateFeatureFlag(val flagKey: FlagKey, val newValue: T) : + DebugMenuAction() + + /** + * The user has clicked "back" button. + */ + data object NavigateBack : DebugMenuAction() + + /** + * The user has clicked "reset" button for the feature flag section. + */ + data object ResetFeatureFlagValues : DebugMenuAction() + + /** + * Internal actions not triggered from the UI. + */ + sealed class Internal : DebugMenuAction() { + /** + * Update the feature flag map with the new value. + */ + data class UpdateFeatureFlagMap(val newMap: Map, Any>) : Internal() + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt new file mode 100644 index 0000000000..5e61dfa7a1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt @@ -0,0 +1,68 @@ +package com.bitwarden.authenticator.ui.platform.feature.debugmenu.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.ui.platform.components.toggle.BitwardenWideSwitch + +/** + * Creates a list item for a [FlagKey]. + */ +@Suppress("UNCHECKED_CAST") +@Composable +fun FlagKey.ListItemContent( + currentValue: T, + onValueChange: (key: FlagKey, value: T) -> Unit, + modifier: Modifier = Modifier, +) = when (val flagKey = this) { + FlagKey.DummyBoolean, + is FlagKey.DummyInt, + FlagKey.DummyString, + -> Unit + + FlagKey.BitwardenAuthenticationEnabled, + FlagKey.PasswordManagerSync, + -> BooleanFlagItem( + label = flagKey.getDisplayLabel(), + key = flagKey as FlagKey, + currentValue = currentValue as Boolean, + onValueChange = onValueChange as (FlagKey, Boolean) -> Unit, + modifier = modifier, + ) +} + +/** + * The UI layout for a boolean backed flag key. + */ +@Composable +private fun BooleanFlagItem( + label: String, + key: FlagKey, + currentValue: Boolean, + onValueChange: (key: FlagKey, value: Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + BitwardenWideSwitch( + label = label, + isChecked = currentValue, + onCheckedChange = { + onValueChange(key, it) + }, + modifier = modifier, + ) +} + +@Composable +private fun FlagKey.getDisplayLabel(): String = when (this) { + FlagKey.DummyBoolean, + is FlagKey.DummyInt, + FlagKey.DummyString, + -> this.keyName + + FlagKey.BitwardenAuthenticationEnabled -> + stringResource(R.string.bitwarden_authentication_enabled) + + FlagKey.PasswordManagerSync -> stringResource(R.string.password_manager_sync) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/di/DebugMenuModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/di/DebugMenuModule.kt new file mode 100644 index 0000000000..e7fc7e0085 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/di/DebugMenuModule.kt @@ -0,0 +1,22 @@ +package com.bitwarden.authenticator.ui.platform.feature.debugmenu.di + +import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository +import com.bitwarden.authenticator.ui.platform.feature.debugmenu.manager.DebugLaunchManagerImpl +import com.bitwarden.authenticator.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * Provides dependencies for the debug menu. + */ +@Module +@InstallIn(SingletonComponent::class) +class DebugMenuModule { + + @Provides + fun provideDebugMenuLaunchManager( + debugMenuRepository: DebugMenuRepository, + ): DebugMenuLaunchManager = DebugLaunchManagerImpl(debugMenuRepository = debugMenuRepository) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugLaunchManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugLaunchManagerImpl.kt new file mode 100644 index 0000000000..a4693bb997 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugLaunchManagerImpl.kt @@ -0,0 +1,67 @@ +package com.bitwarden.authenticator.ui.platform.feature.debugmenu.manager + +import android.view.InputEvent +import android.view.KeyEvent +import android.view.MotionEvent +import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository + +private const val TAP_TIME_THRESHOLD_MILLIS = 500 +private const val POINTERS_REQUIRED = 3 + +/** + * Default implementation of the [DebugMenuLaunchManager] + */ +class DebugLaunchManagerImpl( + private val debugMenuRepository: DebugMenuRepository, +) : DebugMenuLaunchManager { + + private val tapEventQueue: ArrayDeque = ArrayDeque() + + override fun actionOnInputEvent( + event: InputEvent, + action: () -> Unit, + ): Boolean { + val shouldTakeAction = when (event) { + is KeyEvent -> event.debugTrigger() + is MotionEvent -> shouldHandleMotionEvent(event) + else -> false + } + + if (shouldTakeAction) { + action() + } + + return shouldTakeAction + } + + private fun shouldHandleMotionEvent(event: MotionEvent): Boolean { + if (!event.debugTrigger()) return false + // Pop old tap events until we have ones within our threshold + while ( + tapEventQueue + .firstOrNull() + ?.let { event.eventTime - it >= TAP_TIME_THRESHOLD_MILLIS } == true + ) { + tapEventQueue.removeFirst() + } + + // Add this tap event + tapEventQueue.add(event.eventTime) + return event.eventTime - tapEventQueue.first() < TAP_TIME_THRESHOLD_MILLIS && + tapEventQueue.size >= POINTERS_REQUIRED + } + + /** + * This is the equivalent of the entry of `shift` + `~` on a US keyboard. + */ + private fun KeyEvent.debugTrigger(): Boolean = + action == KeyEvent.ACTION_DOWN && + keyCode == KeyEvent.KEYCODE_GRAVE && + isShiftPressed && + debugMenuRepository.isDebugMenuEnabled + + private fun MotionEvent.debugTrigger(): Boolean = + action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_POINTER_DOWN && + pointerCount == POINTERS_REQUIRED && + debugMenuRepository.isDebugMenuEnabled +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugMenuLaunchManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugMenuLaunchManager.kt new file mode 100644 index 0000000000..c030f2b122 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugMenuLaunchManager.kt @@ -0,0 +1,18 @@ +package com.bitwarden.authenticator.ui.platform.feature.debugmenu.manager + +import android.view.InputEvent + +/** + * Manager for abstracting the logic of launching debug menu. + */ +interface DebugMenuLaunchManager { + + /** + * Defines an interface to action on specific input events. + * @param event the input event to evaluate + * @param action the action to perform if the event matches + * + * @return true if the action was performed, false otherwise. + */ + fun actionOnInputEvent(event: InputEvent, action: () -> Unit): Boolean +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt new file mode 100644 index 0000000000..4949a9f0ac --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt @@ -0,0 +1,186 @@ +package com.bitwarden.authenticator.ui.platform.feature.rootnav + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions +import com.bitwarden.authenticator.ui.auth.unlock.UNLOCK_ROUTE +import com.bitwarden.authenticator.ui.auth.unlock.navigateToUnlock +import com.bitwarden.authenticator.ui.auth.unlock.unlockDestination +import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.AUTHENTICATOR_GRAPH_ROUTE +import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.authenticatorGraph +import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.navigateToAuthenticatorGraph +import com.bitwarden.authenticator.ui.platform.feature.debugmenu.setupDebugMenuDestination +import com.bitwarden.authenticator.ui.platform.feature.splash.SPLASH_ROUTE +import com.bitwarden.authenticator.ui.platform.feature.splash.navigateToSplash +import com.bitwarden.authenticator.ui.platform.feature.splash.splashDestination +import com.bitwarden.authenticator.ui.platform.feature.tutorial.TUTORIAL_ROUTE +import com.bitwarden.authenticator.ui.platform.feature.tutorial.navigateToTutorial +import com.bitwarden.authenticator.ui.platform.feature.tutorial.tutorialDestination +import com.bitwarden.authenticator.ui.platform.theme.NonNullEnterTransitionProvider +import com.bitwarden.authenticator.ui.platform.theme.NonNullExitTransitionProvider +import com.bitwarden.authenticator.ui.platform.theme.RootTransitionProviders +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import java.util.concurrent.atomic.AtomicReference + +/** + * Controls the root level [NavHost] for the app. + */ +@Suppress("LongMethod") +@Composable +fun RootNavScreen( + viewModel: RootNavViewModel = hiltViewModel(), + navController: NavHostController = rememberNavController(), + onSplashScreenRemoved: () -> Unit = {}, + onExitApplication: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsState() + val previousStateReference = remember { AtomicReference(state) } + + val isNotSplashScreen = state.navState !is RootNavState.NavState.Splash + LaunchedEffect(isNotSplashScreen) { + if (isNotSplashScreen) { + onSplashScreenRemoved() + } + } + + LaunchedEffect(Unit) { + navController.currentBackStackEntryFlow + .onEach { + viewModel.trySendAction(RootNavAction.BackStackUpdate) + } + .launchIn(this) + } + + NavHost( + navController = navController, + startDestination = SPLASH_ROUTE, + enterTransition = { toEnterTransition()(this) }, + exitTransition = { toExitTransition()(this) }, + popEnterTransition = { toEnterTransition()(this) }, + popExitTransition = { toExitTransition()(this) }, + ) { + splashDestination() + tutorialDestination( + onTutorialFinished = { + viewModel.trySendAction(RootNavAction.Internal.TutorialFinished) + }, + ) + unlockDestination( + onUnlocked = { + viewModel.trySendAction(RootNavAction.Internal.AppUnlocked) + }, + ) + setupDebugMenuDestination( + onNavigateBack = { + navController.popBackStack() + }, + ) + authenticatorGraph( + navController = navController, + onNavigateBack = onExitApplication, + ) + } + + val targetRoute = when (state.navState) { + RootNavState.NavState.Splash -> SPLASH_ROUTE + RootNavState.NavState.Locked -> UNLOCK_ROUTE + RootNavState.NavState.Tutorial -> TUTORIAL_ROUTE + RootNavState.NavState.Unlocked -> AUTHENTICATOR_GRAPH_ROUTE + } + + val currentRoute = navController.currentDestination?.rootLevelRoute() + // Don't navigate if we are already at the correct root. This notably happens during process + // death. In this case, the NavHost already restores state, so we don't have to navigate. + // However, if the route is correct but the underlying state is different, we should still + // proceed in order to get a fresh version of that route. + if (currentRoute == targetRoute && previousStateReference.get() == state) { + previousStateReference.set(state) + return + } + previousStateReference.set(state) + + // When state changes, navigate to different root navigation state + val rootNavOptions = navOptions { + // When changing root navigation state, pop everything else off the back stack: + popUpTo(navController.graph.id) { + inclusive = false + saveState = false + } + launchSingleTop = true + restoreState = false + } + + LaunchedEffect(state) { + when (state.navState) { + RootNavState.NavState.Splash -> { + navController.navigateToSplash(rootNavOptions) + } + + RootNavState.NavState.Tutorial -> { + navController.navigateToTutorial(rootNavOptions) + } + + RootNavState.NavState.Locked -> { + navController.navigateToUnlock(rootNavOptions) + } + + RootNavState.NavState.Unlocked -> { + navController.navigateToAuthenticatorGraph(rootNavOptions) + } + } + } +} + +/** + * Helper method that returns the highest level route for the given [NavDestination]. + * + * As noted above, this can be removed after upgrading to latest compose navigation, since + * the nav args can prevent us from having to do this check. + */ +@Suppress("ReturnCount") +private fun NavDestination?.rootLevelRoute(): String? { + if (this == null) { + return null + } + if (parent?.route == null) { + return route + } + return parent.rootLevelRoute() +} + +/** + * Define the enter transition for each route. + */ +@Suppress("MaxLineLength") +private fun AnimatedContentTransitionScope.toEnterTransition(): NonNullEnterTransitionProvider = + when (targetState.destination.rootLevelRoute()) { + else -> when (initialState.destination.rootLevelRoute()) { + // Disable transitions when coming from the splash screen + SPLASH_ROUTE -> RootTransitionProviders.Enter.none + else -> RootTransitionProviders.Enter.fadeIn + } + } + +/** + * Define the exit transition for each route. + */ +@Suppress("MaxLineLength") +private fun AnimatedContentTransitionScope.toExitTransition(): NonNullExitTransitionProvider = + when (initialState.destination.rootLevelRoute()) { + // Disable transitions when coming from the splash screen + SPLASH_ROUTE -> RootTransitionProviders.Exit.none + else -> when (targetState.destination.rootLevelRoute()) { + else -> RootTransitionProviders.Exit.fadeOut + } + } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt new file mode 100644 index 0000000000..effb02837c --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -0,0 +1,181 @@ +package com.bitwarden.authenticator.ui.platform.feature.rootnav + +import android.os.Parcelable +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.data.auth.repository.AuthRepository +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +/** + * Manages root level navigation state for the application. + */ +@HiltViewModel +class RootNavViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val settingsRepository: SettingsRepository, + private val biometricsEncryptionManager: BiometricsEncryptionManager, +) : BaseViewModel( + initialState = RootNavState( + hasSeenWelcomeGuide = settingsRepository.hasSeenWelcomeTutorial, + navState = RootNavState.NavState.Splash, + ), +) { + + init { + viewModelScope.launch { + settingsRepository.hasSeenWelcomeTutorialFlow + .map { RootNavAction.Internal.HasSeenWelcomeTutorialChange(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + } + } + + override fun handleAction(action: RootNavAction) { + when (action) { + RootNavAction.BackStackUpdate -> { + handleBackStackUpdate() + } + + is RootNavAction.Internal.HasSeenWelcomeTutorialChange -> { + handleHasSeenWelcomeTutorialChange(action.hasSeenWelcomeGuide) + } + + RootNavAction.Internal.TutorialFinished -> { + handleTutorialFinished() + } + + RootNavAction.Internal.SplashScreenDismissed -> { + handleSplashScreenDismissed() + } + + RootNavAction.Internal.AppUnlocked -> { + handleAppUnlocked() + } + } + } + + private fun handleBackStackUpdate() { + authRepository.updateLastActiveTime() + } + + private fun handleHasSeenWelcomeTutorialChange(hasSeenWelcomeGuide: Boolean) { + settingsRepository.hasSeenWelcomeTutorial = hasSeenWelcomeGuide + if (hasSeenWelcomeGuide) { + if (settingsRepository.isUnlockWithBiometricsEnabled && + biometricsEncryptionManager.isBiometricIntegrityValid()) { + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Locked) } + } else { + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) } + } + } else { + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Tutorial) } + } + } + + private fun handleTutorialFinished() { + settingsRepository.hasSeenWelcomeTutorial = true + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) } + } + + private fun handleSplashScreenDismissed() { + if (settingsRepository.hasSeenWelcomeTutorial) { + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) } + } else { + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Tutorial) } + } + } + + private fun handleAppUnlocked() { + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Unlocked) + } + } +} + +/** + * Models root level navigation state for the app. + * + * @property hasSeenWelcomeGuide Indicates if the user has seen the Welcome Guide screen. + * @property navState Current destination state of the app. + */ +@Parcelize +data class RootNavState( + val hasSeenWelcomeGuide: Boolean, + val navState: NavState, +) : Parcelable { + + /** + * Models root level destinations for the app. + */ + @Parcelize + sealed class NavState : Parcelable { + /** + * App should display the Splash nav graph. + */ + @Parcelize + data object Splash : NavState() + + /** + * App should display the Unlock screen. + */ + @Parcelize + data object Locked : NavState() + + /** + * App should display the Tutorial nav graph. + */ + @Parcelize + data object Tutorial : NavState() + + /** + * App should display the Account List nav graph. + */ + @Parcelize + data object Unlocked : NavState() + } +} + +/** + * Models root navigation actions. + */ +sealed class RootNavAction { + /** + * Indicates the backstack has changed. + */ + data object BackStackUpdate : RootNavAction() + + /** + * Models actions the [RootNavViewModel] itself may send. + */ + sealed class Internal : RootNavAction() { + + /** + * Splash screen has been dismissed. + */ + data object SplashScreenDismissed : Internal() + + /** + * Indicates the user finished or skipped opening tutorial slides. + */ + data object TutorialFinished : Internal() + + /** + * Indicates the application has been unlocked. + */ + data object AppUnlocked : Internal() + + /** + * Indicates an update in the welcome guide being seen has been received. + */ + data class HasSeenWelcomeTutorialChange(val hasSeenWelcomeGuide: Boolean) : Internal() + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsNavigation.kt new file mode 100644 index 0000000000..ad796c81d3 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsNavigation.kt @@ -0,0 +1,54 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.navigation +import com.bitwarden.authenticator.ui.platform.base.util.composableWithRootPushTransitions +import com.bitwarden.authenticator.ui.platform.feature.settings.export.exportDestination +import com.bitwarden.authenticator.ui.platform.feature.settings.importing.importingDestination +import com.bitwarden.authenticator.ui.platform.feature.tutorial.tutorialSettingsDestination + +const val SETTINGS_GRAPH_ROUTE = "settings_graph" +private const val SETTINGS_ROUTE = "settings" + +/** + * Add settings graph to the nav graph. + */ +fun NavGraphBuilder.settingsGraph( + navController: NavController, + onNavigateToExport: () -> Unit, + onNavigateToImport: () -> Unit, + onNavigateToTutorial: () -> Unit, +) { + navigation( + startDestination = SETTINGS_ROUTE, + route = SETTINGS_GRAPH_ROUTE, + ) { + composableWithRootPushTransitions( + route = SETTINGS_ROUTE, + ) { + SettingsScreen( + onNavigateToTutorial = onNavigateToTutorial, + onNavigateToExport = onNavigateToExport, + onNavigateToImport = onNavigateToImport, + ) + } + tutorialSettingsDestination( + onTutorialFinished = { navController.popBackStack() }, + ) + exportDestination( + onNavigateBack = { navController.popBackStack() }, + ) + importingDestination( + onNavigateBack = { navController.popBackStack() }, + ) + } +} + +/** + * Navigate to the settings screen. + */ +fun NavController.navigateToSettingsGraph(navOptions: NavOptions? = null) { + navigate(SETTINGS_GRAPH_ROUTE, navOptions) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt new file mode 100644 index 0000000000..38142a9d49 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -0,0 +1,649 @@ +@file:Suppress("TooManyFunctions") + +package com.bitwarden.authenticator.ui.platform.feature.settings + +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.base.util.mirrorIfRtl +import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenMediumTopAppBar +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenSelectionDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenSelectionRow +import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderText +import com.bitwarden.authenticator.ui.platform.components.row.BitwardenExternalLinkRow +import com.bitwarden.authenticator.ui.platform.components.row.BitwardenTextRow +import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.components.toggle.BitwardenWideSwitch +import com.bitwarden.authenticator.ui.platform.components.util.rememberVectorPainter +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme +import com.bitwarden.authenticator.ui.platform.theme.LocalBiometricsManager +import com.bitwarden.authenticator.ui.platform.theme.LocalIntentManager +import com.bitwarden.authenticator.ui.platform.util.displayLabel + +/** + * Display the settings screen. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + viewModel: SettingsViewModel = hiltViewModel(), + biometricsManager: BiometricsManager = LocalBiometricsManager.current, + intentManager: IntentManager = LocalIntentManager.current, + onNavigateToTutorial: () -> Unit, + onNavigateToExport: () -> Unit, + onNavigateToImport: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val scrollBehavior = + TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + SettingsEvent.NavigateToTutorial -> onNavigateToTutorial() + SettingsEvent.NavigateToExport -> onNavigateToExport() + SettingsEvent.NavigateToImport -> onNavigateToImport() + SettingsEvent.NavigateToBackup -> { + intentManager.launchUri( + uri = "https://support.google.com/android/answer/2819582".toUri(), + ) + } + + SettingsEvent.NavigateToHelpCenter -> { + intentManager.launchUri("https://bitwarden.com/help".toUri()) + } + + SettingsEvent.NavigateToPrivacyPolicy -> { + intentManager.launchUri("https://bitwarden.com/privacy".toUri()) + } + + SettingsEvent.NavigateToBitwardenApp -> { + + intentManager.startActivity( + Intent( + Intent.ACTION_VIEW, + "bitwarden://settings/account_security".toUri(), + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } + + SettingsEvent.NavigateToBitwardenPlayStoreListing -> { + intentManager.launchUri( + "https://play.google.com/store/apps/details?id=com.x8bit.bitwarden".toUri(), + ) + } + } + } + + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenMediumTopAppBar( + title = stringResource(id = R.string.settings), + scrollBehavior = scrollBehavior, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(state = rememberScrollState()), + ) { + SecuritySettings( + state = state, + biometricsManager = biometricsManager, + onBiometricToggle = remember(viewModel) { + { + viewModel.trySendAction( + SettingsAction.SecurityClick.UnlockWithBiometricToggle(it), + ) + } + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + VaultSettings( + onExportClick = remember(viewModel) { + { + viewModel.trySendAction(SettingsAction.DataClick.ExportClick) + } + }, + onImportClick = remember(viewModel) { + { + viewModel.trySendAction(SettingsAction.DataClick.ImportClick) + } + }, + onBackupClick = remember(viewModel) { + { + viewModel.trySendAction(SettingsAction.DataClick.BackupClick) + } + }, + onSyncWithBitwardenClick = remember(viewModel) { + { + viewModel.trySendAction(SettingsAction.DataClick.SyncWithBitwardenClick) + } + }, + onDefaultSaveOptionUpdated = remember(viewModel) { + { + viewModel.trySendAction( + SettingsAction.DataClick.DefaultSaveOptionUpdated(it), + ) + } + }, + defaultSaveOption = state.defaultSaveOption, + shouldShowDefaultSaveOptions = state.showDefaultSaveOptionRow, + shouldShowSyncWithBitwardenApp = state.showSyncWithBitwarden, + ) + Spacer(modifier = Modifier.height(16.dp)) + AppearanceSettings( + state = state, + onThemeSelection = remember(viewModel) { + { + viewModel.trySendAction(SettingsAction.AppearanceChange.ThemeChange(it)) + } + }, + ) + Spacer(Modifier.height(16.dp)) + HelpSettings( + onTutorialClick = remember(viewModel) { + { + viewModel.trySendAction(SettingsAction.HelpClick.ShowTutorialClick) + } + }, + onHelpCenterClick = remember(viewModel) { + { + viewModel.trySendAction(SettingsAction.HelpClick.HelpCenterClick) + } + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + AboutSettings( + modifier = Modifier + .padding(horizontal = 16.dp), + state = state, + onSubmitCrashLogsCheckedChange = remember(viewModel) { + { viewModel.trySendAction(SettingsAction.AboutClick.SubmitCrashLogsClick(it)) } + }, + onPrivacyPolicyClick = remember(viewModel) { + { viewModel.trySendAction(SettingsAction.AboutClick.PrivacyPolicyClick) } + }, + onVersionClick = remember(viewModel) { + { viewModel.trySendAction(SettingsAction.AboutClick.VersionClick) } + }, + ) + Box( + modifier = Modifier + .defaultMinSize(minHeight = 56.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.padding(end = 16.dp), + text = state.copyrightInfo.invoke(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + +//region Security settings + +@Composable +private fun SecuritySettings( + state: SettingsState, + biometricsManager: BiometricsManager = LocalBiometricsManager.current, + onBiometricToggle: (Boolean) -> Unit, +) { + if (!biometricsManager.isBiometricsSupported) return + + BitwardenListHeaderText( + modifier = Modifier.padding(horizontal = 16.dp), + label = stringResource(id = R.string.security), + ) + Spacer(modifier = Modifier.height(8.dp)) + UnlockWithBiometricsRow( + modifier = Modifier + .testTag("UnlockWithBiometricsSwitch") + .fillMaxWidth() + .padding(horizontal = 16.dp), + isChecked = state.isUnlockWithBiometricsEnabled, + onBiometricToggle = { onBiometricToggle(it) }, + biometricsManager = biometricsManager, + ) +} + +//endregion + +//region Data settings + +@Composable +@Suppress("LongMethod") +private fun VaultSettings( + modifier: Modifier = Modifier, + defaultSaveOption: DefaultSaveOption, + onExportClick: () -> Unit, + onImportClick: () -> Unit, + onBackupClick: () -> Unit, + onSyncWithBitwardenClick: () -> Unit, + onDefaultSaveOptionUpdated: (DefaultSaveOption) -> Unit, + shouldShowSyncWithBitwardenApp: Boolean, + shouldShowDefaultSaveOptions: Boolean, +) { + BitwardenListHeaderText( + modifier = Modifier.padding(horizontal = 16.dp), + label = stringResource(id = R.string.data), + ) + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextRow( + text = stringResource(id = R.string.import_vault), + onClick = onImportClick, + modifier = modifier + .semantics { testTag = "Import" }, + withDivider = true, + content = { + Icon( + modifier = Modifier + .mirrorIfRtl() + .size(24.dp), + painter = painterResource(id = R.drawable.ic_navigate_next), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + ) + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextRow( + text = stringResource(id = R.string.export), + onClick = onExportClick, + modifier = modifier + .semantics { testTag = "Export" }, + withDivider = true, + content = { + Icon( + modifier = Modifier + .mirrorIfRtl() + .size(24.dp), + painter = painterResource(id = R.drawable.ic_navigate_next), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + ) + Spacer(modifier = Modifier.height(8.dp)) + BitwardenExternalLinkRow( + text = stringResource(R.string.backup), + onConfirmClick = onBackupClick, + modifier = modifier + .semantics { testTag = "Backup" }, + dialogTitle = stringResource(R.string.data_backup_title), + dialogMessage = stringResource(R.string.data_backup_message), + dialogConfirmButtonText = stringResource(R.string.learn_more), + dialogDismissButtonText = stringResource(R.string.ok), + ) + if (shouldShowSyncWithBitwardenApp) { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextRow( + text = stringResource(id = R.string.sync_with_bitwarden_app), + onClick = onSyncWithBitwardenClick, + modifier = modifier, + withDivider = true, + content = { + Icon( + modifier = Modifier + .mirrorIfRtl() + .size(24.dp), + painter = painterResource(id = R.drawable.ic_external_link), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + ) + } + if (shouldShowDefaultSaveOptions) { + DefaultSaveOptionSelectionRow( + currentSelection = defaultSaveOption, + onSaveOptionUpdated = onDefaultSaveOptionUpdated, + ) + } +} + +@Composable +private fun DefaultSaveOptionSelectionRow( + currentSelection: DefaultSaveOption, + onSaveOptionUpdated: (DefaultSaveOption) -> Unit, + modifier: Modifier = Modifier, +) { + var shouldShowDefaultSaveOptionDialog by remember { mutableStateOf(false) } + + BitwardenTextRow( + text = stringResource(id = R.string.default_save_option), + onClick = { shouldShowDefaultSaveOptionDialog = true }, + modifier = modifier, + withDivider = true, + ) { + Text( + modifier = Modifier.padding(vertical = 20.dp), + text = currentSelection.displayLabel(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + var dialogSelection by remember { mutableStateOf(currentSelection) } + if (shouldShowDefaultSaveOptionDialog) { + BitwardenSelectionDialog( + title = stringResource(id = R.string.default_save_option), + subtitle = stringResource(id = R.string.default_save_options_subtitle), + dismissLabel = stringResource(id = R.string.confirm), + onDismissRequest = { shouldShowDefaultSaveOptionDialog = false }, + onDismissActionClick = { + onSaveOptionUpdated(dialogSelection) + shouldShowDefaultSaveOptionDialog = false + }, + ) { + DefaultSaveOption.entries.forEach { option -> + BitwardenSelectionRow( + text = option.displayLabel, + isSelected = option == dialogSelection, + onClick = { + dialogSelection = DefaultSaveOption.entries.first { it == option } + }, + ) + } + } + } +} + +@Composable +private fun UnlockWithBiometricsRow( + isChecked: Boolean, + onBiometricToggle: (Boolean) -> Unit, + biometricsManager: BiometricsManager, + modifier: Modifier = Modifier, +) { + if (!biometricsManager.isBiometricsSupported) return + var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) } + BitwardenWideSwitch( + modifier = modifier, + label = stringResource( + id = R.string.unlock_with, + stringResource(id = R.string.biometrics), + ), + isChecked = isChecked || showBiometricsPrompt, + onCheckedChange = { toggled -> + if (toggled) { + showBiometricsPrompt = true + biometricsManager.promptBiometrics( + onSuccess = { + onBiometricToggle(true) + showBiometricsPrompt = false + }, + onCancel = { showBiometricsPrompt = false }, + onLockOut = { showBiometricsPrompt = false }, + onError = { showBiometricsPrompt = false }, + ) + } else { + onBiometricToggle(false) + } + }, + ) +} + +//endregion Data settings + +//region Appearance settings + +@Composable +private fun AppearanceSettings( + state: SettingsState, + onThemeSelection: (theme: AppTheme) -> Unit, +) { + BitwardenListHeaderText( + modifier = Modifier.padding(horizontal = 16.dp), + label = stringResource(id = R.string.appearance), + ) + ThemeSelectionRow( + currentSelection = state.appearance.theme, + onThemeSelection = onThemeSelection, + modifier = Modifier + .semantics { testTag = "ThemeChooser" } + .fillMaxWidth(), + ) +} + +@Composable +private fun ThemeSelectionRow( + currentSelection: AppTheme, + onThemeSelection: (AppTheme) -> Unit, + modifier: Modifier = Modifier, +) { + var shouldShowThemeSelectionDialog by remember { mutableStateOf(false) } + + BitwardenTextRow( + text = stringResource(id = R.string.theme), + onClick = { shouldShowThemeSelectionDialog = true }, + modifier = modifier, + withDivider = true, + ) { + Icon( + modifier = Modifier + .mirrorIfRtl() + .size(24.dp), + painter = painterResource( + id = R.drawable.ic_navigate_next, + ), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + if (shouldShowThemeSelectionDialog) { + BitwardenSelectionDialog( + title = stringResource(id = R.string.theme), + onDismissRequest = { shouldShowThemeSelectionDialog = false }, + ) { + AppTheme.entries.forEach { option -> + BitwardenSelectionRow( + text = option.displayLabel, + isSelected = option == currentSelection, + onClick = { + shouldShowThemeSelectionDialog = false + onThemeSelection( + AppTheme.entries.first { it == option }, + ) + }, + ) + } + } + } +} + +//endregion Appearance settings + +//region Help settings + +@Composable +private fun HelpSettings( + modifier: Modifier = Modifier, + onTutorialClick: () -> Unit, + onHelpCenterClick: () -> Unit, +) { + BitwardenListHeaderText( + modifier = Modifier.padding(horizontal = 16.dp), + label = stringResource(id = R.string.help), + ) + BitwardenTextRow( + text = stringResource(id = R.string.launch_tutorial), + onClick = onTutorialClick, + modifier = modifier + .semantics { testTag = "LaunchTutorial" }, + withDivider = true, + ) + Spacer(modifier = Modifier.height(8.dp)) + BitwardenExternalLinkRow( + text = stringResource(id = R.string.bitwarden_help_center), + onConfirmClick = onHelpCenterClick, + modifier = modifier + .semantics { testTag = "BitwardenHelpCenter" }, + dialogTitle = stringResource(id = R.string.continue_to_help_center), + dialogMessage = stringResource( + id = R.string.learn_more_about_how_to_use_bitwarden_on_the_help_center, + ), + ) +} + +//endregion Help settings + +//region About settings +@Composable +private fun AboutSettings( + modifier: Modifier = Modifier, + state: SettingsState, + onSubmitCrashLogsCheckedChange: (Boolean) -> Unit, + onPrivacyPolicyClick: () -> Unit, + onVersionClick: () -> Unit, +) { + BitwardenListHeaderText( + modifier = modifier, + label = stringResource(id = R.string.about), + ) + BitwardenWideSwitch( + modifier = modifier + .semantics { testTag = "SubmitCrashLogs" }, + label = stringResource(id = R.string.submit_crash_logs), + isChecked = state.isSubmitCrashLogsEnabled, + onCheckedChange = onSubmitCrashLogsCheckedChange, + ) + BitwardenExternalLinkRow( + text = stringResource(id = R.string.privacy_policy), + modifier = modifier + .semantics { testTag = "PrivacyPolicy" }, + onConfirmClick = onPrivacyPolicyClick, + dialogTitle = stringResource(id = R.string.continue_to_privacy_policy), + dialogMessage = stringResource( + id = R.string.privacy_policy_description_long, + ), + ) + CopyRow( + text = state.version, + onClick = onVersionClick, + ) +} + +@Composable +private fun CopyRow( + text: Text, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val resources = LocalContext.current.resources + Box( + contentAlignment = Alignment.BottomCenter, + modifier = modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(color = MaterialTheme.colorScheme.primary), + onClick = onClick, + ) + .semantics(mergeDescendants = true) { + contentDescription = text.toString(resources) + }, + ) { + Row( + modifier = Modifier + .defaultMinSize(minHeight = 56.dp) + .padding(start = 16.dp, end = 24.dp, top = 8.dp, bottom = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .padding(end = 16.dp) + .weight(1f), + text = text(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Icon( + painter = rememberVectorPainter(id = R.drawable.ic_copy), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + HorizontalDivider( + modifier = Modifier.padding(start = 16.dp), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + } +} + +//endregion About settings + +@Preview +@Composable +private fun CopyRow_preview() { + AuthenticatorTheme { + CopyRow( + text = "Copyable Text".asText(), + onClick = { }, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt new file mode 100644 index 0000000000..9933835684 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -0,0 +1,579 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings + +import android.os.Parcelable +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.BuildConfig +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled +import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager +import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.base.util.concat +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import java.time.Clock +import java.time.Year +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * View model for the settings screen. + */ +@Suppress("TooManyFunctions", "LongParameterList") +@HiltViewModel +class SettingsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + clock: Clock, + private val authenticatorRepository: AuthenticatorRepository, + private val authenticatorBridgeManager: AuthenticatorBridgeManager, + private val settingsRepository: SettingsRepository, + private val clipboardManager: BitwardenClipboardManager, + featureFlagManager: FeatureFlagManager, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: createInitialState( + clock = clock, + appLanguage = settingsRepository.appLanguage, + appTheme = settingsRepository.appTheme, + unlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled, + isSubmitCrashLogsEnabled = settingsRepository.isCrashLoggingEnabled, + isSyncWithBitwardenFeatureEnabled = + featureFlagManager.getFeatureFlag(FlagKey.PasswordManagerSync), + accountSyncState = authenticatorBridgeManager.accountSyncStateFlow.value, + defaultSaveOption = settingsRepository.defaultSaveOption, + sharedAccountsState = authenticatorRepository.sharedCodesStateFlow.value, + ), +) { + + init { + authenticatorRepository + .sharedCodesStateFlow + .map { SettingsAction.Internal.SharedAccountsStateUpdated(it) } + .onEach(::handleAction) + .launchIn(viewModelScope) + + settingsRepository + .defaultSaveOptionFlow + .map { SettingsAction.Internal.DefaultSaveOptionUpdated(it) } + .onEach(::handleAction) + .launchIn(viewModelScope) + } + + override fun handleAction(action: SettingsAction) { + when (action) { + is SettingsAction.SecurityClick -> { + handleSecurityClick(action) + } + + is SettingsAction.DataClick -> { + handleVaultClick(action) + } + + is SettingsAction.AppearanceChange -> { + handleAppearanceChange(action) + } + + is SettingsAction.HelpClick -> { + handleHelpClick(action) + } + + is SettingsAction.AboutClick -> { + handleAboutClick(action) + } + + is SettingsAction.Internal.BiometricsKeyResultReceive -> { + handleBiometricsKeyResultReceive(action) + } + + is SettingsAction.Internal.SharedAccountsStateUpdated -> { + handleSharedAccountsStateUpdated(action) + } + + is SettingsAction.Internal.DefaultSaveOptionUpdated -> { + handleDefaultSaveOptionUpdated(action) + } + } + } + + private fun handleSharedAccountsStateUpdated( + action: SettingsAction.Internal.SharedAccountsStateUpdated, + ) { + mutableStateFlow.update { + it.copy( + showDefaultSaveOptionRow = action.state.isSyncWithBitwardenEnabled, + ) + } + } + + private fun handleSecurityClick(action: SettingsAction.SecurityClick) { + when (action) { + is SettingsAction.SecurityClick.UnlockWithBiometricToggle -> { + handleBiometricsSetupClick(action) + } + } + } + + private fun handleBiometricsSetupClick( + action: SettingsAction.SecurityClick.UnlockWithBiometricToggle, + ) { + if (action.enabled) { + mutableStateFlow.update { + it.copy( + dialog = SettingsState.Dialog.Loading(R.string.saving.asText()), + isUnlockWithBiometricsEnabled = true, + ) + } + viewModelScope.launch { + val result = settingsRepository.setupBiometricsKey() + sendAction(SettingsAction.Internal.BiometricsKeyResultReceive(result)) + } + } else { + settingsRepository.clearBiometricsKey() + mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = false) } + } + } + + private fun handleBiometricsKeyResultReceive( + action: SettingsAction.Internal.BiometricsKeyResultReceive, + ) { + when (action.result) { + BiometricsKeyResult.Error -> { + mutableStateFlow.update { + it.copy( + dialog = null, + isUnlockWithBiometricsEnabled = false, + ) + } + } + + BiometricsKeyResult.Success -> { + mutableStateFlow.update { + it.copy( + dialog = null, + isUnlockWithBiometricsEnabled = true, + ) + } + } + } + } + + private fun handleVaultClick(action: SettingsAction.DataClick) { + when (action) { + SettingsAction.DataClick.ExportClick -> handleExportClick() + SettingsAction.DataClick.ImportClick -> handleImportClick() + SettingsAction.DataClick.BackupClick -> handleBackupClick() + SettingsAction.DataClick.SyncWithBitwardenClick -> handleSyncWithBitwardenClick() + is SettingsAction.DataClick.DefaultSaveOptionUpdated -> + handleDefaultSaveOptionChosen(action) + } + } + + private fun handleDefaultSaveOptionChosen( + action: SettingsAction.DataClick.DefaultSaveOptionUpdated, + ) { + settingsRepository.defaultSaveOption = action.option + } + + private fun handleDefaultSaveOptionUpdated( + action: SettingsAction.Internal.DefaultSaveOptionUpdated, + ) { + mutableStateFlow.update { + it.copy( + defaultSaveOption = action.option, + ) + } + } + + private fun handleSyncWithBitwardenClick() { + when (authenticatorBridgeManager.accountSyncStateFlow.value) { + AccountSyncState.AppNotInstalled -> { + sendEvent(SettingsEvent.NavigateToBitwardenPlayStoreListing) + } + + else -> sendEvent(SettingsEvent.NavigateToBitwardenApp) + } + } + + private fun handleExportClick() { + sendEvent(SettingsEvent.NavigateToExport) + } + + private fun handleImportClick() { + sendEvent(SettingsEvent.NavigateToImport) + } + + private fun handleBackupClick() { + sendEvent(SettingsEvent.NavigateToBackup) + } + + private fun handleAppearanceChange(action: SettingsAction.AppearanceChange) { + when (action) { + is SettingsAction.AppearanceChange.LanguageChange -> { + handleLanguageChange(action.language) + } + + is SettingsAction.AppearanceChange.ThemeChange -> { + handleThemeChange(action.appTheme) + } + } + } + + private fun handleLanguageChange(language: AppLanguage) { + mutableStateFlow.update { + it.copy( + appearance = it.appearance.copy(language = language), + ) + } + settingsRepository.appLanguage = language + val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags( + language.localeName, + ) + AppCompatDelegate.setApplicationLocales(appLocale) + } + + private fun handleThemeChange(theme: AppTheme) { + mutableStateFlow.update { + it.copy( + appearance = it.appearance.copy(theme = theme), + ) + } + settingsRepository.appTheme = theme + } + + private fun handleHelpClick(action: SettingsAction.HelpClick) { + when (action) { + SettingsAction.HelpClick.ShowTutorialClick -> handleShowTutorialCLick() + SettingsAction.HelpClick.HelpCenterClick -> handleHelpCenterClick() + } + } + + private fun handleShowTutorialCLick() { + sendEvent(SettingsEvent.NavigateToTutorial) + } + + private fun handleHelpCenterClick() { + sendEvent(SettingsEvent.NavigateToHelpCenter) + } + + private fun handleAboutClick(action: SettingsAction.AboutClick) { + when (action) { + SettingsAction.AboutClick.PrivacyPolicyClick -> { + handlePrivacyPolicyClick() + } + + SettingsAction.AboutClick.VersionClick -> { + handleVersionClick() + } + + is SettingsAction.AboutClick.SubmitCrashLogsClick -> { + handleSubmitCrashLogsClick(action.enabled) + } + } + } + + private fun handleSubmitCrashLogsClick(enabled: Boolean) { + mutableStateFlow.update { it.copy(isSubmitCrashLogsEnabled = enabled) } + settingsRepository.isCrashLoggingEnabled = enabled + } + + private fun handlePrivacyPolicyClick() { + sendEvent(SettingsEvent.NavigateToPrivacyPolicy) + } + + private fun handleVersionClick() { + clipboardManager.setText( + text = state.copyrightInfo.concat("\n\n".asText()).concat(state.version), + ) + } + + @Suppress("UndocumentedPublicClass") + companion object { + @Suppress("LongParameterList") + private fun createInitialState( + clock: Clock, + appLanguage: AppLanguage, + appTheme: AppTheme, + defaultSaveOption: DefaultSaveOption, + unlockWithBiometricsEnabled: Boolean, + isSubmitCrashLogsEnabled: Boolean, + accountSyncState: AccountSyncState, + isSyncWithBitwardenFeatureEnabled: Boolean, + sharedAccountsState: SharedVerificationCodesState, + ): SettingsState { + val currentYear = Year.now(clock) + val copyrightInfo = "© Bitwarden Inc. 2015-$currentYear".asText() + // Show sync with Bitwarden row if feature is enabled and the OS is supported: + val shouldShowSyncWithBitwarden = isSyncWithBitwardenFeatureEnabled && + accountSyncState != AccountSyncState.OsVersionNotSupported + // Show default save options only if the user had enabled sync with Bitwarden: + // (They can enable it via the "Sync with Bitwarden" row. + val shouldShowDefaultSaveOption = sharedAccountsState.isSyncWithBitwardenEnabled + return SettingsState( + appearance = SettingsState.Appearance( + language = appLanguage, + theme = appTheme, + ), + isUnlockWithBiometricsEnabled = unlockWithBiometricsEnabled, + isSubmitCrashLogsEnabled = isSubmitCrashLogsEnabled, + dialog = null, + version = R.string.version + .asText() + .concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()), + copyrightInfo = copyrightInfo, + defaultSaveOption = defaultSaveOption, + showSyncWithBitwarden = shouldShowSyncWithBitwarden, + showDefaultSaveOptionRow = shouldShowDefaultSaveOption, + ) + } + } +} + +/** + * Models state of the Settings screen. + */ +@Parcelize +data class SettingsState( + val appearance: Appearance, + val defaultSaveOption: DefaultSaveOption, + val isUnlockWithBiometricsEnabled: Boolean, + val isSubmitCrashLogsEnabled: Boolean, + val showSyncWithBitwarden: Boolean, + val showDefaultSaveOptionRow: Boolean, + val dialog: Dialog?, + val version: Text, + val copyrightInfo: Text, +) : Parcelable { + + /** + * Models the dialog state for [SettingsViewModel]. + */ + @Parcelize + sealed class Dialog : Parcelable { + + /** + * Displays a loading dialog with a [message]. + */ + data class Loading( + val message: Text, + ) : Dialog() + } + + /** + * Models state of the Appearance settings. + */ + @Parcelize + data class Appearance( + val language: AppLanguage, + val theme: AppTheme, + ) : Parcelable +} + +/** + * Models events for the settings screen. + */ +sealed class SettingsEvent { + + /** + * Navigate to the Tutorial screen. + */ + data object NavigateToTutorial : SettingsEvent() + + /** + * Navigate to the Export screen. + */ + data object NavigateToExport : SettingsEvent() + + /** + * Navigate to the Import screen. + */ + data object NavigateToImport : SettingsEvent() + + /** + * Navigate to the Backup web page. + */ + data object NavigateToBackup : SettingsEvent() + + /** + * Navigate to the Help Center web page. + */ + data object NavigateToHelpCenter : SettingsEvent() + + /** + * Navigate to the privacy policy web page. + */ + data object NavigateToPrivacyPolicy : SettingsEvent() + + /** + * Navigate to the Bitwarden account settings. + */ + data object NavigateToBitwardenApp : SettingsEvent() + + /** + * Navigate to the Bitwarden Play Store listing. + */ + data object NavigateToBitwardenPlayStoreListing : SettingsEvent() +} + +/** + * Models actions for the settings screen. + */ +sealed class SettingsAction( + val dialog: Dialog? = null, +) { + + /** + * Represents dialogs that may be displayed by the Settings screen. + */ + sealed class Dialog { + + /** + * Display the loading screen with a [message]. + */ + data class Loading( + val message: Text, + ) : Dialog() + } + + /** + * Indicates the user clicked the Unlock with biometrics button. + */ + sealed class SecurityClick : SettingsAction() { + /** + * Indicates the user clicked unlock with biometrics toggle. + */ + data class UnlockWithBiometricToggle(val enabled: Boolean) : SecurityClick() + } + + /** + * Models actions for the Vault section of settings. + */ + sealed class DataClick : SettingsAction() { + + /** + * Indicates the user clicked export. + */ + data object ExportClick : DataClick() + + /** + * Indicates the user clicked import. + */ + data object ImportClick : DataClick() + + /** + * Indicates the user click backup. + */ + data object BackupClick : DataClick() + + /** + * Indicates the user clicked sync with Bitwarden. + */ + data object SyncWithBitwardenClick : DataClick() + + /** + * User confirmed a new [DeafultSaveOption]. + */ + data class DefaultSaveOptionUpdated(val option: DefaultSaveOption) : DataClick() + } + + /** + * Models actions for the Help section of settings. + */ + sealed class HelpClick : SettingsAction() { + + /** + * Indicates the user clicked launch tutorial. + */ + data object ShowTutorialClick : HelpClick() + + /** + * Indicates teh user clicked About. + */ + data object HelpCenterClick : HelpClick() + } + + /** + * Models actions for the Appearance section of settings. + */ + sealed class AppearanceChange : SettingsAction() { + /** + * Indicates the user changed the language. + */ + data class LanguageChange( + val language: AppLanguage, + ) : AppearanceChange() + + /** + * Indicates the user selected a new theme. + */ + data class ThemeChange( + val appTheme: AppTheme, + ) : AppearanceChange() + } + + /** + * Models actions for the About section of settings. + */ + sealed class AboutClick : SettingsAction() { + + /** + * Indicates the user clicked privacy policy. + */ + data object PrivacyPolicyClick : AboutClick() + + /** + * Indicates the user clicked version. + */ + data object VersionClick : AboutClick() + + /** + * Indicates the user clicked submit crash logs toggle. + */ + data class SubmitCrashLogsClick(val enabled: Boolean) : AboutClick() + } + + /** + * Models actions that the Settings screen itself may send. + */ + sealed class Internal { + + /** + * Indicates the biometrics key validation results has been received. + */ + data class BiometricsKeyResultReceive(val result: BiometricsKeyResult) : SettingsAction() + + /** + * Indicates that shared account state was updated. + */ + data class SharedAccountsStateUpdated( + val state: SharedVerificationCodesState, + ) : SettingsAction() + + /** + * Indicates that the default save option on disk was updated. + */ + data class DefaultSaveOptionUpdated( + val option: DefaultSaveOption, + ) : SettingsAction() + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/appearance/model/AppLanguage.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/appearance/model/AppLanguage.kt new file mode 100644 index 0000000000..025e8c1814 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/appearance/model/AppLanguage.kt @@ -0,0 +1,178 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model + +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText + +/** + * Represents the languages supported by the app. + */ +enum class AppLanguage( + val localeName: String?, + val text: Text, +) { + DEFAULT( + localeName = null, + text = R.string.default_system.asText(), + ), + AFRIKAANS( + localeName = "af", + text = "Afrikaans".asText(), + ), + BELARUSIAN( + localeName = "be", + text = "Беларуская".asText(), + ), + BULGARIAN( + localeName = "bg", + text = "български".asText(), + ), + CATALAN( + localeName = "ca", + text = "català".asText(), + ), + CZECH( + localeName = "cs", + text = "čeština".asText(), + ), + DANISH( + localeName = "da", + text = "Dansk".asText(), + ), + GERMAN( + localeName = "de", + text = "Deutsch".asText(), + ), + GREEK( + localeName = "el", + text = "Ελληνικά".asText(), + ), + ENGLISH( + localeName = "en", + text = "English".asText(), + ), + ENGLISH_BRITISH( + localeName = "en-GB", + text = "English (British)".asText(), + ), + SPANISH( + localeName = "es", + text = "Español".asText(), + ), + ESTONIAN( + localeName = "et", + text = "eesti".asText(), + ), + PERSIAN( + localeName = "fa", + text = "فارسی".asText(), + ), + FINNISH( + localeName = "fi", + text = "suomi".asText(), + ), + FRENCH( + localeName = "fr", + text = "Français".asText(), + ), + HINDI( + localeName = "hi", + text = "हिन्दी".asText(), + ), + CROATIAN( + localeName = "hr", + text = "hrvatski".asText(), + ), + HUNGARIAN( + localeName = "hu", + text = "magyar".asText(), + ), + INDONESIAN( + localeName = "in", + text = "Bahasa Indonesia".asText(), + ), + ITALIAN( + localeName = "it", + text = "Italiano".asText(), + ), + HEBREW( + localeName = "iw", + text = "עברית".asText(), + ), + JAPANESE( + localeName = "ja", + text = "日本語".asText(), + ), + KOREAN( + localeName = "ko", + text = "한국어".asText(), + ), + LATVIAN( + localeName = "lv", + text = "Latvietis".asText(), + ), + MALAYALAM( + localeName = "ml", + text = "മലയാളം".asText(), + ), + NORWEGIAN( + localeName = "nb", + text = "norsk (bokmål)".asText(), + ), + DUTCH( + localeName = "nl", + text = "Nederlands".asText(), + ), + POLISH( + localeName = "pl", + text = "Polski".asText(), + ), + PORTUGUESE_BRAZILIAN( + localeName = "pt-BR", + text = "Português do Brasil".asText(), + ), + PORTUGUESE( + localeName = "pt-PT", + text = "Português".asText(), + ), + ROMANIAN( + localeName = "ro", + text = "română".asText(), + ), + RUSSIAN( + localeName = "ru", + text = "русский".asText(), + ), + SLOVAK( + localeName = "sk", + text = "slovenčina".asText(), + ), + SWEDISH( + localeName = "sv", + text = "svenska".asText(), + ), + THAI( + localeName = "th", + text = "ไทย".asText(), + ), + TURKISH( + localeName = "tr", + text = "Türkçe".asText(), + ), + UKRAINIAN( + localeName = "uk", + text = "українська".asText(), + ), + VIETNAMESE( + localeName = "vi", + text = "Tiếng Việt".asText(), + ), + CHINESE_SIMPLIFIED( + localeName = "zh-CN", + text = "中文(中国大陆)".asText(), + ), + CHINESE_TRADITIONAL( + localeName = "zh-TW", + text = "中文(台灣)".asText(), + ), +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/appearance/model/AppTheme.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/appearance/model/AppTheme.kt new file mode 100644 index 0000000000..0d7c57deea --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/appearance/model/AppTheme.kt @@ -0,0 +1,12 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model + +/** + * Represents the theme options the user can set. + * + * The [value] is used for consistent storage purposes. + */ +enum class AppTheme(val value: String?) { + DEFAULT(value = null), + DARK(value = "dark"), + LIGHT(value = "light"), +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/data/model/DefaultSaveOption.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/data/model/DefaultSaveOption.kt new file mode 100644 index 0000000000..fe0491c979 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/data/model/DefaultSaveOption.kt @@ -0,0 +1,12 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings.data.model + +/** + * Represents the default save location the user has set. + * + * The [value] is used for consistent storage purposes. + */ +enum class DefaultSaveOption(val value: String?) { + BITWARDEN_APP(value = "bitwarden"), + LOCAL(value = "local"), + NONE(value = null), +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportNavigation.kt new file mode 100644 index 0000000000..cd8c18adba --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportNavigation.kt @@ -0,0 +1,31 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings.export + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.bitwarden.authenticator.ui.platform.base.util.composableWithSlideTransitions + +/** + * Route for the export data screen. + */ +const val EXPORT_ROUTE = "export" + +/** + * Add the export data destination to the nav graph. + */ +fun NavGraphBuilder.exportDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions(EXPORT_ROUTE) { + ExportScreen( + onNavigateBack = onNavigateBack, + ) + } +} + +/** + * Navigate to the export data screen. + */ +fun NavController.navigateToExport(navOptions: NavOptions? = null) { + navigate(EXPORT_ROUTE, navOptions) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportScreen.kt new file mode 100644 index 0000000000..72744aed6a --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportScreen.kt @@ -0,0 +1,213 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings.export + +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton +import com.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenLoadingDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenTwoButtonDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.LoadingDialogState +import com.bitwarden.authenticator.ui.platform.components.dropdown.BitwardenMultiSelectButton +import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.feature.settings.export.model.ExportVaultFormat +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import com.bitwarden.authenticator.ui.platform.theme.LocalIntentManager +import com.bitwarden.authenticator.ui.platform.util.displayLabel +import kotlinx.collections.immutable.toImmutableList + +/** + * Top level composable for the export data screen. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExportScreen( + viewModel: ExportViewModel = hiltViewModel(), + intentManager: IntentManager = LocalIntentManager.current, + onNavigateBack: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + val exportLocationReceive: (Uri) -> Unit = remember { + { + viewModel.trySendAction(ExportAction.ExportLocationReceive(it)) + } + } + val fileSaveLauncher = intentManager.getActivityResultLauncher { activityResult -> + intentManager.getFileDataFromActivityResult(activityResult)?.let { + exportLocationReceive.invoke(it.uri) + } + } + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + ExportEvent.NavigateBack -> onNavigateBack() + is ExportEvent.ShowToast -> { + Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show() + } + + is ExportEvent.NavigateToSelectExportDestination -> { + fileSaveLauncher.launch( + intentManager.createDocumentIntent( + fileName = event.fileName, + ), + ) + } + } + } + + var shouldShowConfirmationPrompt by remember { mutableStateOf(false) } + val confirmExportClick = remember(viewModel) { + { + viewModel.trySendAction(ExportAction.ConfirmExportClick) + } + } + if (shouldShowConfirmationPrompt) { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.export_confirmation_title), + message = stringResource( + id = R.string.export_vault_warning, + ), + confirmButtonText = stringResource(id = R.string.export), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { + shouldShowConfirmationPrompt = false + confirmExportClick() + }, + onDismissClick = { shouldShowConfirmationPrompt = false }, + onDismissRequest = { shouldShowConfirmationPrompt = false }, + ) + } + + when (val dialog = state.dialogState) { + is ExportState.DialogState.Error -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialog.title, + message = dialog.message, + ), + onDismissRequest = remember(viewModel) { + { + viewModel.trySendAction(ExportAction.DialogDismiss) + } + }, + ) + } + + is ExportState.DialogState.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown( + text = dialog.message, + ), + ) + } + + null -> Unit + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.export), + scrollBehavior = scrollBehavior, + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { + viewModel.trySendAction(ExportAction.CloseButtonClick) + } + }, + ) + }, + ) { paddingValues -> + ExportScreenContent( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + state = state, + onExportFormatOptionSelected = remember(viewModel) { + { + viewModel.trySendAction(ExportAction.ExportFormatOptionSelect(it)) + } + }, + onExportClick = { shouldShowConfirmationPrompt = true }, + ) + } +} + +@Composable +private fun ExportScreenContent( + modifier: Modifier = Modifier, + state: ExportState, + onExportFormatOptionSelected: (ExportVaultFormat) -> Unit, + onExportClick: () -> Unit, +) { + Column( + modifier = modifier + .imePadding() + .verticalScroll(rememberScrollState()), + ) { + val resources = LocalContext.current.resources + BitwardenMultiSelectButton( + label = stringResource(id = R.string.file_format), + options = ExportVaultFormat.entries.map { it.displayLabel() }.toImmutableList(), + selectedOption = state.exportVaultFormat.displayLabel(), + onOptionSelected = { selectedOptionLabel -> + val selectedOption = ExportVaultFormat + .entries + .first { it.displayLabel(resources) == selectedOptionLabel } + onExportFormatOptionSelected(selectedOption) + }, + modifier = Modifier + .semantics { testTag = "FileFormatPicker" } + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + BitwardenFilledTonalButton( + label = stringResource(id = R.string.export), + onClick = onExportClick, + modifier = Modifier + .semantics { testTag = "ExportVaultButton" } + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportViewModel.kt new file mode 100644 index 0000000000..ba444fc25e --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportViewModel.kt @@ -0,0 +1,231 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings.export + +import android.net.Uri +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.ExportDataResult +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.feature.settings.export.model.ExportVaultFormat +import com.bitwarden.authenticator.ui.platform.util.fileExtension +import com.bitwarden.authenticator.ui.platform.util.toFormattedPattern +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.IgnoredOnParcel +import java.time.Clock +import javax.inject.Inject + +/** + * Manages state for the [ExportScreen]. + */ +@HiltViewModel +class ExportViewModel @Inject constructor( + private val authenticatorRepository: AuthenticatorRepository, + private val clock: Clock, +) : + BaseViewModel( + initialState = ExportState(dialogState = null, exportVaultFormat = ExportVaultFormat.JSON), + ) { + + override fun handleAction(action: ExportAction) { + when (action) { + is ExportAction.CloseButtonClick -> { + handleCloseButtonClick() + } + + is ExportAction.ExportFormatOptionSelect -> { + handleExportFormatOptionSelect(action) + } + + is ExportAction.ConfirmExportClick -> { + handleConfirmExportClick() + } + + is ExportAction.DialogDismiss -> { + handleDialogDismiss() + } + + is ExportAction.ExportLocationReceive -> { + handleExportLocationReceive(action) + } + + is ExportAction.Internal -> { + handleInternalAction(action) + } + } + } + + private fun handleCloseButtonClick() { + sendEvent(ExportEvent.NavigateBack) + } + + private fun handleExportFormatOptionSelect(action: ExportAction.ExportFormatOptionSelect) { + mutableStateFlow.update { + it.copy(exportVaultFormat = action.option) + } + } + + private fun handleConfirmExportClick() { + + val date = clock.instant().toFormattedPattern( + pattern = "yyyyMMddHHmmss", + clock = clock, + ) + val extension = state.exportVaultFormat.fileExtension + val fileName = "authenticator_export_$date.$extension" + + sendEvent( + ExportEvent.NavigateToSelectExportDestination(fileName), + ) + } + + private fun handleDialogDismiss() { + mutableStateFlow.update { + it.copy(dialogState = null) + } + } + + private fun handleExportLocationReceive(action: ExportAction.ExportLocationReceive) { + mutableStateFlow.update { + it.copy(dialogState = ExportState.DialogState.Loading()) + } + + viewModelScope.launch { + val result = authenticatorRepository.exportVaultData( + format = state.exportVaultFormat, + fileUri = action.fileUri, + ) + + sendAction( + ExportAction.Internal.SaveExportDataToUriResultReceive( + result = result, + ), + ) + } + } + + private fun handleInternalAction(action: ExportAction.Internal) { + when (action) { + is ExportAction.Internal.SaveExportDataToUriResultReceive -> { + handleExportDataToUriResult(action.result) + } + } + } + + private fun handleExportDataToUriResult(result: ExportDataResult) { + when (result) { + ExportDataResult.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = ExportState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.export_vault_failure.asText(), + ), + ) + } + } + + is ExportDataResult.Success -> { + mutableStateFlow.update { it.copy(dialogState = null) } + sendEvent(ExportEvent.ShowToast(R.string.export_success.asText())) + } + } + } +} + +/** + * Represents the state of the [ExportViewModel]. + */ +data class ExportState( + @IgnoredOnParcel + val exportData: String? = null, + val dialogState: DialogState? = null, + val exportVaultFormat: ExportVaultFormat, +) { + /** + * Represents state of dialogs for the [ExportViewModel]. + */ + sealed class DialogState { + /** + * Displays a loading dialog with an optional [message]. + */ + data class Loading( + val message: Text = R.string.loading.asText(), + ) : DialogState() + + /** + * Displays an error dialog with an optional [title], and a [message]. + */ + data class Error( + val title: Text? = null, + val message: Text, + ) : DialogState() + } +} + +/** + * Represents events for the [ExportViewModel]. + */ +sealed class ExportEvent { + /** + * Navigate back. + */ + data object NavigateBack : ExportEvent() + + /** + * Display a toast with the provided [message]. + */ + data class ShowToast(val message: Text) : ExportEvent() + + /** + * Navigate to the select export destination screen. + */ + data class NavigateToSelectExportDestination(val fileName: String) : ExportEvent() +} + +/** + * Represents actions for the [ExportViewModel]. + */ +sealed class ExportAction { + + /** + * Indicates the user has clicked the close button. + */ + data object CloseButtonClick : ExportAction() + + /** + * Indicates the user has clicked the export confirmation button. + */ + data object ConfirmExportClick : ExportAction() + + /** + * Indicates the user has dismissed the dialog. + */ + data object DialogDismiss : ExportAction() + + /** + * Indicates the user has selected an export format. + */ + data class ExportFormatOptionSelect(val option: ExportVaultFormat) : ExportAction() + + /** + * Indicates the user has selected a location for the exported data. + */ + data class ExportLocationReceive(val fileUri: Uri) : ExportAction() + + /** + * Represents actions the [ExportViewModel] itself may trigger. + */ + sealed class Internal : ExportAction() { + + /** + * Indicates the result for saving exported data to a URI has been received. + */ + data class SaveExportDataToUriResultReceive( + val result: ExportDataResult, + ) : Internal() + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/model/ExportVaultFormat.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/model/ExportVaultFormat.kt new file mode 100644 index 0000000000..f88128aea1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/model/ExportVaultFormat.kt @@ -0,0 +1,9 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings.export.model + +/** + * Represents the file formats a user can select to export the vault. + */ +enum class ExportVaultFormat { + JSON, + CSV, +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/importing/ImportingNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/importing/ImportingNavigation.kt new file mode 100644 index 0000000000..3f8e109a21 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/importing/ImportingNavigation.kt @@ -0,0 +1,28 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings.importing + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.bitwarden.authenticator.ui.platform.base.util.composableWithSlideTransitions + +const val IMPORT_ROUTE = "importing" + +/** + * Add the import screen to the nav graph. + */ +fun NavGraphBuilder.importingDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions(IMPORT_ROUTE) { + ImportingScreen( + onNavigateBack = onNavigateBack, + ) + } +} + +/** + * Navigate to the Import destination. + */ +fun NavController.navigateToImporting(navOptions: NavOptions? = null) { + navigate(IMPORT_ROUTE, navOptions) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/importing/ImportingScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/importing/ImportingScreen.kt new file mode 100644 index 0000000000..a1124569ac --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/importing/ImportingScreen.kt @@ -0,0 +1,194 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings.importing + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat +import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenLoadingDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenTwoButtonDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.LoadingDialogState +import com.bitwarden.authenticator.ui.platform.components.dropdown.BitwardenMultiSelectButton +import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import com.bitwarden.authenticator.ui.platform.theme.LocalIntentManager +import com.bitwarden.authenticator.ui.platform.util.displayLabel +import kotlinx.collections.immutable.toImmutableList + +/** + * Top level composable for the importing screen. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImportingScreen( + viewModel: ImportingViewModel = hiltViewModel(), + intentManager: IntentManager = LocalIntentManager.current, + onNavigateBack: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val importLocationReceive: (IntentManager.FileData) -> Unit = remember { + { + viewModel.trySendAction(ImportAction.ImportLocationReceive(it)) + } + } + val launcher = intentManager.getActivityResultLauncher { activityResult -> + intentManager.getFileDataFromActivityResult(activityResult)?.let { + importLocationReceive(it) + } + } + + val context = LocalContext.current + EventsEffect(viewModel = viewModel) { event -> + when (event) { + ImportEvent.NavigateBack -> onNavigateBack() + is ImportEvent.NavigateToSelectImportFile -> { + launcher.launch( + intentManager.createFileChooserIntent(event.importFileFormat.mimeType), + ) + } + + is ImportEvent.ShowToast -> { + Toast + .makeText(context, event.message(context.resources), Toast.LENGTH_SHORT) + .show() + } + } + } + + when (val dialog = state.dialogState) { + is ImportState.DialogState.Error -> { + BitwardenTwoButtonDialog( + title = dialog.title?.invoke(), + message = dialog.message.invoke(), + confirmButtonText = stringResource(id = R.string.get_help), + onConfirmClick = { + intentManager.launchUri("https://bitwarden.com/help".toUri()) + }, + dismissButtonText = stringResource(id = R.string.cancel), + onDismissClick = { + viewModel.trySendAction(ImportAction.DialogDismiss) + }, + onDismissRequest = { + viewModel.trySendAction(ImportAction.DialogDismiss) + }, + ) + } + + is ImportState.DialogState.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown( + text = dialog.message, + ), + ) + } + + null -> Unit + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.import_vault), + scrollBehavior = scrollBehavior, + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { + viewModel.trySendAction(ImportAction.CloseButtonClick) + } + }, + ) + }, + ) { paddingValues -> + ImportScreenContent( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + state = state, + onImportFormatOptionSelected = remember(viewModel) { + { + viewModel.trySendAction(ImportAction.ImportFormatOptionSelect(it)) + } + }, + onImportClick = remember(viewModel) { + { + viewModel.trySendAction(ImportAction.ImportClick) + } + }, + ) + } +} + +@Composable +private fun ImportScreenContent( + modifier: Modifier = Modifier, + state: ImportState, + onImportFormatOptionSelected: (ImportFileFormat) -> Unit, + onImportClick: () -> Unit, +) { + Column( + modifier = modifier + .imePadding() + .verticalScroll(rememberScrollState()), + ) { + val resources = LocalContext.current.resources + BitwardenMultiSelectButton( + label = stringResource(id = R.string.file_format), + options = ImportFileFormat.entries.map { it.displayLabel() }.toImmutableList(), + selectedOption = state.importFileFormat.displayLabel(), + onOptionSelected = { selectedOptionLabel -> + val selectedOption = ImportFileFormat + .entries + .first { it.displayLabel(resources) == selectedOptionLabel } + onImportFormatOptionSelected(selectedOption) + }, + modifier = Modifier + .semantics { testTag = "FileFormatPicker" } + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + BitwardenFilledTonalButton( + label = stringResource(id = R.string.import_vault), + onClick = onImportClick, + modifier = Modifier + .semantics { testTag = "ImportVaultButton" } + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/importing/ImportingViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/importing/ImportingViewModel.kt new file mode 100644 index 0000000000..c4f97c9dbe --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/importing/ImportingViewModel.kt @@ -0,0 +1,218 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings.importing + +import android.net.Uri +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportDataResult +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.IgnoredOnParcel +import javax.inject.Inject + +/** + * View model for the Importing screen. + */ +@HiltViewModel +class ImportingViewModel @Inject constructor( + private val authenticatorRepository: AuthenticatorRepository, +) : + BaseViewModel( + initialState = ImportState(importFileFormat = ImportFileFormat.BITWARDEN_JSON), + ) { + + override fun handleAction(action: ImportAction) { + when (action) { + ImportAction.CloseButtonClick -> { + handleCloseButtonClick() + } + + ImportAction.ImportClick -> { + handleConfirmImportClick() + } + + ImportAction.DialogDismiss -> { + handleDialogDismiss() + } + + is ImportAction.ImportFormatOptionSelect -> { + handleImportFormatOptionSelect(action) + } + + is ImportAction.ImportLocationReceive -> { + handleImportLocationReceive(action) + } + + is ImportAction.Internal -> { + handleInternalAction(action) + } + } + } + + private fun handleCloseButtonClick() { + sendEvent(ImportEvent.NavigateBack) + } + + private fun handleConfirmImportClick() { + sendEvent(ImportEvent.NavigateToSelectImportFile(state.importFileFormat)) + } + + private fun handleDialogDismiss() { + mutableStateFlow.update { it.copy(dialogState = null) } + } + + private fun handleImportFormatOptionSelect(action: ImportAction.ImportFormatOptionSelect) { + mutableStateFlow.update { it.copy(importFileFormat = action.option) } + } + + private fun handleImportLocationReceive(action: ImportAction.ImportLocationReceive) { + mutableStateFlow.update { it.copy(dialogState = ImportState.DialogState.Loading()) } + + viewModelScope.launch { + val result = authenticatorRepository.importVaultData( + format = state.importFileFormat, + fileData = action.fileUri, + ) + + sendAction( + ImportAction.Internal.SaveImportDataToUriResultReceive(result), + ) + } + } + + private fun handleInternalAction(action: ImportAction.Internal) { + when (action) { + is ImportAction.Internal.SaveImportDataToUriResultReceive -> { + handleSaveImportDataToUriResultReceive(action.result) + } + } + } + + private fun handleSaveImportDataToUriResultReceive(result: ImportDataResult) { + when (result) { + is ImportDataResult.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = ImportState.DialogState.Error( + title = result.title ?: R.string.an_error_has_occurred.asText(), + message = result.message ?: R.string.import_vault_failure.asText(), + ), + ) + } + } + + ImportDataResult.Success -> { + mutableStateFlow.update { it.copy(dialogState = null) } + sendEvent( + ImportEvent.ShowToast( + message = R.string.import_success.asText(), + ), + ) + sendEvent(ImportEvent.NavigateBack) + } + } + } +} + +/** + * Represents state for the [ImportingScreen]. + */ +data class ImportState( + @IgnoredOnParcel + val fileUri: Uri? = null, + val dialogState: DialogState? = null, + val importFileFormat: ImportFileFormat, +) { + + /** + * Represents the current state of any dialogs on the screen. + */ + sealed class DialogState { + + /** + * Represents a loading dialog with the given [message]. + */ + data class Loading( + val message: Text = R.string.loading.asText(), + ) : DialogState() + + /** + * Represents a dismissible dialog with the given error [title] and [message]. + */ + data class Error( + val title: Text? = null, + val message: Text, + ) : DialogState() + } +} + +/** + * Models events for the [ImportingScreen]. + */ +sealed class ImportEvent { + + /** + * Navigate back to the previous screen. + */ + data object NavigateBack : ImportEvent() + + /** + * Show a Toast with the given [message]. + */ + data class ShowToast(val message: Text) : ImportEvent() + + /** + * Navigate to the select import file screen. + */ + data class NavigateToSelectImportFile(val importFileFormat: ImportFileFormat) : ImportEvent() +} + +/** + * Models actions for the [ImportingScreen]. + */ +sealed class ImportAction { + + /** + * Indicates the user clicked close. + */ + data object CloseButtonClick : ImportAction() + + /** + * Indicates the user clicked import. + */ + data object ImportClick : ImportAction() + + /** + * Indicates the user dismissed the dialog. + */ + data object DialogDismiss : ImportAction() + + /** + * Indicates the user selected and import file format. + */ + data class ImportFormatOptionSelect(val option: ImportFileFormat) : ImportAction() + + /** + * Indicates the user selected a file to import. + */ + data class ImportLocationReceive(val fileUri: IntentManager.FileData) : ImportAction() + + /** + * Models actions the [ImportingScreen] itself may send. + */ + sealed class Internal : ImportAction() { + + /** + * Indicates the save data result has been received. + */ + data class SaveImportDataToUriResultReceive( + val result: ImportDataResult, + ) : Internal() + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashNavigation.kt new file mode 100644 index 0000000000..bbca8c418d --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashNavigation.kt @@ -0,0 +1,24 @@ +package com.bitwarden.authenticator.ui.platform.feature.splash + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable + +const val SPLASH_ROUTE: String = "splash" + +/** + * Add splash destinations to the nav graph. + */ +fun NavGraphBuilder.splashDestination() { + composable(SPLASH_ROUTE) { SplashScreen() } +} + +/** + * Navigate to the splash screen. + */ +fun NavController.navigateToSplash( + navOptions: NavOptions? = null, +) { + navigate(SPLASH_ROUTE, navOptions) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashScreen.kt new file mode 100644 index 0000000000..ee35505424 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashScreen.kt @@ -0,0 +1,14 @@ +package com.bitwarden.authenticator.ui.platform.feature.splash + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Splash screen with empty composable content so that the Activity window background is shown. + */ +@Composable +fun SplashScreen() { + Box(modifier = Modifier.fillMaxSize()) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialNavigation.kt new file mode 100644 index 0000000000..60b914abcd --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialNavigation.kt @@ -0,0 +1,45 @@ +package com.bitwarden.authenticator.ui.platform.feature.tutorial + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable + +const val TUTORIAL_ROUTE = "tutorial" +const val SETTINGS_TUTORIAL_ROUTE = "settings/tutorial" + +/** + * Add the top level Tutorial screen to the nav graph. + */ +fun NavGraphBuilder.tutorialDestination(onTutorialFinished: () -> Unit) { + composable(TUTORIAL_ROUTE) { + TutorialScreen( + onTutorialFinished = onTutorialFinished, + ) + } +} + +/** + * Add the Settings Tutorial screen to the nav graph. + */ +fun NavGraphBuilder.tutorialSettingsDestination(onTutorialFinished: () -> Unit) { + composable(SETTINGS_TUTORIAL_ROUTE) { + TutorialScreen( + onTutorialFinished = onTutorialFinished, + ) + } +} + +/** + * Navigate to the top level Tutorial screen. + */ +fun NavController.navigateToTutorial(navOptions: NavOptions? = null) { + navigate(route = TUTORIAL_ROUTE, navOptions = navOptions) +} + +/** + * Navigate to the Tutorial screen within Settings. + */ +fun NavController.navigateToSettingsTutorial(navOptions: NavOptions? = null) { + navigate(route = SETTINGS_TUTORIAL_ROUTE, navOptions) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialScreen.kt new file mode 100644 index 0000000000..b859836a25 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialScreen.kt @@ -0,0 +1,293 @@ +package com.bitwarden.authenticator.ui.platform.feature.tutorial + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.base.util.standardHorizontalMargin +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenTextButton +import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.components.util.rememberVectorPainter +import com.bitwarden.authenticator.ui.platform.util.isPortrait + +/** + * The custom horizontal margin that is specific to this screen. + */ +private val LANDSCAPE_HORIZONTAL_MARGIN: Dp = 48.dp + +/** + * Top level composable for the tutorial screen. + */ +@Composable +fun TutorialScreen( + viewModel: TutorialViewModel = hiltViewModel(), + onTutorialFinished: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val pagerState = rememberPagerState(pageCount = { state.pages.size }) + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + TutorialEvent.NavigateToAuthenticator -> { + onTutorialFinished() + } + + is TutorialEvent.UpdatePager -> { + pagerState.animateScrollToPage(event.index) + } + } + } + + BitwardenScaffold( + modifier = Modifier.fillMaxSize(), + ) { + TutorialScreenContent( + state = state, + pagerState = pagerState, + onPagerSwipe = remember(viewModel) { + { viewModel.trySendAction(TutorialAction.PagerSwipe(it)) } + }, + onDotClick = remember(viewModel) { + { viewModel.trySendAction(TutorialAction.DotClick(it)) } + }, + continueClick = remember(viewModel) { + { viewModel.trySendAction(TutorialAction.ContinueClick(it)) } + }, + skipClick = remember(viewModel) { + { viewModel.trySendAction(TutorialAction.SkipClick) } + }, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Suppress("LongMethod") +@Composable +private fun TutorialScreenContent( + state: TutorialState, + pagerState: PagerState, + onPagerSwipe: (Int) -> Unit, + onDotClick: (Int) -> Unit, + continueClick: (Int) -> Unit, + skipClick: () -> Unit, + modifier: Modifier = Modifier, +) { + LaunchedEffect(pagerState.currentPage) { + onPagerSwipe(pagerState.currentPage) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.weight(1f)) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + ) { index -> + if (LocalConfiguration.current.isPortrait) { + TutorialScreenPortrait( + state = state.pages[index], + modifier = Modifier + .standardHorizontalMargin() + .statusBarsPadding(), + ) + } else { + TutorialScreenLandscape( + state = state.pages[index], + modifier = Modifier + .standardHorizontalMargin(landscape = LANDSCAPE_HORIZONTAL_MARGIN) + .statusBarsPadding(), + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + IndicatorDots( + selectedIndexProvider = { state.index }, + totalCount = state.pages.size, + onDotClick = onDotClick, + modifier = Modifier + .padding(bottom = 12.dp) + .height(44.dp), + ) + + BitwardenFilledTonalButton( + label = state.actionButtonText, + onClick = { continueClick(state.index) }, + modifier = Modifier + .standardHorizontalMargin(landscape = LANDSCAPE_HORIZONTAL_MARGIN) + .fillMaxWidth(), + ) + + BitwardenTextButton( + isEnabled = !state.isLastPage, + label = stringResource(id = R.string.skip), + onClick = skipClick, + modifier = Modifier + .standardHorizontalMargin(landscape = LANDSCAPE_HORIZONTAL_MARGIN) + .fillMaxWidth() + .alpha(if (state.isLastPage) 0f else 1f) + .padding(bottom = 12.dp), + ) + + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} + +@Composable +private fun TutorialScreenPortrait( + state: TutorialState.TutorialSlide, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + Image( + painter = rememberVectorPainter(id = state.image), + contentDescription = null, + modifier = Modifier.size(200.dp), + ) + + Text( + text = stringResource(id = state.title), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier + .padding( + top = 48.dp, + bottom = 16.dp, + ), + ) + Text( + text = stringResource(id = state.message), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +@Composable +private fun TutorialScreenLandscape( + state: TutorialState.TutorialSlide, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Image( + painter = rememberVectorPainter(id = state.image), + contentDescription = null, + modifier = Modifier.size(132.dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(start = 40.dp), + ) { + Text( + text = stringResource(id = state.title), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Text( + text = stringResource(id = state.message), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun IndicatorDots( + selectedIndexProvider: () -> Int, + totalCount: Int, + onDotClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + items(totalCount) { index -> + val color = animateColorAsState( + targetValue = MaterialTheme.colorScheme.primary.copy( + alpha = if (index == selectedIndexProvider()) 1.0f else 0.3f, + ), + label = "dotColor", + ) + + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(color.value) + .clickable { onDotClick(index) }, + ) + } + } +} + +@Preview +@Composable +private fun TutorialScreenPreview() { + Box { + TutorialScreen( + onTutorialFinished = {}, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialViewModel.kt new file mode 100644 index 0000000000..37ce7c62c5 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialViewModel.kt @@ -0,0 +1,159 @@ +package com.bitwarden.authenticator.ui.platform.feature.tutorial + +import android.os.Parcelable +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +/** + * View model for the [TutorialScreen]. + */ +@HiltViewModel +class TutorialViewModel @Inject constructor() : + BaseViewModel( + initialState = TutorialState( + index = 0, + pages = listOf( + TutorialState.TutorialSlide.IntroSlide, + TutorialState.TutorialSlide.QrScannerSlide, + TutorialState.TutorialSlide.UniqueCodesSlide, + ), + ), + ) { + override fun handleAction(action: TutorialAction) { + when (action) { + is TutorialAction.PagerSwipe -> handlePagerSwipe(action) + is TutorialAction.DotClick -> handleDotClick(action) + is TutorialAction.ContinueClick -> handleContinueClick(action) + TutorialAction.SkipClick -> handleSkipClick() + } + } + + private fun handlePagerSwipe(action: TutorialAction.PagerSwipe) { + mutableStateFlow.update { it.copy(index = action.index) } + } + + private fun handleDotClick(action: TutorialAction.DotClick) { + mutableStateFlow.update { it.copy(index = action.index) } + sendEvent(TutorialEvent.UpdatePager(index = action.index)) + } + + private fun handleContinueClick(action: TutorialAction.ContinueClick) { + if (mutableStateFlow.value.isLastPage) { + sendEvent(TutorialEvent.NavigateToAuthenticator) + } else { + mutableStateFlow.update { it.copy(index = action.index + 1) } + sendEvent(TutorialEvent.UpdatePager(index = action.index + 1)) + } + } + + private fun handleSkipClick() { + sendEvent(TutorialEvent.NavigateToAuthenticator) + } +} + +/** + * Models state for the Tutorial screen. + */ +@Parcelize +data class TutorialState( + val index: Int, + val pages: List, +) : Parcelable { + /** + * Provides the text for the action button based on the current page index. + * - Displays "Continue" if the user is not on the last page. + * - Displays "Get Started" if the user is on the last page. + */ + val actionButtonText: String + get() = if (index != pages.lastIndex) "Continue" else "Get Started" + + /** + * Indicates whether the current slide is the last in the pages array. + */ + val isLastPage: Boolean + get() = index == pages.lastIndex + + /** + * A sealed class to represent the different slides the user can view on the tutorial screen. + */ + @Suppress("MaxLineLength") + sealed class TutorialSlide : Parcelable { + abstract val image: Int + abstract val title: Int + abstract val message: Int + + /** + * Tutorial should display the introduction slide. + */ + @Parcelize + data object IntroSlide : TutorialSlide() { + override val image: Int get() = R.drawable.ic_tutorial_verification_codes + override val title: Int get() = R.string.secure_your_accounts_with_bitwarden_authenticator + override val message: Int get() = R.string.get_verification_codes_for_all_your_accounts + } + + /** + * Tutorial should display the QR code scanner description slide. + */ + @Parcelize + data object QrScannerSlide : TutorialSlide() { + override val image: Int get() = R.drawable.ic_tutorial_qr_scanner + override val title: Int get() = R.string.use_your_device_camera_to_scan_codes + override val message: Int get() = R.string.scan_the_qr_code_in_your_2_step_verification_settings_for_any_account + } + + /** + * Tutorial should display the 2FA code description slide. + */ + @Parcelize + data object UniqueCodesSlide : TutorialSlide() { + override val image: Int get() = R.drawable.ic_tutorial_2fa + override val title: Int get() = R.string.sign_in_using_unique_codes + override val message: Int get() = R.string.when_using_2_step_verification_youll_enter_your_username_and_password_and_a_code_generated_in_this_app + } + } +} + +/** + * Represents a set of events related to the tutorial screen. + */ +sealed class TutorialEvent { + /** + * Updates the current index of the pager. + */ + data class UpdatePager(val index: Int) : TutorialEvent() + + /** + * Navigate to the authenticator tutorial slide. + */ + data object NavigateToAuthenticator : TutorialEvent() +} + +/** + * Models actions that can be taken on the tutorial screen. + */ +sealed class TutorialAction { + /** + * Swipe the pager to the given [index]. + */ + data class PagerSwipe(val index: Int) : TutorialAction() + + /** + * Click one of the page indicator dots at the given [index]. + */ + data class DotClick(val index: Int) : TutorialAction() + + /** + * The user clicked the continue button at the given [index]. + */ + data class ContinueClick(val index: Int) : TutorialAction() + + /** + * The user clicked the skip button on one of the tutorial slides. + */ + data object SkipClick : TutorialAction() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManager.kt new file mode 100644 index 0000000000..616a8399c0 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManager.kt @@ -0,0 +1,24 @@ +package com.bitwarden.authenticator.ui.platform.manager.biometrics + +import androidx.compose.runtime.Immutable + +/** + * Interface to manage biometrics within the app. + */ +@Immutable +interface BiometricsManager { + /** + * Returns `true` if the device supports string biometric authentication, `false` otherwise. + */ + val isBiometricsSupported: Boolean + + /** + * Display a prompt for biometrics. + */ + fun promptBiometrics( + onSuccess: () -> Unit, + onCancel: () -> Unit, + onLockOut: () -> Unit, + onError: () -> Unit, + ) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManagerImpl.kt new file mode 100644 index 0000000000..0bee4214ba --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManagerImpl.kt @@ -0,0 +1,99 @@ +package com.bitwarden.authenticator.ui.platform.manager.biometrics + +import android.app.Activity +import android.os.Build +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import com.bitwarden.authenticator.R + +/** + * Default implementation of the [BiometricsManager] to manage biometrics within the app. + */ +class BiometricsManagerImpl( + private val activity: Activity, +) : BiometricsManager { + private val biometricManager: BiometricManager = BiometricManager.from(activity) + + private val fragmentActivity: FragmentActivity get() = activity as FragmentActivity + + private val allowedAuthenticators: Int + get() { + // [Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_STRONG] is not a valid + // combination prior to SDK version 30 so we use + // [Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK] instead. + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK + } else { + Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_STRONG + } + } + + override val isBiometricsSupported: Boolean + get() = when (biometricManager.canAuthenticate(allowedAuthenticators)) { + BiometricManager.BIOMETRIC_SUCCESS -> true + BiometricManager.BIOMETRIC_STATUS_UNKNOWN, + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED, + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE, + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED, + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE, + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED, + -> false + + else -> false + } + + override fun promptBiometrics( + onSuccess: () -> Unit, + onCancel: () -> Unit, + onLockOut: () -> Unit, + onError: () -> Unit, + ) { + val biometricPrompt = BiometricPrompt( + fragmentActivity, + ContextCompat.getMainExecutor(fragmentActivity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult, + ) = onSuccess() + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + when (errorCode) { + BiometricPrompt.ERROR_HW_UNAVAILABLE, + BiometricPrompt.ERROR_UNABLE_TO_PROCESS, + BiometricPrompt.ERROR_TIMEOUT, + BiometricPrompt.ERROR_NO_SPACE, + BiometricPrompt.ERROR_CANCELED, + BiometricPrompt.ERROR_VENDOR, + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NO_BIOMETRICS, + BiometricPrompt.ERROR_HW_NOT_PRESENT, + BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL, + -> onError() + + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> onCancel() + + BiometricPrompt.ERROR_LOCKOUT, + BiometricPrompt.ERROR_LOCKOUT_PERMANENT, + -> onLockOut() + } + } + + override fun onAuthenticationFailed() { + // Just keep on keepin' on, if there is a real issue it + // will come from the onAuthenticationError callback. + } + }, + ) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(R.string.bitwarden_authenticator)) + .setDescription(activity.getString(R.string.biometrics_direction)) + .setAllowedAuthenticators(allowedAuthenticators) + .build() + + biometricPrompt.authenticate(promptInfo) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/exit/ExitManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/exit/ExitManager.kt new file mode 100644 index 0000000000..5cdcf4c85a --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/exit/ExitManager.kt @@ -0,0 +1,14 @@ +package com.bitwarden.authenticator.ui.platform.manager.exit + +import androidx.compose.runtime.Immutable + +/** + * A manager class for handling the various ways to exit the app. + */ +@Immutable +interface ExitManager { + /** + * Finishes the activity. + */ + fun exitApplication() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/exit/ExitManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/exit/ExitManagerImpl.kt new file mode 100644 index 0000000000..b9147941cd --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/exit/ExitManagerImpl.kt @@ -0,0 +1,14 @@ +package com.bitwarden.authenticator.ui.platform.manager.exit + +import android.app.Activity + +/** + * The default implementation of the [ExitManager] for managing the various ways to exit the app. + */ +class ExitManagerImpl( + val activity: Activity, +) : ExitManager { + override fun exitApplication() { + activity.finish() + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/intent/IntentManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/intent/IntentManager.kt new file mode 100644 index 0000000000..ce4b54d52e --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/intent/IntentManager.kt @@ -0,0 +1,71 @@ +package com.bitwarden.authenticator.ui.platform.manager.intent + +import android.content.Intent +import android.net.Uri +import android.os.Parcelable +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.result.ActivityResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import kotlinx.parcelize.Parcelize + +/** + * A manager class for simplifying the handling of Android Intents within a given context. + */ +@Suppress("TooManyFunctions") +@Immutable +interface IntentManager { + /** + * Start an activity using the provided [Intent]. + */ + fun startActivity(intent: Intent) + + /** + * Start the main Bitwarden app with scheme that routes to the account security screen. + */ + fun startMainBitwardenAppAccountSettings() + + /** + * Starts the application's settings activity. + */ + fun startApplicationDetailsSettingsActivity() + + /** + * Start an activity to view the given [uri] in an external browser. + */ + fun launchUri(uri: Uri) + + /** + * Start an activity using the provided [Intent] and provides a callback, via [onResult], for + * retrieving the [ActivityResult]. + */ + @Composable + fun getActivityResultLauncher( + onResult: (ActivityResult) -> Unit, + ): ManagedActivityResultLauncher + + /** + * Processes the [activityResult] and attempts to get the relevant file data from it. + */ + fun getFileDataFromActivityResult(activityResult: ActivityResult): FileData? + + /** + * Creates an intent for choosing a file saved to disk. + */ + fun createFileChooserIntent(mimeType: String): Intent + + /** + * Creates an intent to use when selecting to save an item with [fileName] to disk. + */ + fun createDocumentIntent(fileName: String): Intent + + /** + * Represents file information. + */ + @Parcelize + data class FileData( + val fileName: String, + val uri: Uri, + val sizeBytes: Long, + ) : Parcelable +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/intent/IntentManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/intent/IntentManagerImpl.kt new file mode 100644 index 0000000000..74346a010b --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/intent/IntentManagerImpl.kt @@ -0,0 +1,134 @@ +package com.bitwarden.authenticator.ui.platform.manager.intent + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import android.provider.Settings +import android.webkit.MimeTypeMap +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.platform.annotation.OmitFromCoverage + +/** + * The default implementation of the [IntentManager] for simplifying the handling of Android + * Intents within a given context. + */ +@OmitFromCoverage +class IntentManagerImpl( + private val context: Context, +) : IntentManager { + override fun startActivity(intent: Intent) { + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + // no-op + } + } + + @Composable + override fun getActivityResultLauncher( + onResult: (ActivityResult) -> Unit, + ): ManagedActivityResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = onResult, + ) + + override fun getFileDataFromActivityResult( + activityResult: ActivityResult, + ): IntentManager.FileData? { + if (activityResult.resultCode != Activity.RESULT_OK) return null + val uri = activityResult.data?.data ?: return null + return getLocalFileData(uri) + } + + override fun createFileChooserIntent(mimeType: String): Intent { + val chooserIntent = Intent.createChooser( + Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(mimeType), + ContextCompat.getString(context, R.string.file_source), + ) + + return chooserIntent + } + + override fun createDocumentIntent(fileName: String): Intent = + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + // Attempt to get the MIME type from the file extension + val extension = MimeTypeMap.getFileExtensionFromUrl(fileName) + type = extension?.let { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) + } + ?: "*/*" + + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_TITLE, fileName) + } + + override fun startApplicationDetailsSettingsActivity() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = Uri.parse("package:" + context.packageName) + startActivity(intent = intent) + } + + override fun launchUri(uri: Uri) { + val newUri = if (uri.scheme == null) { + uri.buildUpon().scheme("https").build() + } else { + uri.normalizeScheme() + } + startActivity(Intent(Intent.ACTION_VIEW, newUri)) + } + + override fun startMainBitwardenAppAccountSettings() { + startActivity( + Intent( + Intent.ACTION_VIEW, + "bitwarden://settings/account_security".toUri(), + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } + + private fun getLocalFileData(uri: Uri): IntentManager.FileData? = + context + .contentResolver + .query( + uri, + arrayOf( + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.SIZE, + ), + null, + null, + null, + ) + ?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + val fileName = cursor + .getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME) + .takeIf { it >= 0 } + ?.let { cursor.getString(it) } + val fileSize = cursor + .getColumnIndex(MediaStore.MediaColumns.SIZE) + .takeIf { it >= 0 } + ?.let { cursor.getLong(it) } + if (fileName == null || fileSize == null) return@use null + IntentManager.FileData( + fileName = fileName, + uri = uri, + sizeBytes = fileSize, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/permissions/PermissionsManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/permissions/PermissionsManager.kt new file mode 100644 index 0000000000..f1233e93f1 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/permissions/PermissionsManager.kt @@ -0,0 +1,45 @@ +package com.bitwarden.authenticator.ui.platform.manager.permissions + +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable + +/** + * Interface for managing permissions. + */ +@Immutable +interface PermissionsManager { + + /** + * Method for creating and returning a permission launcher. + */ + @Composable + fun getLauncher(onResult: (Boolean) -> Unit): ManagedActivityResultLauncher + + /** + * Method for creating and returning a permissions launcher that can request multiple + * permissions at once. + */ + @Composable + fun getPermissionsLauncher( + onResult: (Map) -> Unit, + ): ManagedActivityResultLauncher, Map> + + /** + * Method for checking whether the permission is granted. + */ + fun checkPermission(permission: String): Boolean + + /** + * Method for checking whether the permissions are granted. This returns `true` only if all + * permissions have been granted, `false` otherwise. + */ + fun checkPermissions(permissions: Array): Boolean + + /** + * Method for checking if an informative UI should be shown the user. + */ + fun shouldShouldRequestPermissionRationale( + permission: String, + ): Boolean +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/permissions/PermissionsManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/permissions/PermissionsManagerImpl.kt new file mode 100644 index 0000000000..ca247d3e10 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/permissions/PermissionsManagerImpl.kt @@ -0,0 +1,49 @@ +package com.bitwarden.authenticator.ui.platform.manager.permissions + +import android.app.Activity +import android.content.pm.PackageManager +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.core.content.ContextCompat + +/** + * Primary implementation of [PermissionsManager]. + */ +class PermissionsManagerImpl( + private val activity: Activity, +) : PermissionsManager { + + @Composable + override fun getLauncher( + onResult: (Boolean) -> Unit, + ): ManagedActivityResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = onResult, + ) + + @Composable + override fun getPermissionsLauncher( + onResult: (Map) -> Unit, + ): ManagedActivityResultLauncher, Map> = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + onResult = onResult, + ) + + override fun checkPermission(permission: String): Boolean = + ContextCompat.checkSelfPermission( + activity, + permission, + ) == PackageManager.PERMISSION_GRANTED + + override fun checkPermissions(permissions: Array): Boolean = + permissions.map { checkPermission(it) }.all { isGranted -> isGranted } + + override fun shouldShouldRequestPermissionRationale( + permission: String, + ): Boolean = + activity.shouldShowRequestPermissionRationale(permission) +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/theme/AuthenticatorTheme.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/theme/AuthenticatorTheme.kt new file mode 100644 index 0000000000..e3a99a7a63 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/theme/AuthenticatorTheme.kt @@ -0,0 +1,239 @@ +package com.bitwarden.authenticator.ui.platform.theme + +import android.app.Activity +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.os.Build +import androidx.annotation.ColorRes +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager +import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManagerImpl +import com.bitwarden.authenticator.ui.platform.manager.exit.ExitManager +import com.bitwarden.authenticator.ui.platform.manager.exit.ExitManagerImpl +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManagerImpl +import com.bitwarden.authenticator.ui.platform.manager.permissions.PermissionsManager +import com.bitwarden.authenticator.ui.platform.manager.permissions.PermissionsManagerImpl + +/** + * The overall application theme. This can be configured to support a [theme] and [dynamicColor]. + */ +@Composable +fun AuthenticatorTheme( + theme: AppTheme = AppTheme.DEFAULT, + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val darkTheme = when (theme) { + AppTheme.DEFAULT -> isSystemInDarkTheme() + AppTheme.DARK -> true + AppTheme.LIGHT -> false + } + + // Get the current scheme + val context = LocalContext.current + val activity = context as Activity + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> darkColorScheme(context) + else -> lightColorScheme(context) + } + + // Update status bar according to scheme + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + WindowCompat.setDecorFitsSystemWindows(window, false) + val insetsController = WindowCompat.getInsetsController(window, view) + insetsController.isAppearanceLightStatusBars = !darkTheme + insetsController.isAppearanceLightNavigationBars = !darkTheme + window.setBackgroundDrawable(ColorDrawable(colorScheme.surface.value.toInt())) + } + } + + val nonMaterialColors = if (darkTheme) { + darkNonMaterialColors(context) + } else { + lightNonMaterialColors(context) + } + + CompositionLocalProvider( + LocalNonMaterialColors provides nonMaterialColors, + LocalNonMaterialTypography provides nonMaterialTypography, + LocalPermissionsManager provides PermissionsManagerImpl(activity), + LocalIntentManager provides IntentManagerImpl(context), + LocalExitManager provides ExitManagerImpl(activity), + LocalBiometricsManager provides BiometricsManagerImpl(activity), + ) { + // Set overall theme based on color scheme and typography settings + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) + } +} + +private fun darkColorScheme(context: Context): ColorScheme = + androidx.compose.material3.darkColorScheme( + primary = R.color.dark_primary.toColor(context), + onPrimary = R.color.dark_on_primary.toColor(context), + primaryContainer = R.color.dark_primary_container.toColor(context), + onPrimaryContainer = R.color.dark_on_primary_container.toColor(context), + secondary = R.color.dark_secondary.toColor(context), + onSecondary = R.color.dark_on_secondary.toColor(context), + secondaryContainer = R.color.dark_secondary_container.toColor(context), + onSecondaryContainer = R.color.dark_on_secondary_container.toColor(context), + tertiary = R.color.dark_tertiary.toColor(context), + onTertiary = R.color.dark_on_tertiary.toColor(context), + tertiaryContainer = R.color.dark_tertiary_container.toColor(context), + onTertiaryContainer = R.color.dark_on_tertiary_container.toColor(context), + error = R.color.dark_error.toColor(context), + onError = R.color.dark_on_error.toColor(context), + errorContainer = R.color.dark_error_container.toColor(context), + onErrorContainer = R.color.dark_on_error_container.toColor(context), + surface = R.color.dark_surface.toColor(context), + surfaceBright = R.color.dark_surface_bright.toColor(context), + surfaceContainer = R.color.dark_surface_container.toColor(context), + surfaceContainerHigh = R.color.dark_surface_container_high.toColor(context), + surfaceContainerHighest = R.color.dark_surface_container_highest.toColor(context), + surfaceContainerLow = R.color.dark_surface_container_low.toColor(context), + surfaceContainerLowest = R.color.dark_surface_container_lowest.toColor(context), + surfaceVariant = R.color.dark_surface_variant.toColor(context), + surfaceDim = R.color.dark_surface_dim.toColor(context), + onSurface = R.color.dark_on_surface.toColor(context), + onSurfaceVariant = R.color.dark_on_surface_variant.toColor(context), + outline = R.color.dark_outline.toColor(context), + outlineVariant = R.color.dark_outline_variant.toColor(context), + inverseSurface = R.color.dark_inverse_surface.toColor(context), + inverseOnSurface = R.color.dark_inverse_on_surface.toColor(context), + inversePrimary = R.color.dark_inverse_primary.toColor(context), + scrim = R.color.dark_scrim.toColor(context), + ) + +private fun lightColorScheme(context: Context): ColorScheme = + androidx.compose.material3.lightColorScheme( + primary = R.color.primary.toColor(context), + onPrimary = R.color.on_primary.toColor(context), + primaryContainer = R.color.primary_container.toColor(context), + onPrimaryContainer = R.color.on_primary_container.toColor(context), + secondary = R.color.secondary.toColor(context), + onSecondary = R.color.on_secondary.toColor(context), + secondaryContainer = R.color.secondary_container.toColor(context), + onSecondaryContainer = R.color.on_secondary_container.toColor(context), + tertiary = R.color.tertiary.toColor(context), + onTertiary = R.color.on_tertiary.toColor(context), + tertiaryContainer = R.color.tertiary_container.toColor(context), + onTertiaryContainer = R.color.on_tertiary_container.toColor(context), + error = R.color.error.toColor(context), + onError = R.color.on_error.toColor(context), + errorContainer = R.color.error_container.toColor(context), + onErrorContainer = R.color.on_error_container.toColor(context), + surface = R.color.surface.toColor(context), + surfaceBright = R.color.surface_bright.toColor(context), + surfaceContainer = R.color.surface_container.toColor(context), + surfaceContainerHigh = R.color.surface_container_high.toColor(context), + surfaceContainerHighest = R.color.surface_container_highest.toColor(context), + surfaceContainerLow = R.color.surface_container_low.toColor(context), + surfaceContainerLowest = R.color.surface_container_lowest.toColor(context), + surfaceVariant = R.color.surface_variant.toColor(context), + surfaceDim = R.color.surface_dim.toColor(context), + onSurface = R.color.on_surface.toColor(context), + onSurfaceVariant = R.color.on_surface_variant.toColor(context), + outline = R.color.outline.toColor(context), + outlineVariant = R.color.outline_variant.toColor(context), + inverseSurface = R.color.inverse_surface.toColor(context), + inverseOnSurface = R.color.inverse_on_surface.toColor(context), + inversePrimary = R.color.inverse_primary.toColor(context), + scrim = R.color.scrim.toColor(context), + ) + +@ColorRes +private fun Int.toColor(context: Context): Color = + Color(context.getColor(this)) + +/** + * Provides access to the biometrics manager throughout the app. + */ +val LocalBiometricsManager: ProvidableCompositionLocal = compositionLocalOf { + error("CompositionLocal BiometricsManager not present") +} + +/** + * Provides access to the exit manager throughout the app. + */ +val LocalExitManager: ProvidableCompositionLocal = compositionLocalOf { + error("CompositionLocal ExitManager not present") +} + +/** + * Provides access to the intent manager throughout the app. + */ +val LocalIntentManager: ProvidableCompositionLocal = compositionLocalOf { + error("CompositionLocal LocalIntentManager not present") +} + +/** + * Provides access to the permission manager throughout the app. + */ +val LocalPermissionsManager: ProvidableCompositionLocal = compositionLocalOf { + error("CompositionLocal LocalPermissionsManager not present") +} + +/** + * Provides access to non material theme typography throughout the app. + */ +val LocalNonMaterialTypography: ProvidableCompositionLocal = + compositionLocalOf { nonMaterialTypography } + +/** + * Provides access to non material theme colors throughout the app. + */ +val LocalNonMaterialColors: ProvidableCompositionLocal = + compositionLocalOf { + // Default value here will immediately be overridden in BitwardenTheme, similar + // to how MaterialTheme works. + NonMaterialColors( + fingerprint = Color.Transparent, + qrCodeClickableText = Color.Transparent, + ) + } + +/** + * Models colors that live outside of the Material Theme spec. + */ +data class NonMaterialColors( + val fingerprint: Color, + val qrCodeClickableText: Color, +) + +private fun lightNonMaterialColors(context: Context): NonMaterialColors = + NonMaterialColors( + fingerprint = R.color.light_fingerprint.toColor(context), + qrCodeClickableText = R.color.qr_code_clickable_text.toColor(context), + ) + +private fun darkNonMaterialColors(context: Context): NonMaterialColors = + NonMaterialColors( + fingerprint = R.color.dark_fingerprint.toColor(context), + qrCodeClickableText = R.color.qr_code_clickable_text.toColor(context), + ) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/theme/SpanStyles.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/theme/SpanStyles.kt new file mode 100644 index 0000000000..9f172558dd --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/theme/SpanStyles.kt @@ -0,0 +1,21 @@ +package com.bitwarden.authenticator.ui.platform.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle + +/** + * Defines a span style for clickable span texts. Useful because spans require a + * [SpanStyle] instead of the typical [TextStyle]. + */ +@Composable +@ReadOnlyComposable +fun clickableSpanStyle(): SpanStyle = SpanStyle( + color = MaterialTheme.colorScheme.primary, + fontSize = MaterialTheme.typography.bodyMedium.fontSize, + fontWeight = MaterialTheme.typography.bodyMedium.fontWeight, + fontStyle = MaterialTheme.typography.bodyMedium.fontStyle, + fontFamily = MaterialTheme.typography.bodyMedium.fontFamily, +) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/theme/Transition.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/theme/Transition.kt new file mode 100644 index 0000000000..3419100727 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/theme/Transition.kt @@ -0,0 +1,371 @@ +package com.bitwarden.authenticator.ui.platform.theme + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.navigation.NavBackStackEntry +import androidx.navigation.compose.NavHost +import com.bitwarden.authenticator.ui.platform.theme.RootTransitionProviders.Exit.stay + +typealias EnterTransitionProvider = + (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition?) + +typealias ExitTransitionProvider = + (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition?) + +typealias NonNullEnterTransitionProvider = + (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition) + +typealias NonNullExitTransitionProvider = + (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition) + +/** + * The default transition time (in milliseconds) for all fade transitions in the + * [TransitionProviders]. + */ +const val DEFAULT_FADE_TRANSITION_TIME_MS: Int = 300 + +/** + * The default transition time (in milliseconds) for all slide transitions in the + * [TransitionProviders]. + */ +const val DEFAULT_SLIDE_TRANSITION_TIME_MS: Int = 450 + +/** + * The default transition time (in milliseconds) for all slide transitions in the + * [TransitionProviders]. + */ +const val DEFAULT_PUSH_TRANSITION_TIME_MS: Int = 350 + +/** + * The default transition time (in milliseconds) for all "stay"/no-op transitions in the + * [TransitionProviders]. + * + * This should be at least as large as any other transition that might also be happening during a + * navigation. + */ +val DEFAULT_STAY_TRANSITION_TIME_MS: Int = + maxOf( + DEFAULT_FADE_TRANSITION_TIME_MS, + DEFAULT_SLIDE_TRANSITION_TIME_MS, + DEFAULT_PUSH_TRANSITION_TIME_MS, + ) + +/** + * Checks if the parent of the destination before and after the navigation is the same. This is + * useful to ignore certain enter/exit transitions when navigating between distinct, nested flows. + */ +val AnimatedContentTransitionScope.isSameGraphNavigation: Boolean + get() = initialState.destination.parent == targetState.destination.parent + +/** + * Contains standard "transition providers" that may be used to specify the [EnterTransition] and + * [ExitTransition] used when building a typical composable destination. These may return `null` + * values in order to allow transitions between nested navigation graphs to be specified by + * components higher up in the graph. + */ +object TransitionProviders { + /** + * The standard set of "enter" transition providers. + */ + object Enter { + /** + * Fades the new screen in. + * + * Note that this represents a `null` transition when navigating between different nested + * navigation graphs. + */ + val fadeIn: EnterTransitionProvider = { + RootTransitionProviders + .Enter + .fadeIn(this) + .takeIf { isSameGraphNavigation } + } + + /** + * Slides the new screen in from the left of the screen. + */ + val pushLeft: EnterTransitionProvider = { + RootTransitionProviders + .Enter + .pushLeft(this) + .takeIf { isSameGraphNavigation } + } + + /** + * Slides the new screen in from the right of the screen. + */ + val pushRight: EnterTransitionProvider = { + RootTransitionProviders + .Enter + .pushRight(this) + .takeIf { isSameGraphNavigation } + } + + /** + * Slides the new screen in from the bottom of the screen. + * + * Note that this represents a `null` transition when navigating between different nested + * navigation graphs. + */ + val slideUp: EnterTransitionProvider = { + RootTransitionProviders + .Enter + .slideUp(this) + .takeIf { isSameGraphNavigation } + } + + /** + * A "no-op" transition: this changes nothing about the screen but "lasts" as long as + * other standard transitions in order to leave the screen in place such that it does not + * immediately appear while the other screen transitions away. + * + * Note that this represents a `null` transition when navigating between different nested + * navigation graphs. + */ + val stay: EnterTransitionProvider = { + RootTransitionProviders + .Enter + .stay(this) + .takeIf { isSameGraphNavigation } + } + } + + /** + * The standard set of "exit" transition providers. + */ + object Exit { + /** + * Fades the current screen out. + * + * Note that this represents a `null` transition when navigating between different nested + * navigation graphs. + */ + val fadeOut: ExitTransitionProvider = { + RootTransitionProviders + .Exit + .fadeOut(this) + .takeIf { isSameGraphNavigation } + } + + /** + * Slides the current screen out to the left of the screen. + */ + val pushLeft: ExitTransitionProvider = { + RootTransitionProviders + .Exit + .pushLeft(this) + .takeIf { isSameGraphNavigation } + } + + /** + * Slides the current screen out to the right of the screen. + */ + val pushRight: ExitTransitionProvider = { + RootTransitionProviders + .Exit + .pushRight(this) + .takeIf { isSameGraphNavigation } + } + + /** + * Slides the current screen down to the bottom of the screen. + * + * Note that this represents a `null` transition when navigating between different nested + * navigation graphs. + */ + val slideDown: ExitTransitionProvider = { + RootTransitionProviders + .Exit + .slideDown(this) + .takeIf { isSameGraphNavigation } + } + + /** + * A "no-op" transition: this changes nothing about the screen but "lasts" as long as + * other standard transitions in order to leave the screen in place such that it does not + * immediately disappear while the other screen transitions into place. + * + * Note that this represents a `null` transition when navigating between different nested + * navigation graphs. + */ + val stay: ExitTransitionProvider = { + RootTransitionProviders + .Exit + .stay(this) + .takeIf { isSameGraphNavigation } + } + } +} + +/** + * Contains standard "transition providers" that may be used to specify the [EnterTransition] and + * [ExitTransition] used when building a root [NavHost], which requires a non-null value. + */ +object RootTransitionProviders { + /** + * The standard set of "enter" transition providers. + */ + object Enter { + /** + * Fades the new screen in. + */ + val fadeIn: NonNullEnterTransitionProvider = { + fadeIn(tween(DEFAULT_FADE_TRANSITION_TIME_MS)) + } + + /** + * There is no transition for the entering screen. + */ + val none: NonNullEnterTransitionProvider = { + EnterTransition.None + } + + /** + * Slides the new screen in from the left of the screen. + */ + val pushLeft: NonNullEnterTransitionProvider = { + val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS + slideInHorizontally( + animationSpec = tween(durationMillis = totalTransitionDurationMs), + initialOffsetX = { fullWidth -> fullWidth / 2 }, + ) + fadeIn( + animationSpec = tween( + durationMillis = totalTransitionDurationMs / 2, + delayMillis = totalTransitionDurationMs / 2, + ), + ) + } + + /** + * Slides the new screen in from the right of the screen. + */ + val pushRight: NonNullEnterTransitionProvider = { + val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS + slideInHorizontally( + animationSpec = tween(durationMillis = totalTransitionDurationMs), + initialOffsetX = { fullWidth -> -fullWidth / 2 }, + ) + fadeIn( + animationSpec = tween( + durationMillis = totalTransitionDurationMs / 2, + delayMillis = totalTransitionDurationMs / 2, + ), + ) + } + + /** + * Slides the new screen in from the bottom of the screen. + */ + val slideUp: NonNullEnterTransitionProvider = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Up, + animationSpec = tween(DEFAULT_SLIDE_TRANSITION_TIME_MS), + ) + } + + /** + * A "no-op" transition: this changes nothing about the screen but "lasts" as long as + * other standard transitions in order to leave the screen in place such that it does not + * immediately appear while the other screen transitions away. + */ + val stay: NonNullEnterTransitionProvider = { + fadeIn( + animationSpec = tween(DEFAULT_STAY_TRANSITION_TIME_MS), + initialAlpha = 1f, + ) + } + } + + /** + * The standard set of "exit" transition providers. + */ + object Exit { + /** + * Fades the current screen out. + */ + val fadeOut: NonNullExitTransitionProvider = { + fadeOut(tween(DEFAULT_FADE_TRANSITION_TIME_MS)) + } + + /** + * There is no transition for the exiting screen. + * + * Unlike the [stay] transition, this will immediately remove the outgoing screen even if + * there is an ongoing enter transition happening for the new screen. + */ + val none: NonNullExitTransitionProvider = { + ExitTransition.None + } + + /** + * Slides the current screen out to the left of the screen. + */ + @Suppress("MagicNumber") + val pushLeft: NonNullExitTransitionProvider = { + val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS + val delayMs = totalTransitionDurationMs / 7 + val slideWithoutDelayMs = totalTransitionDurationMs - delayMs + slideOutHorizontally( + animationSpec = tween( + durationMillis = slideWithoutDelayMs, + delayMillis = delayMs, + ), + targetOffsetX = { fullWidth -> -fullWidth / 2 }, + ) + fadeOut( + animationSpec = tween( + durationMillis = totalTransitionDurationMs / 2, + delayMillis = delayMs, + ), + ) + } + + /** + * Slides the current screen out to the right of the screen. + */ + @Suppress("MagicNumber") + val pushRight: NonNullExitTransitionProvider = { + val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS + val delayMs = totalTransitionDurationMs / 7 + val slideWithoutDelayMs = totalTransitionDurationMs - delayMs + slideOutHorizontally( + animationSpec = tween( + durationMillis = slideWithoutDelayMs, + delayMillis = delayMs, + ), + targetOffsetX = { fullWidth -> fullWidth / 2 }, + ) + fadeOut( + animationSpec = tween( + durationMillis = totalTransitionDurationMs / 2, + delayMillis = delayMs, + ), + ) + } + + /** + * Slides the current screen down to the bottom of the screen. + */ + val slideDown: NonNullExitTransitionProvider = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Down, + animationSpec = tween(DEFAULT_SLIDE_TRANSITION_TIME_MS), + ) + } + + /** + * A "no-op" transition: this changes nothing about the screen but "lasts" as long as + * other standard transitions in order to leave the screen in place such that it does not + * immediately disappear while the other screen transitions into place. + */ + val stay: NonNullExitTransitionProvider = { + fadeOut( + animationSpec = tween(DEFAULT_STAY_TRANSITION_TIME_MS), + targetAlpha = 0.99f, + ) + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/theme/Type.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/theme/Type.kt new file mode 100644 index 0000000000..ef44dca9f5 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/theme/Type.kt @@ -0,0 +1,255 @@ +package com.bitwarden.authenticator.ui.platform.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.sp +import com.bitwarden.authenticator.R + +val Typography: Typography = Typography( + displayLarge = TextStyle( + fontSize = 57.sp, + lineHeight = 64.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontWeight = FontWeight.W400, + letterSpacing = (-0.25).sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + displayMedium = TextStyle( + fontSize = 45.sp, + lineHeight = 52.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontWeight = FontWeight.W400, + letterSpacing = (0).sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + displaySmall = TextStyle( + fontSize = 36.sp, + lineHeight = 44.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontWeight = FontWeight.W400, + letterSpacing = 0.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + headlineLarge = TextStyle( + fontSize = 32.sp, + lineHeight = 40.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontWeight = FontWeight.W400, + letterSpacing = 0.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + headlineMedium = TextStyle( + fontSize = 28.sp, + lineHeight = 36.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontWeight = FontWeight.W400, + letterSpacing = 0.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + headlineSmall = TextStyle( + fontSize = 24.sp, + lineHeight = 32.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontWeight = FontWeight.W400, + letterSpacing = 0.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + titleLarge = TextStyle( + fontSize = 22.sp, + lineHeight = 28.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontWeight = FontWeight.W400, + letterSpacing = 0.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + titleMedium = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontFamily = FontFamily(Font(R.font.roboto_medium)), + fontWeight = FontWeight.W500, + letterSpacing = 0.15.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + titleSmall = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = FontFamily(Font(R.font.roboto_medium)), + fontWeight = FontWeight.W500, + letterSpacing = 0.1.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + bodyLarge = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontWeight = FontWeight.W400, + letterSpacing = 0.5.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + bodyMedium = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontWeight = FontWeight.W400, + letterSpacing = 0.25.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + bodySmall = TextStyle( + fontSize = 12.sp, + lineHeight = 16.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontWeight = FontWeight.W400, + letterSpacing = 0.4.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + labelLarge = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = FontFamily(Font(R.font.roboto_medium)), + fontWeight = FontWeight.W500, + letterSpacing = 0.1.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + labelMedium = TextStyle( + fontSize = 12.sp, + lineHeight = 16.sp, + fontFamily = FontFamily(Font(R.font.roboto_medium)), + fontWeight = FontWeight.W500, + letterSpacing = 0.5.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + labelSmall = TextStyle( + fontSize = 11.sp, + lineHeight = 16.sp, + fontFamily = FontFamily(Font(R.font.roboto_medium)), + fontWeight = FontWeight.W500, + letterSpacing = 0.5.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), +) + +val nonMaterialTypography: NonMaterialTypography = NonMaterialTypography( + sensitiveInfoSmall = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular_mono)), + fontWeight = FontWeight.W400, + letterSpacing = 0.5.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + sensitiveInfoMedium = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular_mono)), + fontWeight = FontWeight.W400, + letterSpacing = 0.5.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + bodySmallProminent = TextStyle( + fontSize = 12.sp, + lineHeight = 16.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontWeight = FontWeight.W700, + letterSpacing = 0.4.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + labelMediumProminent = TextStyle( + fontSize = 12.sp, + lineHeight = 16.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontWeight = FontWeight.W600, + letterSpacing = 0.5.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), +) + +/** + * Models typography that live outside of the Material Theme spec. + */ +data class NonMaterialTypography( + val bodySmallProminent: TextStyle, + val labelMediumProminent: TextStyle, + val sensitiveInfoSmall: TextStyle, + val sensitiveInfoMedium: TextStyle, +) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/AppThemeExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/AppThemeExtensions.kt new file mode 100644 index 0000000000..51e243123e --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/AppThemeExtensions.kt @@ -0,0 +1,16 @@ +package com.bitwarden.authenticator.ui.platform.util + +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme + +/** + * Returns a human-readable display label for the given [AppTheme]. + */ +val AppTheme.displayLabel: Text + get() = when (this) { + AppTheme.DEFAULT -> R.string.default_system.asText() + AppTheme.DARK -> R.string.dark.asText() + AppTheme.LIGHT -> R.string.light.asText() + } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ConfigurationExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ConfigurationExtensions.kt new file mode 100644 index 0000000000..709022e17a --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ConfigurationExtensions.kt @@ -0,0 +1,15 @@ +@file:OmitFromCoverage + +package com.bitwarden.authenticator.ui.platform.util + +import android.content.res.Configuration +import com.bitwarden.authenticator.data.platform.annotation.OmitFromCoverage + +/** + * A helper method to indicate if the current UI configuration is portrait or not. + */ +val Configuration.isPortrait: Boolean + get() = when (this.orientation) { + Configuration.ORIENTATION_LANDSCAPE -> false + else -> true + } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/DefaultSaveOptionExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/DefaultSaveOptionExtensions.kt new file mode 100644 index 0000000000..3e563551bd --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/DefaultSaveOptionExtensions.kt @@ -0,0 +1,16 @@ +package com.bitwarden.authenticator.ui.platform.util + +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption + +/** + * Returns a human-readable display label for the given [DefaultSaveOption]. + */ +val DefaultSaveOption.displayLabel: Text + get() = when (this) { + DefaultSaveOption.NONE -> R.string.none.asText() + DefaultSaveOption.LOCAL -> R.string.save_here.asText() + DefaultSaveOption.BITWARDEN_APP -> R.string.save_to_bitwarden.asText() + } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ExportFormatExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ExportFormatExtensions.kt new file mode 100644 index 0000000000..7b2964fa3e --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ExportFormatExtensions.kt @@ -0,0 +1,24 @@ +package com.bitwarden.authenticator.ui.platform.util + +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.feature.settings.export.model.ExportVaultFormat + +/** + * Provides a human-readable label for the export format. + */ +val ExportVaultFormat.displayLabel: Text + get() = when (this) { + ExportVaultFormat.JSON -> R.string.export_format_label_json.asText() + ExportVaultFormat.CSV -> R.string.export_format_label_csv.asText() + } + +/** + * Provides the file extension associated with the export format. + */ +val ExportVaultFormat.fileExtension: String + get() = when (this) { + ExportVaultFormat.JSON -> "json" + ExportVaultFormat.CSV -> "csv" + } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ImportFormatExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ImportFormatExtensions.kt new file mode 100644 index 0000000000..c4ca953404 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ImportFormatExtensions.kt @@ -0,0 +1,17 @@ +package com.bitwarden.authenticator.ui.platform.util + +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText + +/** + * Provides a human-readable label for the export format. + */ +val ImportFileFormat.displayLabel: Text + get() = when (this) { + ImportFileFormat.BITWARDEN_JSON -> R.string.import_format_label_bitwarden_json.asText() + ImportFileFormat.TWO_FAS_JSON -> R.string.import_format_label_2fas_json.asText() + ImportFileFormat.LAST_PASS_JSON -> R.string.import_format_label_lastpass_json.asText() + ImportFileFormat.AEGIS -> R.string.import_format_label_aegis_json.asText() + } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/TemporalAccessExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/TemporalAccessExtensions.kt new file mode 100644 index 0000000000..936527cf53 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/TemporalAccessExtensions.kt @@ -0,0 +1,22 @@ +package com.bitwarden.authenticator.ui.platform.util + +import java.time.Clock +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.TemporalAccessor + +/** + * Converts the [TemporalAccessor] to a formatted string based on the provided pattern and timezone. + */ +fun TemporalAccessor.toFormattedPattern( + pattern: String, + zone: ZoneId, +): String = DateTimeFormatter.ofPattern(pattern).withZone(zone).format(this) + +/** + * Converts the [TemporalAccessor] to a formatted string based on the provided pattern and timezone. + */ +fun TemporalAccessor.toFormattedPattern( + pattern: String, + clock: Clock = Clock.systemDefaultZone(), +): String = toFormattedPattern(pattern = pattern, zone = clock.zone) diff --git a/authenticator/src/main/proto/google_authenticator.proto b/authenticator/src/main/proto/google_authenticator.proto new file mode 100644 index 0000000000..9e622d028b --- /dev/null +++ b/authenticator/src/main/proto/google_authenticator.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +option java_package = "com.bitwarden.authenticator.data.platform.manager.imports.model"; +option java_outer_classname = "GoogleAuthenticatorProtos"; + +message MigrationPayload { + enum Algorithm { + ALGO_INVALID = 0; + ALGO_SHA1 = 1; + } + + enum OtpType { + OTP_INVALID = 0; + OTP_HOTP = 1; + OTP_TOTP = 2; + } + + message OtpParameters { + bytes secret = 1; + string name = 2; + string issuer = 3; + Algorithm algorithm = 4; + int32 digits = 5; + OtpType type = 6; + int64 counter = 7; + } + + repeated OtpParameters otp_parameters = 1; + int32 version = 2; + int32 batch_size = 3; + int32 batch_index = 4; + int32 batch_id = 5; +} diff --git a/authenticator/src/main/res/drawable-night/ic_empty_vault.xml b/authenticator/src/main/res/drawable-night/ic_empty_vault.xml new file mode 100644 index 0000000000..7844042007 --- /dev/null +++ b/authenticator/src/main/res/drawable-night/ic_empty_vault.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_arrow_right.xml b/authenticator/src/main/res/drawable/ic_arrow_right.xml new file mode 100644 index 0000000000..1df294a97a --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_arrow_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/authenticator/src/main/res/drawable/ic_back.xml b/authenticator/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000000..72edab8b28 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/authenticator/src/main/res/drawable/ic_bitwarden.xml b/authenticator/src/main/res/drawable/ic_bitwarden.xml new file mode 100644 index 0000000000..b136ea789b --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_bitwarden.xml @@ -0,0 +1,9 @@ + + + diff --git a/authenticator/src/main/res/drawable/ic_camera.xml b/authenticator/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000000..c7af235783 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,13 @@ + + + + diff --git a/authenticator/src/main/res/drawable/ic_chevron_down.xml b/authenticator/src/main/res/drawable/ic_chevron_down.xml new file mode 100644 index 0000000000..ae597f5f48 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_chevron_down.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_chevron_up.xml b/authenticator/src/main/res/drawable/ic_chevron_up.xml new file mode 100644 index 0000000000..2834d24487 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_chevron_up.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_close.xml b/authenticator/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000000..0709d97e4d --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/authenticator/src/main/res/drawable/ic_copy.xml b/authenticator/src/main/res/drawable/ic_copy.xml new file mode 100644 index 0000000000..6de6bd02fa --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_delete_item.xml b/authenticator/src/main/res/drawable/ic_delete_item.xml new file mode 100644 index 0000000000..f8780ff816 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_delete_item.xml @@ -0,0 +1,10 @@ + + + diff --git a/authenticator/src/main/res/drawable/ic_edit_item.xml b/authenticator/src/main/res/drawable/ic_edit_item.xml new file mode 100644 index 0000000000..61a5c03a8c --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_edit_item.xml @@ -0,0 +1,10 @@ + + + diff --git a/authenticator/src/main/res/drawable/ic_empty_vault.xml b/authenticator/src/main/res/drawable/ic_empty_vault.xml new file mode 100644 index 0000000000..b14c68cd7f --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_empty_vault.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_empty_vault_dark.xml b/authenticator/src/main/res/drawable/ic_empty_vault_dark.xml new file mode 100644 index 0000000000..7844042007 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_empty_vault_dark.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_empty_vault_light.xml b/authenticator/src/main/res/drawable/ic_empty_vault_light.xml new file mode 100644 index 0000000000..b14c68cd7f --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_empty_vault_light.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_external_link.xml b/authenticator/src/main/res/drawable/ic_external_link.xml new file mode 100644 index 0000000000..63c65dfe0c --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_external_link.xml @@ -0,0 +1,12 @@ + + + + diff --git a/authenticator/src/main/res/drawable/ic_keyboard_24px.xml b/authenticator/src/main/res/drawable/ic_keyboard_24px.xml new file mode 100644 index 0000000000..cfdcd0f52e --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_keyboard_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/authenticator/src/main/res/drawable/ic_launcher_foreground.xml b/authenticator/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..df54b36f91 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_launcher_monochrome.xml b/authenticator/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000000..df54b36f91 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_login_item.xml b/authenticator/src/main/res/drawable/ic_login_item.xml new file mode 100644 index 0000000000..570e30e5a5 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_login_item.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_logo_horizontal.xml b/authenticator/src/main/res/drawable/ic_logo_horizontal.xml new file mode 100644 index 0000000000..a923925ee3 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_logo_horizontal.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/authenticator/src/main/res/drawable/ic_minus.xml b/authenticator/src/main/res/drawable/ic_minus.xml new file mode 100644 index 0000000000..e48c80009e --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_minus.xml @@ -0,0 +1,10 @@ + + + diff --git a/authenticator/src/main/res/drawable/ic_more.xml b/authenticator/src/main/res/drawable/ic_more.xml new file mode 100644 index 0000000000..2cf873f58e --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_more.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/authenticator/src/main/res/drawable/ic_more_horizontal.xml b/authenticator/src/main/res/drawable/ic_more_horizontal.xml new file mode 100644 index 0000000000..55d76917de --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_more_horizontal.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/authenticator/src/main/res/drawable/ic_navigate_next.xml b/authenticator/src/main/res/drawable/ic_navigate_next.xml new file mode 100644 index 0000000000..7ff13e39f2 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_navigate_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/authenticator/src/main/res/drawable/ic_plus.xml b/authenticator/src/main/res/drawable/ic_plus.xml new file mode 100644 index 0000000000..337951f8b5 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_plus.xml @@ -0,0 +1,9 @@ + + + diff --git a/authenticator/src/main/res/drawable/ic_refresh.xml b/authenticator/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000000..31962ef4df --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/authenticator/src/main/res/drawable/ic_region_select_dropdown.xml b/authenticator/src/main/res/drawable/ic_region_select_dropdown.xml new file mode 100644 index 0000000000..48378de39d --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_region_select_dropdown.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_search_24px.xml b/authenticator/src/main/res/drawable/ic_search_24px.xml new file mode 100644 index 0000000000..d941ea8f09 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_search_24px.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_settings.xml b/authenticator/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000000..25c7d29b3e --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_settings_filled.xml b/authenticator/src/main/res/drawable/ic_settings_filled.xml new file mode 100644 index 0000000000..118c067472 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_settings_filled.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_tooltip.xml b/authenticator/src/main/res/drawable/ic_tooltip.xml new file mode 100644 index 0000000000..70557ae410 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_tooltip.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_tooltip_small.xml b/authenticator/src/main/res/drawable/ic_tooltip_small.xml new file mode 100644 index 0000000000..149dd5ae8b --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_tooltip_small.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_tutorial_2fa.xml b/authenticator/src/main/res/drawable/ic_tutorial_2fa.xml new file mode 100644 index 0000000000..b09c1c2634 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_tutorial_2fa.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_tutorial_qr_scanner.xml b/authenticator/src/main/res/drawable/ic_tutorial_qr_scanner.xml new file mode 100644 index 0000000000..b309a03bbd --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_tutorial_qr_scanner.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_tutorial_verification_codes.xml b/authenticator/src/main/res/drawable/ic_tutorial_verification_codes.xml new file mode 100644 index 0000000000..9d0cb712e7 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_tutorial_verification_codes.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_verification_codes.xml b/authenticator/src/main/res/drawable/ic_verification_codes.xml new file mode 100644 index 0000000000..9717383a8f --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_verification_codes.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_verification_codes_filled.xml b/authenticator/src/main/res/drawable/ic_verification_codes_filled.xml new file mode 100644 index 0000000000..3a9a4cdde3 --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_verification_codes_filled.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/authenticator/src/main/res/drawable/ic_visibility.xml b/authenticator/src/main/res/drawable/ic_visibility.xml new file mode 100644 index 0000000000..cfd26a1a3a --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_visibility.xml @@ -0,0 +1,14 @@ + + + + diff --git a/authenticator/src/main/res/drawable/ic_visibility_off.xml b/authenticator/src/main/res/drawable/ic_visibility_off.xml new file mode 100644 index 0000000000..cc86816e6c --- /dev/null +++ b/authenticator/src/main/res/drawable/ic_visibility_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/authenticator/src/main/res/drawable/logo_rounded.xml b/authenticator/src/main/res/drawable/logo_rounded.xml new file mode 100644 index 0000000000..5f06e6bf2a --- /dev/null +++ b/authenticator/src/main/res/drawable/logo_rounded.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/authenticator/src/main/res/font/roboto_medium.ttf b/authenticator/src/main/res/font/roboto_medium.ttf new file mode 100644 index 0000000000..ac0f908b9c Binary files /dev/null and b/authenticator/src/main/res/font/roboto_medium.ttf differ diff --git a/authenticator/src/main/res/font/roboto_regular.ttf b/authenticator/src/main/res/font/roboto_regular.ttf new file mode 100644 index 0000000000..ddf4bfacb3 Binary files /dev/null and b/authenticator/src/main/res/font/roboto_regular.ttf differ diff --git a/authenticator/src/main/res/font/roboto_regular_mono.ttf b/authenticator/src/main/res/font/roboto_regular_mono.ttf new file mode 100644 index 0000000000..6df2b25360 Binary files /dev/null and b/authenticator/src/main/res/font/roboto_regular_mono.ttf differ diff --git a/authenticator/src/main/res/font/sf_pro.ttf b/authenticator/src/main/res/font/sf_pro.ttf new file mode 100644 index 0000000000..4f88dc135e Binary files /dev/null and b/authenticator/src/main/res/font/sf_pro.ttf differ diff --git a/authenticator/src/main/res/mipmap-anydpi/ic_launcher.xml b/authenticator/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000000..c78bee3b53 --- /dev/null +++ b/authenticator/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/authenticator/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/authenticator/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000000..c78bee3b53 --- /dev/null +++ b/authenticator/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/authenticator/src/main/res/resources.properties b/authenticator/src/main/res/resources.properties new file mode 100644 index 0000000000..467b3efec9 --- /dev/null +++ b/authenticator/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en-US diff --git a/authenticator/src/main/res/values-af-rZA/strings.xml b/authenticator/src/main/res/values-af-rZA/strings.xml new file mode 100644 index 0000000000..fdcce20cc5 --- /dev/null +++ b/authenticator/src/main/res/values-af-rZA/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Avbryt + Legg til gjenstand + En feil har oppstått. + We were unable to process your request. Please try again or contact us. + Internet connection required + Vennligst koble deg til internett før du fortsetter. + OK + Synkroniserer + Kopier + Rediger + Close + Bitwarden-autentiserer + Navn + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Pek kameraet ditt mot QR-koden. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Autentiseringsnøkkel + Nei takk + Innstillinger + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Legg til kode + Cannot read key. + Verification code added + Brukernavn + Refresh period + Algoritme + Skjul + Vis + Avansert + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Informasjon + OTP-type + Verification codes + There are no items that match the search + Tilbake + Clear + Search codes + Innstillinger + Prøv igjen + Utseende + Standard (System) + Tema + Mørk + Lys + Språk + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Fortsett + Hopp over + Kom i gang + Unique codes + Hjelp + Launch tutorial + %1$s copied + Slett gjenstanden + Item deleted + Slett + Vil du virkelig slette denne permanent? Dette kan ikke angres. + Data + Eksporter + Laster + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + Filformat + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometri + Sikkerhet + Use biometrics to unlock + Too many failed biometrics attempts. + Om + Versjon + Fortsett + Bitwarden Help Center + Vil du fortsette til Hjelpesenteret? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Personvernerklæring + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Nøkkel + Create Verification code + Key is required. + Et navn er påkrevd. + Submit crash logs + There was a problem importing your vault. + Filens kilde + Importer + Vault import successful + Nøkkelen er ugyldig. + Save as a favorite + Favoritt + Favoritter + Sikkerhetskopi + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Lær mer + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Last ned nå + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Gå til innstillingene + Allow Authenticator app syncing in settings to view all of your verification codes here. + Noe gikk galt + Vennligst prøv igjen + Move to Bitwarden + Default save option + Lagre til Bitwarden + Lagre her + Ingen + Select where you would like to save new verification codes. + Bekreft + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Få hjelp + diff --git a/authenticator/src/main/res/values-ar-rSA/strings.xml b/authenticator/src/main/res/values-ar-rSA/strings.xml new file mode 100644 index 0000000000..591a26c995 --- /dev/null +++ b/authenticator/src/main/res/values-ar-rSA/strings.xml @@ -0,0 +1,155 @@ + + + المصادِق + المصادقة البيومترية + إلغاء + أضف عنصرا + لقد حدث خطأ. + لم نتمكن من معالجة طلبك. الرجاء المحاولة مرة أخرى أو الاتصال بنا. + الاتصال بالإنترنت مطلوب + الرجاء الاتصال بالإنترنت قبل المتابعة. + حسناً + مزامنة + نسخ + تعديل + إغلاق + مصادق Bitwarden + الاسم + أضف تدوير العنصر + امسح كود الاستجابة السريع ضوئيًا + أدخِل مفتاح الإعداد + امسح رمز استجابة سريعة ضوئيًا + حدد الكاميرا الخاصة بك في رمز QR. + لا يمكن مسح رمز QR. + أدخل المفتاح يدوياً + لا يمكن إضافة مفتاح المصادقة؟ + بمجرد إدخال المفتاح بنجاح،\nحدد إضافة TOTP لتخزين المفتاح بأمان + أضف TOTP + مفتاح المصادقة + ﻻ، شكرًا + الإعدادات + تمكين إذن الكاميرا لاستخدام الماسح الضوئي + قائمة العناصر فارغة + ليس لديك أي عناصر لعرضها. + أضف رمزا جديدا لتأمين حساباتك. + أضف رمزا + لا يمكن قراءة المفتاح. + أضيف رمز التحقق + اسم المستخدم + فترة التحديث + الخوارزمية + إخفاء + إظهار + متقدم + إغلاق الخيارات المتقدمة + عدد الأرقام + حفظ + حقل %1$s مطلوب. + %d ثواني + جار الحفظ + تم حفظ العنصر + معلومات + نوع OTP + رموز التحقق + لا توجد عناصر تطابق البحث + رجوع + مسح + ابحث عن رموز + الخيارات + حاول مجددًا + المظهر + الافتراضي (النظام) + السمة + داكن + فاتح + اللّغة + تم تغيير اللغة إلى %1$s. الرجاء إعادة تشغيل التطبيق لرؤية التغيير + تأمين حساباتك مع مصادق Bitwarden + احصل على رموز التحقق لجميع حساباتك التي تدعم التحقق بخطوتين. + استخدم كاميرا جهازك لمسح الرموز + امسح رمز QR في إعدادات التحقق من خطوتين لأي حساب. + تسجيل الدخول باستخدام الرموز الفريدة + عند استخدام التحقق بخطوتين، ستدخل اسم المستخدم وكلمة المرور الخاصة بك وكلمة المرور التي أنشئت في هذا التطبيق. + متابعة + تخطي + ابدأ الآن + رموز فريدة + المساعدة + تشغيل البرنامج التعليمي + %1$s تم نسخه + حذف العنصر + تم حذف العنصر + حذف + هل تريد حقاً الحذف نهائيًا؟ لا يمكن التراجع عن ذلك. + البيانات + تصدير + جار التحميل + تأكيد التصدير + يحتوي هذا التصدير على بياناتك بتنسيق غير مشفر. لا يجب عليك تخزين أو إرسال الملف الذي صُدِّر عبر قنوات غير آمنة (مثل البريد الإلكتروني). احذفه مباشرة بعد انتهائك من استخدامه. + صيغة الملف + كانت هناك مشكلة في تصدير خزانتك. إذا استمرت المشكلة، ستحتاج إلى التصدير من خزانة الويب. + تم تصدير البيانات بنجاح + فتح مع %1$s + البصمات + الأمان + استخدام القياسات الحيوية للفتح + العديد من محاولات القياس الحيوي الفاشلة. + حول + الإصدار + متابعة + مركز المساعدة Bitwarden + هل تريد المتابعة إلى مركز المساعدة؟ + تعرف على المزيد حول كيفية استخدام مصادق Bitwarden في مركز المساعدة. + سياسة الخصوصية + هل تريد المتابعة إلى سياسة الخصوصية؟ + اطلع على سياستنا للخصوصية على bitwarden.com + مفتاح + إنشاء رمز التحقق + المفتاح مطلوب. + الاسم مطلوب. + إرسال سجلات الأعطال + حدثت مشكلة في استيراد خزانتك. + مصدر الملف + استيراد + تم استيراد الخزانة بنجاح + المفتاح غير صالح. + حفظ كمفضلة + المفضلة + المفضلات + النسخ الاحتياطي + النسخ الاحتياطي للبيانات + يتم النسخ الاحتياطي لبيانات مصادقة Bitwarden ويمكن استعادتها مع النسخ الاحتياطية لجهازك المجدولة بانتظام. + اعرف المزيد + الاستيراد من الملفات المحمية بكلمة مرور 2FAS غير مدعوم. حاول مرة أخرى مع ملف مصدَّر غير محمي بكلمة المرور. + استيراد ملفات Bitwarden CSV غير مدعوم. حاول مرة أخرى مع ملف JSON مصدَّر. + تحميل تطبيق Bitwarden + تخزين جميع تسجيلات الدخول الخاصة بك ومزامنة رموز التحقق مباشرة مع تطبيق المصادقة. + تنزيل الآن + المزامنة مع تطبيق Bitwarden + غير قادر على مزامنة الرموز من تطبيق Bitwarden. تأكد من تحديث كلا التطبيقين. لا يزال بإمكانك الوصول إلى الرموز الموجودة في تطبيق Bitwarden. + %1$s | %2$s + المزامنة مع تطبيق Bitwarden + اذهب إلى الإعدادات + السماح لمزامنة تطبيق المصادقة في الإعدادات لعرض جميع رموز التحقق الخاصة بك هنا. + حدث خطأ ما + الرجاء المحاولة مرة أخرى + نقل إلى Bitwarden + خيار الحفظ الافتراضي + حفظ إلى Bitwarden + حفظ هنا + لا شَيْء + حدد المكان الذي ترغب في حفظ رموز التحقق الجديدة. + تأكيد + أُنشئَ رمز التحقق + احفظ مفتاح المصادقة هذا هنا، أو أضفه إلى تسجيل الدخول في تطبيق Bitwarden الخاص بك. + حفظ الخيار افتراضيا + تمت مزامنة الحساب من تطبيق Bitwarden + إضافة كود إلى Bitwarden + إضافة كود محليا + الرموز المحلية + المعلومات المطلوبة مفقودة + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-az-rAZ/strings.xml b/authenticator/src/main/res/values-az-rAZ/strings.xml new file mode 100644 index 0000000000..f0fe93c396 --- /dev/null +++ b/authenticator/src/main/res/values-az-rAZ/strings.xml @@ -0,0 +1,155 @@ + + + Kimlik doğrulayıcı + Biometrik doğrulama + İmtina + Element əlavə et + Bir xəta baş verdi. + Tələbinizi emal edə bilmədik. Lütfən yenidən sınayın və ya bizimlə əlaqə saxlayın. + İnternet bağlantısı tələb olunur + Davam etməzdən əvvəl lütfən internetə bağlanın. + Oldu + Sinxronlaşdırılır + Kopyala + Düzəliş et + Bağla + Bitwarden Authenticator + Ad + Element döndərmə əlavə et + QR kodu skan et + Quraşdırma açarı daxil et + QR kodu skane et + Kameranı QR koduna yönləndirin. + QR kodu skan edilə bilmir. + Açarı əllə daxil et. + Kimlik doğrulayıcı açarı əlavə edilə bilmir? + Açar uğurla daxil edildikdən sonra\naçarı güvənli şəkildə saxlamaq üçün \"TOTP əlavə et\"i seçin + TOTP əlavə et + Kimlik doğrulayıcı açarı + Xeyr təşəkkürlər + Ayarlar + Skaneri istifadə etmək üçün kamera icazəsini fəallaşdırın + Boş element siyahısı + Göstəriləcək heç bir elementiniz yoxdur. + Hesablarınızı qorumaq üçün yeni bir kod əlavə edin. + Kod əlavə et + Açar oxuna bilmir. + Doğrulama kodu əlavə edildi + İstifadəçi adı + Təzələmə periodu + Alqoritm + Gizlət + Göstər + Qabaqcıl + Qabaqcıl seçimləri yığcamlaşdır + Rəqəm sayı + Saxla + %1$s xanası lazımlıdır. + %d saniyə + Saxlanılır + Element saxlanıldı + Məlumat + OTP növü + Doğrulama kodları + Axtarışla uyuşan heç bir element yoxdur + Geri + Təmizlə + Kod axtar + Seçimlər + Yenidən sına + Görünüş + İlkin (Sistem) + Tema + Qaranlıq + İşıqlı + Dil + Dil, %1$s olaraq dəyişdirildi. Dəyişiklikləri görmək üçün lütfən tətbiqi yenidən başladın + Hesablarınızı Bitwarden Authenticator ilə qoruyun + 2 addımlı doğrulamanı dəstəkləyən bütün hesablarınız üçün doğrulama kodu alın. + Kodları skan etmək üçün cihazınızın kamerasını istifadə edin + İstənilən hesab üçün 2 addımlı doğrulama ayarlarınızdakı QR kodu skan edin. + Unikal kodları istifadə edərək hesabınıza daxil olun + 2 addımlı doğrulamanı istifadə edərkən istifadəçi adınızı, parolunuzu və bu tətbiqdə yaradılan kodu daxil edəcəksiniz. + Davam et + Ötür + Başlayaq + Unikal kodlar + Kömək + Təlimi başlat + %1$s kopyalandı. + Elementi sil + Element silindi + Sil + Həqiqətən birdəfəlik silmək istəyirsiniz? Bunun geri dönüşü yoxdur. + Data + Xaricə köçür + Yüklənir + Xaricə köçürməni təsdiqlə + Bu xaricə köçürmə, datanızı şifrələnməmiş formatda ehtiva edir. Xaricə köçürülən faylı güvənli olmayan kanallar (e-poçt kimi) üzərində saxlamamalı və ya göndərməməlisiniz. İşiniz bitdikdən sonra onu dərhal silin. + Fayl formatı + Seyfin xaricə köçürülməsi zamanı problem yarandı. Əgər problem davam edərsə, veb seyfinizdən xaricə köçürməli olacaqsınız. + Data uğurla xaricə köçürüldü + %1$s ilə kilidi aç + Biometrik + Güvənlik + Kilidi açmaq üçün biometrik istifadə et + Həddən artıq uğursuz biometrik cəhdi. + Haqqında + Versiya + Davam et + Bitwarden Kömək Mərkəzi + Kömək Mərkəzi ilə davam edilsin? + Kömək Mərkəzində Bitwarden Authenticator-u necə istifadə ediləcəyi ilə bağlı ətraflı öyrənin. + Gizlilik siyasəti + Gizlilik Siyasətinə davam edirsiniz? + bitwarden.com saytında gizlilik siyasətimizi nəzərdən keçirin + Açar + Doğrulama kodu yarat + Açar tələb olunur. + Ad tələb olunur. + Çökmə jurnallarını göndər + Seyfiniz daxilə köçürülərkən problem baş verdi. + Fayl mənbəsi + Daxilə köçür + Seyfi daxilə köçürmə uğurlu oldu + Açar yararsızdır. + Sevimli olaraq saxla + Sevimli + Sevimlilər + Nüsxələmə + Data nüsxələmə + Bitwarden Authenticator datası nüsxələndi və müntəzəm planlanmış cihaz nüsxələrinizlə bərpa edilə bilər. + Daha ətraflı + 2FAS parol qorumalı fayllardan daxilə köçürmə dəstəklənmir. Parol qorumalı olmayan xaricə köçürülən bir faylla yenidən sınayın. + Bitwarden CSV fayllarını daxilə köçürmə dəstəklənmir. Xaricə köçürülən JSON faylı ilə yenidən sınayın. + Bitwarden tətbiqini endir + Bütün girişlərinizi saxlayın və doğrulama kodlarını birbaşa Authenticator tətbiqi ilə sinxronlaşdırın. + İndi endir + Bitwarden tətbiqi ilə sinxronlaşdır + Kodlar, Bitwarden tətbiqindən sinxronlaşdırıla bilmir. Hər iki tətbiqin güncəl olduğuna əmin olun. Mövcud kodlarınıza Bitwarden tətbiqində müraciət etməyə davam edə bilərsiniz. + %1$s | %2$s + Bitwarden tətbiqi ilə sinxronlaşdır + Ayarlara get + Bütün doğrulama kodlarınıza burada baxmaq üçün ayarlarda Authenticator tətbiqinin sinxronlaşdırmasına icazə verin. + Nəsə səhv getdi + Lütfən yenidən sınayın + \"Bitwarden\"ə daşı + İlkin saxlama seçimi + \"Bitwarden\"də saxla + Burada saxla + Yoxdur + Yeni doğrulama kodlarını harada saxlamaq istədiyinizi seçin. + Təsdiqlə + Doğrulama kodu yaradıldı + Bu kimlik doğrulayıcı açarı burada saxlayın, ya da onu Bitwarden tətbiqində bir girişə əlavə edin. + Seçimi ilkin olaraq saxla + Hesab, Bitwarden tətbiqindən sinxronlaşdırıldı + Kodu Bitwarden-ə əlavə et + Kodu daxili anbara əlavə et + Daxili anbardakı kodlar + Tələb olunan məlumat əskikdir + "Tələb olunan məlumatlar əskikdir (məs. 'xidmətlər' və ya 'sirr'). Faylınızı yoxlayıb yenidən sınayın. Dəstək üçün bitwarden.com/help ünvanını ziyarət edin" + "Fayl emal oluna bilmədi" + "Fayl emal edilə bilmədi. Bunun yararlı JSON faylı olduğuna əmin olub yenidən sınayın. Kömək lazımdır? bitwarden.com/help ünvanını ziyarət edin" + Kömək al + diff --git a/authenticator/src/main/res/values-be-rBY/strings.xml b/authenticator/src/main/res/values-be-rBY/strings.xml new file mode 100644 index 0000000000..7aa3ecb5e0 --- /dev/null +++ b/authenticator/src/main/res/values-be-rBY/strings.xml @@ -0,0 +1,155 @@ + + + Аўтэнтыфікатар + Біяметрычныя праверка + Скасаваць + Дадаць элемент + Адбылася памылка. + Мы не змаглі апрацаваць ваш запыт. Калі ласка, паспрабуйце яшчэ раз або звяжыцеся з намі. + Патрабуецца доступ да інтэрнэту + Перад тым, як працягнуць, падключыцеся да інтэрнэту. + + Сінхранізацыя + Скапіраваць + Рэдагаваць + Закрыць + Bitwarden Authenticator + Назва + Дадаць паварот элемента + Сканіраваць QR-код + Увядзіце ключ усталёўкі + Сканіраваць QR-код + Навядзіце камеру на QR-код. + Немагчыма адсканіраваць QR-код. + Увесці ключ уручную. + Cannot add authenticator key? + Пасля таго, як ваш ключ паспяхова ўведзены,\nвыберыце \"Дадаць TOTP\" для надзейнага захавання ключа + Дадаць TOTP + Ключ аўтэнтыфікацыі + Не, дзякуй + Налады + Каб выкарыстоўваць сканер дайце дазвол на выкарыстанне камеры + Пусты спіс элементаў + У вас няма элементаў для адлюстравання. + Дадайце новы код для абароны ўліковых запісаў. + Дадаць код + Немагчыма прачытаць ключ. + Дададзены праверачны код + Імя карыстальніка + Перыяд абнаўлення + Алгарытм + Схаваць + Паказаць + Дадаткова + Згарнуць дадатковыя параметры + Колькасць лічбаў + Захаваць + Поле %1$s з\'яўляецца абавязковым. + %d секунд + Захаванне + Элемент абноўлены + Інфармацыя + Тып OTP + Праверачныя коды + Адсутнічаюць элементы, якія адпавядаюць пошуку + Назад + Ачысціць + Пошук кодаў + Параметры + Паспрабуйце зноў + Знешні выгляд + Прадвызначана (сістэмная) + Тэма + Цёмная + Светлая + Мова + Мова была зменена на %1$s. Перазапусціце праграму, каб убачыць змены + Абараніце свае ўліковыя запісы з дапамогай Bitwarden Authenticator + Атрымлівайце праверачныя коды для ўсіх сваіх уліковых запісаў выкарыстоўваючы двухэтапную верыфікацыі. + Выкарыстоўвайце камеру вашай прылады для сканіравання кодаў + Адсканіруйце QR-код у наладах двухэтапнай верыфікацыі для любога ўліковага запісу. + Аўтарызуйцеся з дапамогай унікальных кодаў + Пры выкарыстанні двухэтапнай верыфікацыі вы ўведзяце сваё імя карыстальніка, пароль і код, згенераваны ў гэтай праграме. + Працягнуць + Прапусціць + Пачатак працы + Унікальныя коды + Даведка + Інструкцыя па запуску + %1$s скапіраваны + Выдаліць элемент + Элемент быў выдалены + Выдаліць + Вы сапраўды хочаце выдаліць назаўсёды? Гэтае дзеянне немагчыма адрабіць. + Даныя + Экспарт + Загрузка + Пацвердзіць экспартаванне + Пры экспартаванні файл утрымлівае даныя вашых элементаў у незашыфраваным фармаце. Іх не варта захоўваць або адпраўляць па неабароненых каналах (напрыклад, па электроннай пошце). Выдаліце іх адразу пасля выкарыстання. + Фармат файла + Узнікла праблема падчас экспартавання вашага сховішча. Калі праблема захаваецца, экспартуйце свае даныя з вэб-сховішча. + Даныя паспяхова экспартаваны + Разблакіраваць з %1$s + Біяметрыя + Бяспека + Выкарыстоўваць біяметрычныя даныя для разблакіроўкі + Занадта шмат няўдалых спроб біяметрыі. + Пра + Версія + Працягнуць + Даведачны цэнтр Bitwarden + Працягнуць працу ў Даведачным цэнтры? + Даведайцеся больш аб тым, як выкарыстоўваць Bitwarden Authenticator, у Даведачным цэнтры. + Палітыка прыватнасці + Перайсці да палітыкі прыватнасці? + Азнаёмцеся з нашай палітыкай канфідэнцыяльнасці на сайце bitwarden.com + Ключ + Стварыць праверачны код + Патрабуецца ключ. + Патрабуецца назва. + Адпраўляць справаздачы пра збоі + Узнікла праблема з імпартам вашага сховішча. + Крыніца файла + Імпарт + Сховішча паспяхова імпартавана + Несапраўдны ключ. + Захаваць у абраным + Абранае + Абраныя + Рэзервовая копія + Рэзервовае капіраванне + Даныя Bitwarden Authenticator захоўваюцца ў рэзервовай копіі і могуць быць адноўлены пры рэгулярным рэзервовым капіраванні прылады. + Даведацца больш + Імпарт з файлаў, абароненых паролем 2FAS не падтрымліваецца. Паўтарыце спробу з экспартаваным файлам, які не абаронены паролем. + Імпарт CSV-файлаў Bitwarden не падтрымліваецца. Паўторыце спробу з экспартаваным JSON-файлам. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-bg-rBG/strings.xml b/authenticator/src/main/res/values-bg-rBG/strings.xml new file mode 100644 index 0000000000..d29b5d916c --- /dev/null +++ b/authenticator/src/main/res/values-bg-rBG/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Weryfikacja biometryczna + Cancel + Dodaj element + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Synchronizuję + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Zeskanuj kod QR + Wprowadź klucz konfiguracyjny + Zeskanuj kod QR + Skieruj aparat w stronę kodu QR. + Nie można zeskanować kodu QR. + Enter key manually + Nie możesz dodać klucza uwierzytelniającego? + Po poprawnym wprowadzeniu klucza\nwybierz Dodaj TOTP, aby bezpiecznie przechowywać klucz + Dodaj TOTP + Klucz uwierzytelniający + Nie, dziękuję + Settings + Włącz uprawnienia aparatu do korzystania ze skanera + Pusta lista elementów + Nie masz żadnych elementów do wyświetlenia. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Ukryj + Pokaż + Advanced + Zwiń opcje zaawansowane + Number of digits + Save + The %1$s field is required. + %d sekund + Zapisywanie + Element został zapisany + Informacje + Typ OTP + Verification codes + There are no items that match the search + Powrót + Wyczyść + Szukaj kodów + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Uzyskaj kody weryfikacyjne dla wszystkich swoich kont, które obsługują dwuetapową weryfikację. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + Gdy używasz dwuetapowej weryfikacji, wprowadzisz nazwę użytkownika, hasło oraz kod wygenerowany w tej aplikacji. + Continue + Skip + Get started + Unikalne kody + Help + Launch tutorial + %1$s copied + Usuń element + Item deleted + Delete + Czy na pewno chcesz trwale usunąć? Tej operacji nie można cofnąć. + Data + Export + Ładowanie + Potwierdź eksport + Plik zawiera dane sejfu w niezaszyfrowanym formacie. Nie powinieneś go przechowywać, ani przesyłać poprzez niezabezpieczone kanały (takie jak poczta e-mail). Skasuj go natychmiast po użyciu. + File format + Wystąpił problem z wyeksportowaniem sejfu. Jeśli problem nadal występuje, musisz wyeksportować dane z sejfu internetowego. + Dane wyeksportowane pomyślnie + Unlock with %1$s + Biometria + Security + Use biometrics to unlock + Zbyt wiele nieudanych prób biometrycznych. + About + Version + Continue + Bitwarden Help Center + Kontynuować do Centrum Pomocy? + Dowiedz się więcej o tym, jak korzystać z uwierzytelniania Bitwarden w Centrum Pomocy. + Polityka prywatności + Continue to privacy policy? + Sprawdź naszą politykę prywatności na bitwarden.com + Key + Utwórz kod weryfikacyjny + Klucz jest wymagany. + Nazwa jest wymagana. + Prześlij dzienniki awarii + Podczas importowania sejfu wystąpił problem. + Źródło pliku + Import + Import sejfu zakończony powodzeniem + Klucz jest nieprawidłowy. + Zapisz jako ulubione + Favorite + Favorites + Backup + Kopia zapasowa danych + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importowanie plików CSV w Bitwarden nie jest obsługiwane. Spróbuj ponownie z eksportowanym plikiem JSON. + Download the Bitwarden app + Przechowuj wszystkie swoje logowania i synchronizuj kody weryfikacyjne bezpośrednio z aplikacją Authenticator. + Pobierz teraz + Synchronizacja z aplikacją Bitwarden + Nie można zsynchronizować kodów z aplikacji Bitwarden. Upewnij się, że obie aplikacje są aktualne. Nadal możesz uzyskać dostęp do istniejących kodów w aplikacji Bitwarden. + %1$s | %2$s + Sync with the Bitwarden app + Przejdź do ustawień + Zezwalaj aplikacji Authenticator na synchronizację w ustawieniach aby wyświetlić tutaj wszystkie Twoje kody weryfikacyjne. + Coś poszło nie tak + Proszę spróbować ponownie. + Przenieś do Bitwarden + Domyślna opcja zapisu + Zapisz w Bitwarden + Zapisz tutaj + Żaden + Wybierz, gdzie chcesz zapisać nowe kody weryfikacyjne. + Potwierdź + Kod weryfikacyjny został utworzony + Zapisz ten klucz uwierzytelniający tutaj lub dodaj go do logowania w aplikacji Bitwarden. + Zapisz opcję jako domyślną + Konto zsynchronizowane z aplikacji Bitwarden + Dodaj kod do Bitwarden + Dodaj kod lokalnie + Kody lokalne + Required Information Missing + "Brakuje wymaganych informacji (np. ‘services’ lub ‘secret’). Sprawdź swój plik i spróbuj ponownie. Odwiedź bitwarden.com/help by uzyskać pomoc" + "Plik nie może zostać przetworzony" + "Plik nie może być przetworzony. Upewnij się, że plik JSON jest prawidłowy i spróbuj ponownie. Potrzebujesz pomocy? Odwiedź bitwarden.com/help" + Uzyskaj pomoc + diff --git a/authenticator/src/main/res/values-bn-rBD/strings.xml b/authenticator/src/main/res/values-bn-rBD/strings.xml new file mode 100644 index 0000000000..6fd006df80 --- /dev/null +++ b/authenticator/src/main/res/values-bn-rBD/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + বাতিল করুন + Add item + একটা ভুল ঘটে গেছে। + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + ঠিক আছে + Syncing + Copy + Edit + Close + Bitwarden Authenticator + নাম + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + না, থাক + সেটিংস + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + অ্যালগরিদম + লুকান + দেখান + Advanced + Collapse advanced options + Number of digits + সংরক্ষণ করুন + The %1$s field is required. + %d সেকেন্ড + সংরক্ষণ করা হচ্ছে + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + বিকল্পসমূহ + আবার চেষ্টা করুন + অবয়ব + Default (System) + থিম + গাঢ় + হালকা + ভাষা + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + চালিয়ে যান + এড়িয়ে যান + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + নিরাপত্তা + Use biometrics to unlock + Too many failed biometrics attempts. + সম্পর্কে + সংস্করণ + চালিয়ে যান + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + আরও জানুন + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-bs-rBA/strings.xml b/authenticator/src/main/res/values-bs-rBA/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-bs-rBA/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-ca-rES/strings.xml b/authenticator/src/main/res/values-ca-rES/strings.xml new file mode 100644 index 0000000000..096d054dce --- /dev/null +++ b/authenticator/src/main/res/values-ca-rES/strings.xml @@ -0,0 +1,155 @@ + + + Autenticador + Verificació biomètrica + Cancel·la + Afig element + S\'ha produït un error. + No hem pogut processar la vostra sol·licitud. Torneu-ho a provar o contacteu amb nosaltres. + Es requereix connexió a Internet + Connecteu-vos a Internet abans de continuar. + D\'acord + Sincronitzant + Copia + Edita + Tanca + Autenticador Bitwarden + Nom + Afig rotació d\'elements + Escaneja un codi QR + Introduïu una clau de configuració + Escaneja codi QR + Dirigiu la càmera al codi QR. + No es pot escanejar el codi QR. + Introduïu la clau manualment. + No es pot afegir la clau d\'autenticació? + Una vegada introduïda la clau correctament,\nseleccioneu Afig TOTP per emmagatzemar la clau de manera segura + Afig TOTP + Clau autenticadora + No, gràcies + Configuració + Habilita el permís de la càmera per utilitzar l\'escàner + Llista d\'articles buits + No teniu cap element per mostrar. + Afig un codi nou per protegir els comptes. + Afig codi + No es pot llegir la clau. + S\'ha afegit el codi de verificació + Nom d\'usuari + Període d\'actualització + Algoritme + Amaga + Mostra + Avançat + Redueix les opcions avançades + Nombre de dígits + Guarda + El camp %1$s és obligatori. + %d segons + S\'està guardant + Element guardat + Informació + Tipus OTP + Codis de verificació + No hi ha elements que coincidisquen amb la cerca + Arrere + Esborra + Cerca codis + Opcions + Torneu-ho a provar + Aparença + Per defecte (Sistema) + Tema + Fosc + Clar + Idioma + L\'idioma s\'ha canviat a %1$s. Reinicieu l\'aplicació per veure el canvi + Protegiu els vostres comptes amb l\'autenticador de Bitwarden + Obteniu codis de verificació per a tots els vostres comptes que admeten la verificació en dos passos. + Utilitzeu la càmera del dispositiu per escanejar codis + Escaneja el codi QR a la configuració de verificació en dos passos per a qualsevol compte. + Inicieu la sessió amb codis únics + Quan utilitzeu la verificació en dos passos, introduïu el vostre nom d\'usuari i contrasenya i un codi generat en aquesta aplicació. + Continua + Omet + Comencem + Codis únics + Ajuda + Inicia el tutorial + S\'ha copiat \'%1$s\' + Suprimeix element + Element suprimit + Suprimeix + Esteu segur que voleu suprimir-ho definitivament? Això no es pot desfer. + Dades + Exporta + S\'està carregant + Confirma l\'exportació + Aquesta exportació conté les vostres dades en un format sense xifrar. No hauríeu d\'emmagatzemar ni enviar el fitxer exportat per canals no segurs (com ara el correu electrònic). Suprimiu-lo immediatament després d\'haver-lo acabat d\'utilitzar. + Format de fitxer + S\'ha produït un problema en exportar la caixa forta. Si el problema continua, haureu d’exportar-la des del web. + Les dades s\'han exportat correctament + Desbloqueja amb %1$s + Dades biomètriques + Seguretat + Utilitzeu dades biomètriques per desbloquejar + Massa intents de biometria fallits. + Quant a + Versió + Continua + Centre d\'ajuda de Bitwarden + Voleu continuar cap al Centre d\'ajuda? + Obteniu més informació sobre com utilitzar l\'autenticador Bitwarden al Centre d\'ajuda. + Política de privadesa + Voleu continuar amb la política de privadesa? + Consulteu la nostra política de privadesa a bitwarden.com + Clau + Crea un codi de verificació + La clau és obligatòria. + El nom és obligatori. + Envieu registres d\'error + S\'ha produït un problema en importar la vostra caixa forta. + Origen del fitxer + Importa + S\'ha importat la caixa forta correctament + La clau no és vàlida. + Guarda com a preferit + Preferit + Preferits + Còpia de seguretat + Còpia de seguretat de dades + Es fa una còpia de seguretat de les dades de l\'autenticador de Bitwarden i es poden restaurar amb les còpies de seguretat del dispositiu programades periòdicament. + Més informació + No s\'admet la importació de fitxers protegits amb contrasenya 2FAS. Torna-ho a provar amb un fitxer exportat que no estiga protegit amb contrasenya. + No s\'admet la importació de fitxers CSV de Bitwarden. Torna-ho a provar amb un fitxer JSON exportat. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Descarregueu ara + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Vés a la configuració + Allow Authenticator app syncing in settings to view all of your verification codes here. + Alguna cosa no ha anat bé + Torna-ho a provar + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + Cap + Select where you would like to save new verification codes. + Confirma-ho + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Obteniu ajuda + diff --git a/authenticator/src/main/res/values-cs-rCZ/strings.xml b/authenticator/src/main/res/values-cs-rCZ/strings.xml new file mode 100644 index 0000000000..3e406b3b3e --- /dev/null +++ b/authenticator/src/main/res/values-cs-rCZ/strings.xml @@ -0,0 +1,155 @@ + + + Ověřovací aplikace + Biometrické ověření + Zrušit + Přidat položku + Vyskytla se chyba. + Nepodařilo se nám zpracovat Váš požadavek. Zkuste to znovu nebo nás kontaktujte. + Je vyžadováno připojení k internetu + Před pokračováním se připojte k internetu. + OK + Synchronizování + Kopírovat + Upravit + Zavřít + Autentikátor Bitwarden + Název + Přidat rotaci položky + Naskenovat QR kód + Zadat nastavovací klíč + Naskenovat QR kód + Umístěte kameru na QR kód. + Nelze naskenovat QR kód. + Zadejte klíč ručně. + Nelze přidat ověřovací klíč? + Jakmile je klíč úspěšně zadán,\nzvolte \"Přidat TOTP\" pro bezpečné uložení klíče + Přidat TOTP + Ověřovací klíč + Ne, děkuji + Nastavení + Pro použití skeneru musíte povolit přístup ke kameře + Prázdný seznam položek + Nemáte žádné položky k zobrazení. + Přidejte nový kód pro zabezpečení Vašich účtů. + Přidat kód + Nelze přečíst klíč. + Ověřovací kódy byly přidány + Uživatelské jméno + Doba obnovení + Algoritmus + Skrýt + Zobrazit + Pokročilé + Sbalit pokročilé volby + Počet číslic + Uložit + Pole %1$s je vyžadováno. + %d sekund + Ukládání + Položka byla uložena + Informace + Typ OTP + Ověřovací kódy + Neexistují žádné položky, které by odpovídaly hledání + Zpět + Vymazat + Prohledat kódy + Volby + Zkusit znovu + Vzhled + Výchozí (Systémový) + Motiv + Tmavý + Světlý + Jazyk + Jazyk byl změněn na %1$s. Pro zobrazení změn restartujte aplikaci. + Zabezpečte své účty pomocí Autentikátoru Bitwarden + Získejte ověřovací kódy pro všechny své účty, které podporují dvoufázové ověření. + Použijte kameru zařízení ke skenování kódů + Naskenujte QR kód v nastavení dvoufázového ověření pro jakýkoli účet. + Přihlašujte se pomocí jedinečných kódů + Používáte-li dvoufázové ověření, zadejte své uživatelské jméno a heslo a kód vygenerovaný v této aplikaci. + Pokračovat + Přeskočit + Začínáme + Unikátní kódy + Nápověda + Spustit tutoriál + %1$s zkopírována + Smazat položku + Položka byla smazána + Smazat + Opravdu chcete trvale tuto položku smazat? Akce je nevratná. + Data + Exportovat + Načítání + Potvrdit export + Tento export obsahuje Vaše data v nezašifrovaném formátu. Soubor exportu byste neměli ukládat ani odesílat přes nezabezpečené kanály (např. e-mailem). Smažte jej okamžitě po jeho použití. + Formát souboru + Při exportu Vašeho trezoru došlo k chybě. Pokud problém přetrvává, proveďte export z webového trezoru. + Export dat proběhl úspěšně + Odemknout pomocí %1$s + Biometrie + Zabezpečení + Použít biometrii k odemknutí + Příliš mnoho neúspěšných pokusů o biometrii. + O aplikaci + Verze + Pokračovat + Centrum nápovědy Bitwardenu + Pokračovat do Centra nápovědy? + Přečtěte si více o tom, jak používat Autentikátor Bitwarden v Centru nápovědy. + Zásady ochrany osobních údajů + Pokračovat na Zásady ochrany osobních údajů? + Podívejte se na naše Zásady ochrany osobních údajů na bitwarden.com + Klíč + Vytvořit ověřovací kód + Je vyžadován klíč. + Název je povinný. + Odesílat záznamy o pádech + Při importu Vašeho trezoru nastal problém. + Zdrojový soubor + Importovat + Import trezoru byl úspěšný + Klíč je neplatný. + Uložit jako oblíbené + Oblíbené + Oblíbené + Záloha + Zálohování dat + Data Autentikátoru Bitwarden jsou zálohována a mohou být obnovena z pravidelných záloh Vašeho zařízení. + Dozvědět se více + Importování ze souborů chráněných hesly 2FAS není podporován. Zkuste to znovu s exportovaným souborem, který není chráněn heslem. + Importování CSV souborů Bitwardenu není podporováno. Zkuste to znovu s exportovaným souborem JSON. + Stáhnout aplikaci Bitwarden + Uložte všechny Vaše přihlašovací údaje a synchronizujte ověřovací kódy přímo s ověřovací aplikací. + Stáhnout nyní + Synchronizovat s aplikací Bitwarden + Nelze synchronizovat kódy z aplikace Bitwarden. Zkontrolujte, zda jsou obě aplikace aktuální. Ke stávajícím kódům máte stále přístup v aplikaci Bitwarden. + %1$s | %2$s + Synchronizovat s aplikací Bitwarden + Přejít do nastavení + V nastavení povolte synchronizaci aplikace Authenticator a zobrazte zde všechny své ověřovací kódy. + Něco se pokazilo + Zkuste to znovu + Přesunout do Bitwardenu + Výchozí volby ukládání + Uložit do Bitwardenu + Uložit zde + Žádné + Vyberte, kde chcete uložit nové ověřovací kódy. + Potvrdit + Ověřovací kód byl vytvořen + Uložte tento ověřovací klíč zde nebo ho přidejte do Vaší aplikace Bitwarden. + Uložit volbu jako výchozí + Účet je synchronizován s aplikací Bitwarden + Přidat kód do Bitwardenu + Přidat kód místně + Místní kódy + Chybí požadované informace + "Chybí požadované informace (např. \"služby\" nebo \"tajné\"). Zkontrolujte soubor a zkuste to znovu. Navštivte bitwarden.com/help pro pomoc." + "Soubor nelze zpracovat" + "Soubor nelze zpracovat. Ujistěte se, že je to platný JSON a zkuste to znovu. Potřebujete pomoc? Navštivte bitwarden.com/help" + Získat pomoc + diff --git a/authenticator/src/main/res/values-cy-rGB/strings.xml b/authenticator/src/main/res/values-cy-rGB/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-cy-rGB/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-da-rDK/strings.xml b/authenticator/src/main/res/values-da-rDK/strings.xml new file mode 100644 index 0000000000..a3bcc0f727 --- /dev/null +++ b/authenticator/src/main/res/values-da-rDK/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometrisk godkendelse + Afbryd + Tilføj emne + En fejl er opstået. + Anmodningen kunne ikke behandles. Forsøg igen eller kontakt os. + Internetforbindelse kræves + Opret forbindelse til internet for at fortsætte. + OK + Synkning + Kopiér + Redigér + Luk + Bitwarden Authenticator + Navn + Tilføj Emnerotation + Skan en QR-kode + Angiv en opsætningsnøgle + Skan QR-kode + Ret kameraet mod QR-koden. + QR-kode kan ikke skannes. + Angiv nøgle manuelt + Kan ikke tilføje godkendelsesnøgle? + Når nøglen er angivet,\nvælg Tilføj TOTP for at gemme nøglen sikkert + Tilføj TOTP + Godkendelsesnøgle + Nej tak + Indstillinger + Tildel kameratilladelse for brug af skanneren + Tom Emneliste + Der er ingen emner at vise. + Tilføj en ny kode for at sikre kontiene. + Tilføj kode + Nøgle kan ikke læses. + Bekræftelseskode er tilføjet + Brugernavn + Opdateringsperiode + Algoritme + Skjul + Vis + Avanceret + Sammenfold avancerede indstillinger + Antal cifre + Gem + Feltet %1$s er obligatorisk. + %d sekunder + Gemmer + Emne gemt + Information + OTP-type + Bekræftelseskoder + Ingen emner matcher søgningen + Retur + Ryd + Søg kode + Indstillinger + Forsøg igen + Udseende + Standard (system) + Tema + Mørk + Lys + Sprog + Sproget er skiftet til %1$s. Genstart appen for at effektuere ændringen + Sikre kontiene med Bitwarden Authenticator + Få bekræftelseskoder for alle kontiene, som understøtter totrinsbekræftelse. + Brug enhedens kamera til skanning af koder + Skan QR-koden i indstillingen for totrinsbekræftelse for enhver konto. + Log ind ved vha. unikke koder + Ved brug af totrinsbekræftelse angiver man sit brugernavn og adgangskode samt en kode genereret i denne app. + Fortsæt + Overspring + Kom i gang + Unikke koder + Hjælp + Start tutorial + %1$s kopieret + Slet emne + Emne slettet + Slet + Sikker på, at denne chiffer skal slettes permanent? Dette kan ikke fortrydes. + Data + Eksport + Indlæser + Bekræft eksport + Denne eksport indeholder boksdataene i ukrypteret form. Den eksporterede fil bør derfor ikke gemmes eller sendes via usikre kanaler (f.eks. e-mail). Slet den så snart, at den ikke længere skal bruges. + Filformat + Et problem opstod under eksporten af boksen. Fortsætter problemet, eksportér i stedet fra web-boksen. + Data er eksporteret + Oplås med %1$s + Biometri + Sikkerhed + Brug biometri til oplåsning + For mange mislykkede biometriforsøg. + Om + Version + Fortsæt + Bitwarden Hjælpecenter + Fortsæt til Hjælpecenter? + Læs mere i Hjælpecenter om, hvordan Bitwarden bruges. + Fortrolighedspolitik + Fortsæt til Fortrolighedspolitik? + Tjek vores Fortrolighedspolitik på bitwarden.com + Nøgle + Opret Bekræftelseskode + Nøgle er obligatorisk. + Navn er obligatorisk. + Indsend nedbrudslogger + Et problem opstod under import af boksen. + Filkilde + Import + Boks er importeret + Nøgle er ugyldig. + Gem som en favorit + Favorit + Favoritter + Sikkerhedskopi + Datasikkerhedskopiering + Bitwarden Authenticator-data sikkerhedskopieres og kan gendannes via de planlagte, regelmæssige enhedssikkerhedskopieringer. + Læs mere + Import fra 2FAS-adgangskodebeskyttede filer understøttes ikke. Prøv igen med en eksporteret fil uden adgangskodebeskyttelse. + Import af Bitwarden CSV-filer understøttes ikke. Prøv igen med en eksporteret JSON-fil. + Download Bitwarden-appen + Gem alle logins og synk bekræftelseskoder direkte med Authenticator-appen. + Download nu + Synk med Bitwarden-appen + Kan ikke synke koder fra Bitwarden-appen. Sørg for, at begge apps er opdaterede. Eksisterende koder kan stadig tilgås i Bitwarden-appen. + %1$s | %2$s + Synk med Bitwarden-appen + Gå til Indstillinger + Tillad Authenticator-app synkning i Indstillinger for at se alle bekræftelseskoderne her. + Noget gik galt + Forsøg igen + Move to Bitwarden + Standard gem-valg + Gem i Bitwarden + Gem her + Intet + Vælg, hvor nye bekræftelseskoder ønskes gemt. + Bekræft + Bekræftelseskode oprettet + Gem denne Authenticator-nøgle her, eller føj den til et login i Bitwarden-appen. + Gem valg som standard + Konto synket fra Bitwarden-appen + Føj kode til Bitwarden + Tilføj kode lokalt + Lokale koder + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-de-rDE/strings.xml b/authenticator/src/main/res/values-de-rDE/strings.xml new file mode 100644 index 0000000000..10a14814a5 --- /dev/null +++ b/authenticator/src/main/res/values-de-rDE/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometrische Verifizierung + Abbrechen + Eintrag hinzufügen + Es ist ein Fehler aufgetreten. + Wir konnten deine Anfrage nicht bearbeiten. Bitte versuche es erneut oder kontaktiere uns. + Internetverbindung erforderlich + Bitte verbinde dich mit dem Internet, um fortzufahren. + OK + Wird synchronisiert + Kopieren + Bearbeiten + Schließen + Bitwarden Authenticator + Name + Eintrags-Rotation hinzufügen + Einen QR-Code scannen + Gib einen Einrichtungs-Schlüssel ein + QR-Code scannen + Richte deine Kamera auf den QR-Code. + QR-Code kann nicht gescannt werden. + Gib den Schlüssel manuell ein. + Authentifizierungsschlüssel lässt sich nicht hinzufügen? + Sobald der Schlüssel erfolgreich eingegeben wurde,\nwähle TOTP hinzufügen aus, um den Schlüssel sicher abzuspeichern + TOTP hinzufügen + Authenticator-Schlüssel + Nein, danke + Einstellungen + Kamerazugriff gewähren, um den Scanner zu verwenden + Eintrags-Liste leeren + Keine Einträge zum Anzeigen vorhanden. + Füge einen neuen Code hinzu, um deine Konten abzusichern. + Code hinzufügen + Kann den Schlüssel nicht lesen. + Verifizierungscode hinzugefügt + Benutzername + Aktualisierungszeitraum + Algorithmus + Verbergen + Anzeigen + Erweitert + Erweiterte Optionen einklappen + Anzahl der Ziffern + Speichern + Das Feld %1$s ist ein Pflichtfeld. + %d Sekunden + Wird gespeichert + Eintrag gespeichert + Informationen + OTP-Typ + Verifizierungscodes + Es gibt keine Einträge, die mit der Suche übereinstimmen + Zurück + Leeren + Codes durchsuchen + Optionen + Erneut versuchen + Erscheinungsbild + Standard (System) + Design + Dunkel + Hell + Sprache + Die Sprache wurde auf %1$s geändert. Bitte starte die App neu, um die Änderung zu sehen + Schütze deine Konten mit dem Bitwarden Authenticator + Erhalte Verifizierungscodes für alle deine Konten, die die 2-Faktor-Authentifizierung unterstützen. + Verwende die Kamera deines Geräts, um Codes zu scannen + Scanne den QR-Code für jedes Konto in deinen Einstellungen für die 2-Faktor-Authentifizierung. + Mit eindeutigen Codes anmelden + Bei der 2-Faktor-Authentifizierung gibst du deinen Benutzernamen und dein Passwort sowie einen in dieser App generierten Code ein. + Fortsetzen + Überspringen + Loslegen + Eindeutige Codes + Hilfe + Tutorial starten + %1$s kopiert + Eintrag löschen + Eintrag gelöscht + Löschen + Wirklich dauerhaft löschen? Dieser Vorgang kann nicht rückgängig gemacht werden. + Daten + Export + Wird geladen + Export bestätigen + Dieser Export enthält deine Eintrags-Daten in einem unverschlüsselten Format. Du solltest die exportierte Datei daher nicht über unsichere Kanäle (z. B. E-Mail) speichern oder versenden. Lösche die Datei sofort nach Ihrer Verwendung. + Dateiformat + Beim Exportieren deines Tresors ist ein Problem aufgetreten. Wenn das Problem weiterhin besteht, musst du aus dem Web-Tresor exportieren. + Daten erfolgreich exportiert + Entsperren mit %1$s + Biometrie + Sicherheit + Biometrie zum Entsperren verwenden + Zu viele fehlgeschlagene biometrische Versuche. + Über + Version + Fortsetzen + Bitwarden-Hilfezentrum + Weiter zum Hilfezentrum? + Erfahre mehr über die Verwendung des Bitwarden Authenticators im Hilfezentrum. + Datenschutzbestimmungen + Weiter zu den Datenschutzbestimmungen? + Unsere Datenschutzbestimmungen auf bitwarden.com anschauen + Schlüssel + Verifizierungscode erstellen + Schlüssel ist erforderlich. + Name ist erforderlich. + Absturzprotokolle senden + Beim Importieren deines Tresors ist ein Fehler aufgetreten. + Dateiquelle + Import + Tresor-Import erfolgreich + Schlüssel ist ungültig. + Als Favorit speichern + Favorit + Favoriten + Sicherung + Datensicherung + Bitwarden Authenticator Daten werden gesichert und können mit deinen regelmäßig geplanten Geräte-Sicherungen wiederhergestellt werden. + Mehr erfahren + Der Import aus passwortgeschützten 2FAS-Dateien wird nicht unterstützt. Versuche es erneut mit einer exportierten Datei, die nicht passwortgeschützt ist. + Der Import von Bitwarden CSV-Dateien wird nicht unterstützt. Versuche es erneut mit einer exportierten JSON-Datei. + Bitwarden-App herunterladen + Speicher alle deine Zugangsdaten und synchronisiere Verifizierungscodes direkt mit der Authenticator-App. + Jetzt herunterladen + Mit der Bitwarden-App synchronisieren + Codes aus der Bitwarden-App konnten nicht synchronisiert werden. Stelle sicher, dass beide Apps auf dem neusten Stand sind. Du kannst weiterhin auf deine existierenden Codes in der Bitwarden-App zugreifen. + %1$s | %2$s + Mit der Bitwarden-App synchronisieren + Gehe zu den Einstellungen + Lasse die Authenticator-App-Synchronisierung in den Einstellungen zu, um hier alle deine Verifizierungscodes anzuzeigen. + Etwas ist schiefgelaufen + Bitte versuche es erneut + Zu Bitwarden verschieben + Standard-Speicheroption + In Bitwarden speichern + Hier speichern + Keine + Wähle aus, wo du neue Verifizierungscodes speichern möchtest. + Bestätigen + Verifzierungscode erstellt + Speichere diesen Authentifizierungsschlüssel hier oder füge ihn zu Zugangsdaten in deiner Bitwarden App hinzu. + Option als Standard speichern + Konto über Bitwarden App synchronisiert + Code zu Bitwarden hinzufügen + Code lokal hinzufügen + Lokale Codes + Erforderliche Informationen fehlen + "Benötigte Informationen fehlen (z.B. „Dienste“ oder „Geheimnis“). Überprüfe deine Datei und versuche es erneut. Besuche bitwarden.com/help für Unterstützung" + "Datei konnte nicht verarbeitet werden" + "Datei konnte nicht verarbeitet werden. Stelle sicher, dass es sich um ein gültiges JSON handelt und versuche es erneut. Benötigst du Hilfe? Besuche bitwarden.com/help" + Hilfe erhalten + diff --git a/authenticator/src/main/res/values-el-rGR/strings.xml b/authenticator/src/main/res/values-el-rGR/strings.xml new file mode 100644 index 0000000000..d93ebc432c --- /dev/null +++ b/authenticator/src/main/res/values-el-rGR/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Βιομετρική επαλήθευση + Ακύρωση + Προσθήκη στοιχείου + Παρουσιάστηκε σφάλμα. + Δεν μπορέσαμε να επεξεργαστούμε το αίτημά σας. Παρακαλούμε δοκιμάστε ξανά ή επικοινωνήστε μαζί μας. + Απαιτείται σύνδεση στο διαδίκτυο + Συνδεθείτε στο διαδίκτυο πριν συνεχίσετε. + OK + Συγχρονισμός + Αντιγραφή + Επεξεργασία + Κλείσιμο + Bitwarden Authenticator + Όνομα + Προσθήκη περιστροφής στοιχείου + Σαρώστε έναν κωδικό QR + Εισαγάγετε ένα κλειδί ρύθμισης + Σάρωση κωδικού QR + Στρέψτε την κάμερά σας στον κωδικό QR. + Δεν είναι δυνατή η σάρωση του κωδικού QR. + Εισαγάγετε το κλειδί χειροκίνητα. + Cannot add authenticator key? + Μόλις γίνει επιτυχής εισαγωγή του κλειδιού,\nεπιλέξτε «Προσθήκη TOTP» για να το αποθηκεύσετε με ασφάλεια + Προσθήκη TOTP + Κλειδί επαλήθευσης + Όχι, ευχαριστώ + Ρυθμίσεις + Ενεργοποιήστε την άδεια πρόσβασης στην κάμερα για χρήση του σαρωτή + Κενή καταχώρηση στοιχείου + Δεν έχετε κανένα στοιχείο προς εμφάνιση. + Προσθέστε έναν νέο κωδικό για να ασφαλίσετε τους λογαριασμούς σας. + Προσθήκη κωδικού + Δεν είναι δυνατή η ανάγνωση του κλειδιού. + Προστέθηκε κωδικός επαλήθευσης + Όνομα χρήστη + Περίοδος ανανέωσης + Αλγόριθμος + Απόκρυψη + Εμφάνιση + Σύνθετα + Σύμπτυξη σύνθετων επιλογών + Αριθμός ψηφίων + Αποθήκευση + Το πεδίο «%1$s» είναι υποχρεωτικό. + %d δευτερόλεπτα + Αποθήκευση + Το στοιχείο αποθηκεύτηκε + Πληροφορίες + Τύπος OTP + Κωδικοί επαλήθευσης + Δεν υπάρχουν στοιχεία που να ταιριάζουν με την αναζήτηση + Πίσω + Απαλοιφή + Αναζήτηση κωδικών + Επιλογές + Δοκιμή ξανά + Εμφάνιση + Προεπιλογή (σύστημα) + Θέμα + Σκουρόχρωμο + Ανοιχτόχρωμο + Γλώσσα + Η γλώσσα έχει αλλάξει σε %1$s. Κάντε επανεκκίνηση της εφαρμογής για να δείτε την αλλαγή + Ασφαλίστε τους λογαριασμούς σας με το Bitwarden Authenticator + Λάβετε κωδικούς επαλήθευσης για όλους τους λογαριασμούς σας με υποστήριξη επαλήθευσης 2 βημάτων. + Χρησιμοποιήστε την κάμερα της συσκευής σας για τη σάρωση κωδικών + Σαρώστε τον κωδικό QR στις ρυθμίσεις επαλήθευσης 2 βημάτων για οποιονδήποτε λογαριασμό. + Συνδεθείτε με μοναδικούς κωδικούς + Κατά τη χρήση της επαλήθευσης 2 βημάτων, θα εισάγετε το όνομα χρήστη και τον κωδικό πρόσβασής σας, μαζί με έναν κωδικό που θα δημιουργείται σε αυτήν την εφαρμογή. + Συνέχεια + Παράλειψη + Έναρξη + Μοναδικοί κωδικοί + Βοήθεια + Εκκίνηση οδηγού + Το «%1$s» αντιγράφηκε + Διαγραφή στοιχείου + Το στοιχείο διαγράφηκε + Διαγραφή + Θέλετε σίγουρα να κάνετε οριστική διαγραφή; Δεν είναι δυνατή η αναίρεση αυτής της ενέργειας. + Δεδομένα + Εξαγωγή + Φόρτωση + Επιβεβαίωση εξαγωγής + Αυτή η εξαγωγή περιέχει τα δεδομένα σας σε αποκρυπτογραφημένη μορφή. Δεν πρέπει να αποθηκεύσετε ή να στείλετε το εξαχθέν αρχείο μέσω μη ασφαλών τρόπων (όπως μέσω ηλ. ταχυδρομείου). Διαγράψτε το αμέσως μόλις τελειώσετε με τη χρήση του. + Μορφή αρχείου + Παρουσιάστηκε πρόβλημα κατά την εξαγωγή της κρύπτης σας. Εάν το πρόβλημα επιμένει, θα χρειαστεί να κάνετε εξαγωγή από τη διαδικτυακή κρύπτη. + Επιτυχής εξαγωγή δεδομένων + Ξεκλείδωμα με %1$s + Βιομετρικά στοιχεία + Ασφάλεια + Χρήση βιομετρικών στοιχείων για ξεκλείδωμα + Πάρα πολλές αποτυχημένες απόπειρες με βιομετρικά στοιχεία. + Πληροφορίες + Έκδοση + Συνέχεια + Κέντρο βοήθειας Bitwarden + Συνέχεια στο Κέντρο βοήθειας; + Μάθετε περισσότερα για τον τρόπο χρήσης του Bitwarden Authenticator στο Κέντρο βοήθειας. + Πολιτική απορρήτου + Συνέχεια στην πολιτική απορρήτου; + Δείτε την πολιτική απορρήτου μας στο bitwarden.com + Κλειδί + Δημιουργία κωδικού επαλήθευσης + Το κλειδί είναι υποχρεωτικό. + Το όνομα είναι υποχρεωτικό. + Υποβολή αρχείου καταγραφής σφαλμάτων + Παρουσιάστηκε πρόβλημα κατά την εισαγωγή της κρύπτης σας. + Προέλευση αρχείου + Εισαγωγή + Επιτυχής εισαγωγή κρύπτης + Το κλειδί δεν είναι έγκυρο. + Αποθήκευση ως αγαπημένο + Αγαπημένο + Αγαπημένα + Αντίγραφο ασφαλείας + Αντίγραφο δεδομένων + Τα δεδομένα του Bitwarden Authenticator αποθηκεύονται σε ένα αντίγραφο ασφαλείας και μπορούν να ανακτηθούν με την προγραμματισμένη δημιουργία εφεδρικών αντιγράφων της συσκευής σας. + Μάθετε περισσότερα + Η εισαγωγή από αρχεία του 2FAS με κωδικό πρόσβασης δεν υποστηρίζεται. Δοκιμάστε ξανά με ένα αρχείο που δεν προστατεύεται με κωδικό πρόσβασης. + Η εισαγωγή αρχείων CSV του Bitwarden δεν υποστηρίζεται. Δοκιμάστε ξανά με ένα εξαχθέν αρχείο JSON. + Λήψη της εφαρμογής Bitwarden + Αποθηκεύστε όλες τις συνδέσεις σας και συγχρονίστε απευθείας τους κωδικούς επαλήθευσης με το Authenticator. + Λήψη τώρα + Συγχρονισμός με την εφαρμογή Bitwarden + Δεν είναι δυνατός ο συγχρονισμός κωδικών από το Bitwarden. Βεβαιωθείτε ότι έχουν ενημερωθεί και οι δύο εφαρμογές. Μπορείτε ακόμα να αποκτήσετε πρόσβαση στους υπάρχοντες κωδικούς σας στο Bitwarden. + %1$s | %2$s + Συγχρονισμός με την εφαρμογή Bitwarden + Μετάβαση στις ρυθμίσεις + Επιτρέψτε τον συγχρονισμό του Authenticator στις ρυθμίσεις για να βλέπετε όλους τους κωδικούς επαλήθευσής σας εδώ. + Κάτι πήγε στραβά + Δοκιμάστε ξανά + Move to Bitwarden + Προεπιλεγμένη επιλογή αποθήκευσης + Αποθήκευση στο Bitwarden + Αποθήκευση εδώ + Κανένα + Επιλέξτε πού θα θέλατε να αποθηκεύονται οι νέοι κωδικοί επαλήθευσης. + Επιβεβαίωση + Δημιουργήθηκε κωδικός επαλήθευσης + Αποθηκεύστε αυτό το κλειδί ελέγχου ταυτότητας εδώ ή προσθέστε το σε μια σύνδεση στο Bitwarden. + Αποθήκευση ως προεπιλογή + Ο λογαριασμός συγχρονίστηκε από το Bitwarden + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-en-rGB/strings.xml b/authenticator/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000000..ff35fd2e47 --- /dev/null +++ b/authenticator/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually. + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Centre + Continue to Help Centre? + Learn more about how to use Bitwarden Authenticator on the Help Centre. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favourite + Favourite + Favourites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it is valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-en-rIN/strings.xml b/authenticator/src/main/res/values-en-rIN/strings.xml new file mode 100644 index 0000000000..ff35fd2e47 --- /dev/null +++ b/authenticator/src/main/res/values-en-rIN/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually. + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Centre + Continue to Help Centre? + Learn more about how to use Bitwarden Authenticator on the Help Centre. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favourite + Favourite + Favourites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it is valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-es-rES/strings.xml b/authenticator/src/main/res/values-es-rES/strings.xml new file mode 100644 index 0000000000..6fe4a57b25 --- /dev/null +++ b/authenticator/src/main/res/values-es-rES/strings.xml @@ -0,0 +1,155 @@ + + + Autenticador + Verificación biométrica + Cancelar + Añadir elemento + Ha ocurrido un error. + No hemos podido procesar tu solicitud. Por favor, inténtalo de nuevo o contacta con nosotros. + Conexión a internet requerida + Por favor conéctate a Internet antes de continuar. + Confirmar + Sincronizando + Copiar + Editar + Cerrar + Autenticador de Bitwarden + Nombre + Añadir rotación de elemento + Escanear un código QR + Introduce una clave de configuración + Escanear código QR + Apunta tu cámara al código QR. + No puede escanear el código QR. + Introducir clave manualmente. + Cannot add authenticator key? + Una vez que la clave se ha sido introducida correctamente,\nselecciona \"Añadir TOTP\" para almacenar la clave de forma segura + Añadir TOTP + Clave de autenticador + No gracias + Ajustes + Habilita el permiso de cámara para utilizar el escáner + Listado de elementos vacío + No tienes ningún elemento para mostrar. + Añade un nuevo código para proteger tus cuentas. + Añadir código + No se puede leer la clave. + Código de verificación añadido + Nombre de usuario + Período de actualización + Algoritmo + Ocultar + Mostrar + Avanzado + Colapsar opciones avanzadas + Número de dígitos + Guardar + Se requiere el campo %1$s. + %d segundos + Guardando + Elemento guardado + Información + Tipo OTP + Códigos de verificación + No hay elementos que coincidan con la búsqueda + Atrás + Limpiar + Códigos de búsqueda + Opciones + Reinténtalo + Apariencia + Por defecto (Sistema) + Tema + Oscuro + Claro + Idioma + El idioma ha sido cambiado a %1$s. Por favor, reinicia la aplicación para ver los cambios + Protege tus cuentas con Bitwarden Authenticator + Obtén códigos de verificación para todas tus cuentas que soportan verificación en 2 pasos. + Utiliza la cámara de tu dispositivo para escanear códigos + Escanea el código QR en los ajustes de verificación en 2 pasos de cualquier cuenta. + Inicia sesión utilizando códigos únicos + Cuando utilices la verificación en 2 pasos, introducirás tu nombre de usuario y contraseña y un código generado en esta aplicación. + Continuar + Omitir + Comenzar + Códigos únicos + Ayuda + Iniciar tutorial + %1$s copiado + Eliminar elemento + Elemento eliminado + Eliminar + ¿Seguro que quieres eliminarlo permanentemente? Esto no se puede deshacer. + Datos + Exportar + Cargando + Confirmar exportación + Esta exportación contiene tus datos en un formato sin cifrar. No debes almacenar ni enviar el archivo exportado a través de canales no seguros (como el correo electrónico). Elimínalo inmediatamente después de que hayas terminado de usarlo. + Formato del archivo + Hubo un problema al exportar su caja fuerte. Si el problema persiste, deberás exportar desde la caja fuerte web. + Datos exportados correctamente + Desbloquear con %1$s + Datos biométricos + Seguridad + Usar datos biométricos para desbloquear + Demasiados intentos fallidos de biometría. + Acerca de + Versión + Continuar + Centro de ayuda de Bitwarden + ¿Continuar al Centro de Ayuda? + Obtén más información sobre cómo usar Bitwarden Authenticator en el Centro de ayuda. + Política de privacidad + ¿Continuar a la política de privacidad? + Echa un vistazo a nuestra política de privacidad en bitwarden.com + Clave + Crear código de verificación + Se requiere clave. + Se requiere nombre. + Enviar registros de fallos + Hubo un problema al importar tu caja fuerte. + Fuente de archivo + Importar + Importación de la caja fuerte exitosa + La clave no es válida. + Guardar como favorito + Favorito + Favoritos + Copia de seguridad + Copia de seguridad de los datos + Se ha hecho una copia de seguridad de los datos de Bitwarden Authenticator y se pueden restaurar con las copias de seguridad de tu dispositivo programadas regularmente. + Más información + No se admite la importación desde archivos protegidos con contraseña 2FAS. Inténtalo de nuevo con un archivo exportado que no esté protegido con contraseña. + No se admite la importación de archivos CSV de Bitwarden. Inténtalo de nuevo con un archivo JSON exportado. + Descarga la aplicación Bitwarden + Store all of your logins and sync verification codes directly with the Authenticator app. + Descargar ahora + Sincronizar con la aplicación Bitwarden + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sincronizar con la aplicación Bitwarden + Ir a los ajustes + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Por favor, reinténtalo + Move to Bitwarden + Ajuste de guardado por defecto + Guardar en Bitwarden + Guardar aquí + Ninguno + Select where you would like to save new verification codes. + Confirmar + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Guardar opción por defecto + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-et-rEE/strings.xml b/authenticator/src/main/res/values-et-rEE/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-et-rEE/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-eu-rES/strings.xml b/authenticator/src/main/res/values-eu-rES/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-eu-rES/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-fa-rIR/strings.xml b/authenticator/src/main/res/values-fa-rIR/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-fa-rIR/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-fi-rFI/strings.xml b/authenticator/src/main/res/values-fi-rFI/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-fi-rFI/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-fil-rPH/strings.xml b/authenticator/src/main/res/values-fil-rPH/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-fil-rPH/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-fr-rFR/strings.xml b/authenticator/src/main/res/values-fr-rFR/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-fr-rFR/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-gl-rES/strings.xml b/authenticator/src/main/res/values-gl-rES/strings.xml new file mode 100644 index 0000000000..bbc2ff0e10 --- /dev/null +++ b/authenticator/src/main/res/values-gl-rES/strings.xml @@ -0,0 +1,155 @@ + + + Autenticador + Verifiación biométrica + Cancelar + Engadir elemento + Produciuse un erro. + Non puidemos procesar a túa solicitude. Por favor, téntao de novo ou contáctanos. + Requírese unha conexión a Internet + Por favor conéctese a Internet antes de continuar. + Aceptar + Sincronizando + Copiar + Editar + Pechar + Autenticador de Bitwarden + Nome + Engadir rotación de elementos + Escanear un código QR + Introducir unha clave de configuración + Escanear código QR + Apunta a túa cámara ao código QR. + Non se pode escanear o código QR. + Introduce a clave manualmente. + Cannot add authenticator key? + Unha vez metida a clave correctamente,\nselecciona Engadir código dun só uso para gardar a clave de forma segura + Engadir código dun só uso + Clave de autenticación + Non, grazas + Axustes + Activa o permiso de cámara para empregar o escáner + Listado de elementos baleiro + Non tes ningún elemento para amosar. + Engadir un novo código para asegurar as túas contas. + Engadir código + Non se pode ler a clave. + Código de verificación engadido + Nome de usuario + Período de refresco + Algoritmo + Agochar + Amosar + Avanzado + Colapsar opcións avanzadas + Número de díxitos + Gardar + O campo %1$s é obrigatorio. + %d segundos + Gardando + Elemento gardado + Información + Tipo de código dun só uso + Códigos de verificación + Non hai elementos que coincidan coa procura + Atrás + Limpar + Procurar códigos + Opcións + Téntao de novo + Aparencia + Predeterminado (Sistema) + Tema + Escuro + Claro + Lingua + A lingua foi cambiada a %1$s. Por favor, reinicie a aplicación para ver o cambio + Asegura as túas contas co Autenticador de Bitwarden + Obtén códigos de verificación para todas as túas contas que soporten verificación en dous pasos. + Emprega a cámara do teu dispositivo para escanear códigos + Escanea o código QR nos axustes de verificación en dous pasos de calquera conta. + Inicia sesión empregando códigos únicos + Cando empregues a verificación en 2 pasos, introducirás o teu nome de usuario e contrasinal e un código xerado nesta aplicación. + Continuar + Omitir + Comezar + Códigos únicos + Axuda + Iniciar tutorial + %1$s copiado + Eliminar elemento + Elemento eliminado + Eliminar + Esta seguro de borrar? Isto non pode ser desfeito. + Datos + Exportar + Cargando + Confirmar a exportación + Esta exportación contén os teus datos nun formato sen encriptar. Non deberías gardar ou enviar o arquivo exportado por canais inseguros (como o correo electrónico). Elimínao inmediatamente despois de finalizar o seu uso. + Formato de ficheiro + Houbo un problema exportando o teu baúl. Se o problema persiste, terás que exportalo dende a páxina web. + Exportación de datos exitosa + Desbloquear con %1$s + Biometría + Seguridade + Empregar biometría para desbloquear + Demasiados intentos de autenticación biometrica errados. + Acerca de + Versión + Continuar + Centro de axuda de Bitwarden + Continuar ao Centro de Axuda? + Aprende máis de como empregar o Bitwarden Authenticator no Centro de Axuda. + Política de privacidade + Continuar á política de privacidade? + Revisa a nosa política de privacidade en bitwarden.com + Clave + Crear código de verificación + Requírese a clave. + Requírese o nome. + Enviar rexistro de fallo + Produciuse un erro ao importar o teu baúl. + Fonte do arquivo + Importar + Importación de baúl exitosa + A clave é invalida. + Engadir como favorito + Favorito + Favoritos + Copia de seguridade + Copia de seguridade + Os datos do Autenticador de Bitwarden teñen unha copia de seguridade e poden ser restaurados dende as copias de seguridade periódicas configuradas no teu dispositivo. + Máis información + A importación dende arquivos 2FAS protexido por contrasinal non é soportada. Tenteo de novo cun arquivo exportado sen protección. + A importación de ficheiros CSV de Bitwarden non está soportada. Proba de novo cun JSON. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-hi-rIN/strings.xml b/authenticator/src/main/res/values-hi-rIN/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-hi-rIN/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-hr-rHR/strings.xml b/authenticator/src/main/res/values-hr-rHR/strings.xml new file mode 100644 index 0000000000..c3f8c1e5be --- /dev/null +++ b/authenticator/src/main/res/values-hr-rHR/strings.xml @@ -0,0 +1,155 @@ + + + Autentifikator + Biometrijska autentifikacija + Odustani + Dodaj stavku + Došlo je do pogreške. + Trenutno ne možemo obraditi tvoj zahtjev. Pokušaj ponovno ili nas kontaktiraj. + Potrebna je veza s internetom + Spoji se na internet prije nastavka. + OK + Sinkronizacija + Kopiraj + Uredi + Zatvori + Bitwarden Authenticator + Ime + Dodaj stavku rotacije + Skeniraj QR kôd + Unesi ključ za postavljanje + Skeniraj QR kôd + Usmjeri kameru na QR kôd. + Nije moguće očitati QR kôd. + Ručno unesi ključ. + Nije moguće dodati ključ za provjeru autentičnosti? + Jednom kada je ključ uspješno unesen,\nodaberi „Dodaj TOTP” za sigurno spremanje ključa + Dodaj TOTP + Ključ za provjeru autentičnosti + Ne hvala + Postavke + Za skeniranje, odobri korištenje kamere + Isprazni popis stavki + Nema ništa za prikaz. + Dodaj novi kôd za zaštitu svojih računa. + Dodaj kôd + Ne mogu očitati ključ. + Kôd za provjeru dodan + Korisničko ime + Vrijeme osvježavanja + Algoritam + Sakrij + Prikaži + Napredno + Sakrij napredne mogućnosti + Broj znamenki + Spremi + Polje %1$s je obavezno. + %d sek. + Spremanje + Stavka spremljena + Informacije + Vrsta OTP-a + Kodovi za provjeru + Nema stavki koje odgovaraju pretrazi + Natrag + Očisti + Pretraži kodove + Mogućnosti + Pokušaj ponovno + Izgled + Zadano (sustav) + Tema + Tamno + Svijetlo + Jezik + Jezik je promijenjen u %1$s. Ponovno pokreni aplikaciju za primjenu + Osiguraj svoje račune Bitwarden Authenticatorom + Dobij kontrolne kodove za sve svoje račune koji podržavaju dvofaktorsku autentifikaciju. + Koristi kameru uređaja za skeniranje kodova + Skeniraj QR kôd u postavkama dvostruke autentifikacije za bilo koji račun. + Prijava korištenjem jedinstvenih kodova + Kada koristiš dvostruku autentifikaciju, unijet ćeš svoje korisničko ime i zaporku te kôd generiran u ovoj aplikaciji. + Nastavi + Preskoči + Počni + Jedinstveni kodovi + Pomoć + Pokreni vodič + %1$s kopirano + Izbriši stavku + Stavka izbrisana + Izbriši + Stvarno želiš izbrisati? To se ne može poništiti. + Podaci + Izvoz + Učitavanje + Potvrdi izvoz + Ovaj izvoz sadrži podatke trezora u nešifriranom obliku! Izvezenu datoteku se ne bi smjelo pohranjivati ili slati putem nesigurnih kanala (npr. e-poštom). Izbriši ju odmah nakon korištenja. + Format datoteke + Dogodio se problem prilikom izvoza trezora. Ako se problem ponavlja, izvezi koristeći web trezor. + Podaci uspješno izvezeni + Otključaj s %1$s + Biometrija + Sigurnost + Koristi biometriju za otključavanje + Previše neuspjelih biometrijskih pokušaja. + O aplikaciji + Verzija + Nastavi + Bitwarden centar za pomoć + Nastavi u centar za pomoć? + Za pomoć oko korištenja Bitwarden Authenticatora posjeti centar za pomoć. + Politika privatnosti + Nastavi na politiku privatnosti? + Pogledaj našu politiku privatnosti na bitwarden.com + Ključ + Stvori kôd za provjeru + Ključ je obavezan. + Ime je obavezno. + Slanje zapisnika rušenja + Došlo je do problema pri uvozu tvojeg trezora. + Izvor datoteke + Uvoz + Trezor uspješno uvezen + Ključ je neispravan. + Spremi u favorite + Favorit + Favoriti + Sigurnosna kopija + Sigurnosna kopija podataka + Podaci Bitwarden Authenticatora sigurnosno su kopirani i mogu se obnoviti s tvojim redovito zakazanim sigurnosnim kopijama uređaja. + Saznaj više + Uvoz iz datoteka zaštićenih 2FAS lozinkom nije podržan. Pokušaj ponovno s izvezenom datotekom koja nije zaštićena lozinkom. + Uvoz Bitwarden CSV datoteka nije podržan. Pokušaj ponovno s izvezenom JSON datotekom. + Preuzmi Bitwarden aplikaciju + Pohrani sve svoje prijave i sinkroniziraj kôdove za provjeru izravno s aplikacijom Autentifikator. + Preuzmi sada + Sinkroniziraj s Bitwarden aplikacijom + Nije moguće sinkronizirati kodove iz aplikacije Bitwarden. Provjeri jesu li obje aplikacije ažurirane. I dalje možeš pristupiti svojim postojećim kodovima u aplikaciji Bitwarden. + %1$s od %2$s + Sinkroniziraj s Bitwardenom + Idi na Postavke + Dopusti sinkronizaciju aplikacije Autentifikator u postavkama kako bi ovdje mogli vidjeti sve svoje kodove za provjeru. + Dogodila se greška + Molimo pokušaj ponovo + Premjesti u Bitwarden + Zadana opcija spremanja + Spremi u Bitwarden + Spremi ovdje + Nijedno + Odaberi gdje želiš spremiti nove kodove. + Potvrdi + Kôd za provjeru stvoren + Spremi ovaj autentifikatorski ključ ovdje ili ga dodaj prijavi u Bitwarden aplikaciji. + Spremi opciju kao zadanu + Račun sinkroniziran iz aplikacije Bitwarden + Dodaj kod u Bitwardn + Dodaj kod lokalno + Lokalni kodovi + Potrebni podaci nedostaju + "Nedostaju potrebni podaci (npr. „usluge” ili „tajno”). Provjeri svoju datoteku i pokušaj ponovno. Posjeti bitwarden.com/help za podršku" + "Datoteka se ne može obraditi" + "Datoteka se ne može obraditi. Provjeri je li JSON ispravan i pokušaj ponovno. Za pomoć posjeti bitwarden.com/help" + Potraži pomoć + diff --git a/authenticator/src/main/res/values-hu-rHU/strings.xml b/authenticator/src/main/res/values-hu-rHU/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-hu-rHU/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-in-rID/strings.xml b/authenticator/src/main/res/values-in-rID/strings.xml new file mode 100644 index 0000000000..f810b9cd9b --- /dev/null +++ b/authenticator/src/main/res/values-in-rID/strings.xml @@ -0,0 +1,155 @@ + + + Autentikator + Verifikasi biometrik + Cancel + Tambahkan item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Sinkronisasi + Copy + Edit + Close + Bitwarden Authenticator + Name + Tambahkan Item Rotasi + Pindai kode QR + Masukkan kunci penyiapan + Pindai kode QR + Arahkan kamera Anda ke kode QR. + Tidak dapat memindai kode QR. + Masukkan kunci secara manual. + Cannot add authenticator key? + Setelah kunci berhasil dimasukkan,\npilih Tambahkan TOTP untuk menyimpan kunci dengan aman + Tambahkan TOTP + Kunci Autentikator + Tidak terima kasih + Settings + Aktifkan izin kamera untuk menggunakan pemindai + Hilangkan Daftar Item + Anda tidak memiliki item untuk ditampilkan. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Sembunyikan + Tampilkan + Advanced + Tutup opsi lanjutan + Number of digits + Save + The %1$s field is required. + %d detik + Menyimpan + Item tersimpan + Informasi + Tipe OTP + Verification codes + There are no items that match the search + Kembali + Bersihkan + Cari kode + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Dapatkan kode verifikasi untuk semua akun Anda yang mendukung verifikasi 2 langkah. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Kode unik + Help + Launch tutorial + %1$s copied + Hapus item + Item deleted + Delete + Anda yakin ingin menghapus secara permanen? Ini tidak dapat dibatalkan. + Data + Export + Memuat + Konfirmasi ekspor + Ekspor ini berisi data Anda dalam format tidak terenkripsi. Anda tidak boleh menyimpan atau mengirim file yang diekspor melalui saluran yang tidak aman (seperti email). Hapus segera setelah Anda selesai menggunakannya. + File format + Ada masalah saat mengekspor brankas Anda. Jika masalah terus berlanjut, Anda harus mengekspor dari brankas web. + Data berhasil diekspor + Unlock with %1$s + Biometrik + Security + Use biometrics to unlock + Terlalu banyak kesalahan percobaan biometrik. + About + Version + Continue + Bitwarden Help Center + Lanjutkan ke Pusat Bantuan? + Pelajari lebih lanjut cara menggunakan Bitwarden Authenticator di Pusat Bantuan. + Kebijakan privasi + Continue to privacy policy? + Lihat kebijakan privasi di bitwarden.com + Key + Buat kode Verifikasi + Kunci diperlukan. + Nama diperlukan. + Kirim catatan kerusakan + Terdapat galat mengimpor kunci mu. + Sumber Berkas + Import + Vault berhasil diimpor + Kunci salah. + Simpan sebagai favorit + Favorite + Favorites + Backup + Cadangan data + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Mengimpor file CSV Bitwarden tidak didukung. Coba lagi dengan file JSON yang diekspor. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-it-rIT/strings.xml b/authenticator/src/main/res/values-it-rIT/strings.xml new file mode 100644 index 0000000000..bbaa68f91e --- /dev/null +++ b/authenticator/src/main/res/values-it-rIT/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Verifica biometrica + Annulla + Aggiungi elemento + Si è verificato un errore. + Non siamo riusciti a elaborare la tua richiesta. Riprova o contattaci. + Connessione a internet necessaria + Connettiti a internet prima di continuare. + OK + Sincronizzazione + Copia + Modifica + Chiudi + Bitwarden Authenticator + Nome + Aggiungi Rotazione Elemento + Scansiona un codice QR + Inserisci chiave di configurazione + Scansiona codice QR + Inquadra il codice QR con la fotocamera. + Impossibile scansionare il codice QR. + Inserisci manualmente la chiave + Non riesci a inserire la chiave di autenticazione? + Una volta inserita la chiave,\npremi su Aggiungi TOTP per salvare la chiave in modo sicuro + Aggiungi TOTP + Chiave di autenticazione + No grazie + Impostazioni + Consenti a Bitwarden l\'accesso alla fotocamera per la scansione + Elenco Oggetti Vuoto + Non ci sono elementi da mostrare. + Aggiungi un nuovo codice per proteggere i tuoi account. + Aggiungi codice + Impossibile leggere la chiave. + Codice di verifica aggiunto + Nome Utente + Periodo di aggiornamento + Algoritmo + Nascondi + Mostra + Avanzato + Comprimi opzioni avanzate + Numero di cifre + Salva + Il campo %1$s è obbligatorio. + %d secondi + Salvataggio + Elemento salvato + Informazioni + Tipo di OTP + Codici di verifica + Nessun elemento corrisponde alla ricerca + Indietro + Cancella + Cerca codici + Opzioni + Riprova + Aspetto + Predefinito (Sistema) + Tema + Scuro + Chiaro + Lingua + La lingua è stata cambiata a %1$s. Riavvia l\'app per vedere la modifica + Proteggi i tuoi account con Bitwarden Authenticator + Ottieni codici di verifica per tutti i tuoi account che supportano la verifica in 2 passaggi. + Usa la fotocamera del dispositivo per scansionare i codici + Scansiona il codice QR nelle impostazioni di verifica in 2 passaggi per qualsiasi account. + Accedi utilizzando codici univoci + Quando si utilizza la verifica in 2 passaggi, inserirai il nome utente e password e un codice generato in questa app. + Continua + Salta + Inizia + Codici unici + Aiuto + Avvia tutorial + %1$s copiato + Elimina elemento + Elemento eliminato + Elimina + Sicuro di eliminare definitivamente? L\'operazione è irreversibile. + Dati + Esporta + Caricamento + Conferma esportazione + Questa esportazione contiene i dati della cassaforte in un formato non crittografato. Non salvare o inviare il file esportato attraverso canali non protetti (come le email). Eliminalo subito dopo l\'utilizzo. + Formato file + Si è verificato un problema durante l\'esportazione della cassaforte. Se il problema persiste, esportala dalla cassaforte web. + Dati esportati con successo + Sblocca con %1$s + Dati biometrici + Sicurezza + Sblocca con dati biometrici + Troppi tentativi biometrici falliti. + Informazioni + Versione + Continua + Centro assistenza Bitwarden + Aprire il Centro Assistenza? + Scopri di più su come utilizzare Bitwarden Authenticator nel Centro Assistenza. + Informativa sulla privacy + Proseguire sull\'informativa privacy? + Consulta la nostra privacy policy su bitwarden.com + Chiave + Crea codice di verifica + La chiave è obbligatoria. + Il nome è obbligatorio. + Invia rapporti sugli arresti anomali + Si è verificato un problema durante l\'importazione della cassaforte. + Sorgente file + Importa + Importazione cassaforte riuscita + Chiave non valida. + Salva come preferito + Preferito + Preferiti + Backup + Backup dati + I dati di Bitwarden Authenticator sono salvati e possono essere ripristinati con i backup del dispositivo regolarmente pianificati. + Per saperne di più + L\'importazione da file protetti da password 2FAS non è supportata. Riprovare con un file esportato che non è protetto da password. + L\'importazione di file CSV Bitwarden non è supportata. Riprova con un file JSON esportato. + Scarica l\'app Bitwarden + Memorizza tutti i login e sincronizza i codici di verifica direttamente con l\'app Authenticator. + Scarica adesso + Sincronizza con l\'app Bitwarden + Impossibile sincronizzare i codici dall\'app Bitwarden. Assicurati che entrambe le app siano aggiornate. Puoi comunque accedere ai tuoi codici esistenti nell\'app Bitwarden. + %1$s | %2$s + Sincronizza con l\'app Bitwarden + Vai alle impostazioni + Consenti la sincronizzazione dell\'app Authenticator nelle impostazioni per visualizzare tutti i codici di verifica qui. + Qualcosa è andato storto + Per favore riprova + Sposta in Bitwarden + Opzione di salvataggio predefinita + Salva su Bitwarden + Salva qui + Niente + Seleziona dove vuoi salvare i nuovi codici di verifica. + Conferma + Codice di verifica creato + Salva qui questa chiave di autenticazione, o aggiungila ad un login su Bitwarden. + Salva in modo predefinito + Account sincronizzato dall\'app Bitwarden + Aggiungi codice a Bitwarden + Aggiungi codice localmente + Codici locali + Informazioni Richieste Mancanti + "Mancano informazioni richieste (ad es. ‘servizi’ o ‘segreto’). Controlla il tuo file e riprova. Visita bitwarden.com/help per assistenza" + "Il file non può essere elaborato" + "Il file non può essere elaborato. Assicurati che sia compatibile con JSON e riprova. Hai bisogno di aiuto? Visita bitwarden.com/help" + Ottieni aiuto + diff --git a/authenticator/src/main/res/values-iw-rIL/strings.xml b/authenticator/src/main/res/values-iw-rIL/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-iw-rIL/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-ja-rJP/strings.xml b/authenticator/src/main/res/values-ja-rJP/strings.xml new file mode 100644 index 0000000000..33b2f87938 --- /dev/null +++ b/authenticator/src/main/res/values-ja-rJP/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + 生体認証 + キャンセル + 項目の追加 + エラーが発生しました。 + リクエストを処理できませんでした。もう一度やり直すか、お問い合わせください。 + インターネット接続が必要です + 続行する前にインターネットに接続してください。 + OK + 同期中 + コピー + 編集 + 閉じる + Bitwarden Authenticator + 名前 + 項目に回転を追加 + QRコードを読み取る + セットアップキーを入力 + QRコードを読み取る + カメラをQRコードに向けてください。 + QRコードをスキャンできません。 + 手動でキーを入力する + 認証キーを追加できませんか? + キーを正しく入力したら、\n「TOTPを追加」を選択してキーを安全に保存します + TOTPを追加 + 認証アプリのキー + 結構です + 設定 + スキャナーを利用するためにカメラの使用を許可する + 空のアイテム一覧 + 表示するアイテムがありません。 + 新しいコードを追加してアカウントを保護します。 + コードを追加 + キーが読み込めません。 + 認証コードが追加されました + ユーザー名 + 更新頻度 + アルゴリズム + 非表示にする + 表示する + 上級者向け + 高度なオプションを閉じる + 桁数 + 保存 + 「%1$s」欄は必須です。 + %d 秒 + 保存中 + 項目を保存しました + 情報 + OTPの種類 + 認証コード + 検索に一致するアイテムはありません + 戻る + 消去 + コードの検索 + オプション + 再試行 + 外観 + 既定(システム) + テーマ + + + 言語 + 言語が%1$sに変更されました。変更を反映するにはアプリを再起動してください + Bitwarden Authenticator でアカウントを保護しましょう + 2段階認証に対応したあらゆるアカウントの認証コードを取得します。 + 端末のカメラでコードを読み取り + 各種アカウントの2段階認証の設定時に表示される、QRコードを読み取ります。 + その時限りのコードでサインイン + 2段階認証を行う際、ユーザー名とパスワードに加えて、このアプリで生成されたコードを入力します。 + 続ける + スキップ + 利用を開始 + その時限りのコード + ヘルプ + チュートリアルを開始 + %1$sをコピーしました + アイテムを削除 + アイテムが削除されました + 削除 + 完全に削除してもよろしいですか?元に戻すことはできません。 + データ + エクスポート + 読み込み中 + エクスポートの確認 + このエクスポートには、暗号化されていない形式のデータが含まれています。安全でない通信路(電子メールなど)を介してエクスポートされたファイルを保存または送信しないでください。使用後は、すぐにファイルを削除してください。 + ファイル形式 + 保管庫のエクスポート中に問題が発生しました。問題が解決しない場合は、ウェブ保管庫からエクスポートする必要があります。 + データのエクスポートに成功しました + %1$sでロック解除 + 生体認証 + セキュリティ + 生体認証でロック解除 + 生体認証の失敗回数が多すぎます。 + このアプリについて + バージョン + 続ける + Bitwarden ヘルプセンター + ヘルプセンターに進みますか? + ヘルプセンターでBitwarden Authenticatorの詳しい利用方法をご覧ください。 + プライバシーポリシー + プライバシーポリシーに進みますか? + bitwarden.com でプライバシーポリシーをご覧ください。 + キー + 認証コードを生成 + キーは必須です。 + 名前は必須です。 + クラッシュログを送信 + 保管庫のインポート中に問題が発生しました。 + 入力するファイル + インポート + 保管庫のインポートに成功しました + キーが無効です。 + お気に入りに保存 + お気に入り + お気に入り + バックアップ + データのバックアップ + Bitwarden Authenticator のデータは、端末の定期バックアップによってバックアップ・復元されます。 + もっと詳しく + 2FASのパスワードで保護されたファイルからのインポートには対応しておりません。パスワード保護されていないエクスポートファイルでもう一度お試しください。 + Bitwarden CSVファイルのインポートはサポートされていません。エクスポートされたJSONファイルで再試行してください。 + Bitwarden アプリをダウンロード + すべてのログイン情報を格納し、認証アプリに認証コードを直接同期します。 + 今すぐダウンロード + Bitwarden アプリと同期 + Bitwarden アプリからコードを同期できません。両方のアプリが最新であることを確認してください。既存のコードには Bitwarden アプリでアクセスできます。 + %1$s | %2$s + Bitwarden アプリと同期 + 設定に移動 + ここにすべての認証コードを表示するためには、認証アプリの同期を許可してください。 + 何らかのエラーが発生しました + もう一度お試しください + Bitwarden へ移動 + デフォルトの保存オプション + Bitwarden に保存 + ここに保存 + なし + 新しい認証コードを保存する場所を選択します。 + 確認 + 認証コードが作成されました + この認証キーをここに保存するか、Bitwarden アプリでログイン情報に追加してください。 + 選択をデフォルトとして保存 + Bitwarden アプリからアカウントが同期されました + Bitwarden にコードを追加 + コードをローカルに追加 + ローカルのコード + 必須の情報がありません + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-ka-rGE/strings.xml b/authenticator/src/main/res/values-ka-rGE/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-ka-rGE/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-kn-rIN/strings.xml b/authenticator/src/main/res/values-kn-rIN/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-kn-rIN/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-ko-rKR/strings.xml b/authenticator/src/main/res/values-ko-rKR/strings.xml new file mode 100644 index 0000000000..de6488c1b5 --- /dev/null +++ b/authenticator/src/main/res/values-ko-rKR/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + 취소 + Add item + 오류가 발생했습니다. + We were unable to process your request. Please try again or contact us. + 인터넷 연결 필요 + 계속하기 전에 인터넷에 연결해 주세요. + OK + Syncing + 복사 + 편집 + 닫기 + Bitwarden Authenticator + 이름 + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + 설정 + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + 사용자 이름 + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + 저장 + %1$s 필드는 반드시 입력해야 합니다. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + 설정 + 다시 시도 + Appearance + Default (System) + Theme + 어두운 테마 + 밝은 테마 + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + 계속 + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s 항목을 복사했습니다. + Delete item + 항목을 삭제했습니다. + 삭제 + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + %1$s로 잠금 해제 + Biometrics + 보안 + Use biometrics to unlock + Too many failed biometrics attempts. + 정보 + 버전 + 계속 + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + 즐겨찾기 + 즐겨찾기 + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + 더 알아보기 + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-lt-rLT/strings.xml b/authenticator/src/main/res/values-lt-rLT/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-lt-rLT/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-lv-rLV/strings.xml b/authenticator/src/main/res/values-lv-rLV/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-lv-rLV/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-ml-rIN/strings.xml b/authenticator/src/main/res/values-ml-rIN/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-ml-rIN/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-mr-rIN/strings.xml b/authenticator/src/main/res/values-mr-rIN/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-mr-rIN/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-my-rMM/strings.xml b/authenticator/src/main/res/values-my-rMM/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-my-rMM/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-nb-rNO/strings.xml b/authenticator/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-ne-rNP/strings.xml b/authenticator/src/main/res/values-ne-rNP/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-ne-rNP/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-night/ic_launcher_background.xml b/authenticator/src/main/res/values-night/ic_launcher_background.xml new file mode 100644 index 0000000000..93629affd2 --- /dev/null +++ b/authenticator/src/main/res/values-night/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + @color/dark_gray + diff --git a/authenticator/src/main/res/values-nl-rNL/strings.xml b/authenticator/src/main/res/values-nl-rNL/strings.xml new file mode 100644 index 0000000000..6b06b0c8f3 --- /dev/null +++ b/authenticator/src/main/res/values-nl-rNL/strings.xml @@ -0,0 +1,156 @@ + + + Authenticator + Biometrische verificatie + Annuleren + Item toevoegen + Er is een fout opgetreden. + We hebben je verzoek niet kunnen verwerken. Probeer het opnieuw of neem contact met ons op. + Internetverbinding vereist + Maak verbinding met internet voordat je verder gaat. + Oké + Synchroniseren + Kopiëren + Bewerken + Sluiten + Bitwarden Authenticator + Naam + Item rotatie toevoegen + Scan een QR-code + Een installatiesleutel invoeren + QR-code scannen + Richt je camera op de QR code. + Kan QR-code niet scannen. + Sleutel handmatig invoeren + Kan authenticatorsleutel niet toevoegen? + Nadat de sleutel succesvol is ingevoerd, +selecteer TOTP toevoegen om de sleutel veilig op te slaan + TOTP toevoegen + Authenticatorsleutel + Nee, bedankt + Instellingen + Camera-toestemming inschakelen om de scanner te gebruiken + Lege item lijst + Je hebt geen items om weer te geven. + Voeg een nieuwe code toe om je accounts te beveiligen. + Code toevoegen + Kan sleutel niet lezen. + Verificatiecode toegevoegd + Gebruikersnaam + Periode verversen + Algoritme + Verbergen + Toon + Geavanceerd + Geavanceerde opties samenvouwen + Aantal cijfers + Opslaan + Het %1$s veld is verplicht. + %d seconden + Opslaan + Item opgeslagen + Informatie + OTP-type + Verificatiecodes + Er zijn geen items die overeenkomen met de zoekopdracht + Terug + Wissen + Codes zoeken + Opties + Probeer opnieuw + Weergave + Standaard (systeem) + Thema + Donker + Licht + Taal + De taal is gewijzigd in %1$s. Start de app opnieuw op om de wijziging te zien + Beveilig je accounts met Bitwarden Authenticator + Ontvang verificatiecodes voor al je accounts die tweestapsverificatie ondersteunen. + Gebruik de camera van je apparaat om codes te scannen + Scan de QR-code in je instellingen voor tweestapsverificatie voor elk account. + Inloggen met unieke codes + Als je tweestapsverificatie gebruikt, voer je je gebruikersnaam en wachtwoord in en een code die in deze app wordt gegenereerd. + Doorgaan + Overslaan + Aan de slag + Unieke codes + Help + Tutorial starten + %1$s gekopieerd + Item verwijderen + Item verwijderd + Verwijderen + Wil je echt permanent verwijderen? Dit kan niet ongedaan worden gemaakt. + Gegevens + Exporteren + Laden + Export bevestigen + Deze export bevat je gegevens in een niet-versleutelde indeling. Bewaar of verstuur het geëxporteerde bestand niet via onbeveiligde kanalen (zoals e-mail). Verwijder het onmiddellijk nadat je het hebt gebruikt. + Bestandsformaat + Er is een probleem opgetreden bij het exporteren van je kluis. Als het probleem zich blijft voordoen, moet je exporteren vanuit de webkluis. + Gegevens succesvol geëxporteerd + Ontgrendelen met %1$s + Biometrie + Beveiliging + Biometrie gebruiken om te ontgrendelen + Te veel mislukte biometriepogingen. + Over + Versie + Doorgaan + Bitwarden Helpcentrum + Doorgaan naar Helpcentrum? + Lees meer over het gebruik van Bitwarden Authenticator in het Helpcentrum. + Privacybeleid + Doorgaan naar privacybeleid? + Bekijk ons privacybeleid op bitwarden.com + Sleutel + Verificatiecode aanmaken + Sleutel is vereist. + Naam is vereist. + Crashlogs indienen + Er is een probleem opgetreden bij het importeren van je kluis. + Bestand bron + Importeren + Kluis succesvol geïmporteerd + Sleutel is ongeldig. + Opslaan als favoriet + Favoriet + Favorieten + Back-up + Gegevens back-up + Bitwarden Authenticator gegevens worden geback-upt en kunnen worden hersteld met je regelmatig geplande apparaat back-ups. + Leer meer + Importeren vanuit 2FAS-bestanden met wachtwoordbeveiliging wordt niet ondersteund. Probeer het opnieuw met een geëxporteerd bestand dat niet met een wachtwoord is beveiligd. + Het importeren van Bitwarden CSV-bestanden wordt niet ondersteund. Probeer het opnieuw met een geëxporteerd JSON-bestand. + Download de Bitwarden app + Sla al je logins op en synchroniseer verificatiecodes direct met de Authenticator app. + Nu downloaden + Synchroniseren met Bitwarden app + Kan geen codes synchroniseren vanuit de Bitwarden app. Zorg ervoor dat beide apps up-to-date zijn. Je hebt nog steeds toegang tot je bestaande codes in de Bitwarden app. + %1$s | %2$s + Synchroniseren met de Bitwarden app + Ga naar instellingen + Authenticator app synchronisatie toestaan in instellingen om hier al je verificatiecodes te bekijken. + Er ging iets mis + Probeer opnieuw + Verplaatsen naar Bitwarden + Standaard opslaan optie + Opslaan in Bitwarden + Hier opslaan + Geen + Selecteer waar je nieuwe verificatiecodes wilt opslaan. + Bevestigen + Verificatiecode gemaakt + Sla deze authenticatorsleutel hier op of voeg hem toe aan een login in je Bitwarden app. + Opslaan optie als standaard + Account gesynchroniseerd vanuit Bitwarden app + Code toevoegen aan Bitwarden + Lokaal code toevoegen + Lokale codes + Verplichte informatie ontbreekt + "Vereiste informatie ontbreekt (bijv. 'services' of 'secret'). Controleer je bestand en probeer het opnieuw. Bezoek bitwarden.com/help voor ondersteuning" + "Bestand kon niet worden verwerkt" + "Bestand kon niet worden verwerkt. Zorg ervoor dat het geldige JSON is en probeer het opnieuw. Hulp nodig? Ga naar bitwarden.com/help" + Krijg hulp + diff --git a/authenticator/src/main/res/values-nn-rNO/strings.xml b/authenticator/src/main/res/values-nn-rNO/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-nn-rNO/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-or-rIN/strings.xml b/authenticator/src/main/res/values-or-rIN/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-or-rIN/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-pl-rPL/strings.xml b/authenticator/src/main/res/values-pl-rPL/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-pl-rPL/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-pt-rBR/strings.xml b/authenticator/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000000..411396b79a --- /dev/null +++ b/authenticator/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,155 @@ + + + Autenticador + Verificação biométrica + Cancelar + Adicionar item + Ocorreu um erro. + Não foi possível processar o seu pedido. Por favor, tente novamente ou entre em contato conosco. + É necessária uma conexão com a Internet + Por favor, conecte-se à internet antes de continuar. + Aceitar + Sincronizando + Copiar + Editar + Fechar + Autenticador Bitwarden + Nome + Adicionar Rotação do Item + Escanear QR code + Inserir uma chave de configuração + Escanear QR code + Aponte sua câmera para o QR code. + Não foi possível escanear o QR code. + Insira a chave manualmente. + Não foi possível adicionar chave de autenticação? + Assim que a chave for inserida corretamente,\nselecione Adicionar TOTP para armazenar a chave de forma segura + Adicionar TOTP + Chave de Autenticação + Não obrigado + Configurações + Conceda permissão de uso da câmera para usar o scanner + Lista de Itens Vazios + Você não tem nenhum item para exibir. + Adicione um novo código para proteger suas contas. + Adicionar código + Impossível ler a chave. + Código de verificação adicionado + Nome de usuário + Período de atualização + Algoritmo + Ocultar + Mostrar + Avançado + Recolher opções avançadas + Número de dígitos + Salvar + O campo %1$s é necessário. + %d segundos + Salvando + Item salvo + Informação + Tipo de OTP + Códigos de verificação + Não há itens que correspondam à pesquisa + Voltar + Limpar + Buscar códigos + Opções + Tentar novamente + Aparência + Padrão (Sistema) + Tema + Escuro + Claro + Idioma + O idioma foi alterado para %1$s. Por favor, reinicie o aplicativo para ver a alteração + Proteja suas contas com o Bitwarden Authenticator + Obtenha códigos de verificação para todas as suas contas que suportam verificação em duas etapas. + Use a câmera do seu dispositivo para escanear códigos + Digitalize o QR code nas configurações de verificação de duas etapas para qualquer conta. + Fazer login usando códigos únicos + Ao usar a verificação em duas etapas, você digitará seu nome de usuário e senha e um código gerado neste aplicativo. + Continuar + Pular + Introdução + Códigos únicos + Ajuda + Iniciar tutorial + %1$s copiado + Excluir item + Item excluído + Excluir + Você realmente quer excluir permanentemente? Isso não pode ser desfeito. + Dados + Exportar + Carregando + Confirmar exportação + Esta exportação contém os dados do seu cofre em um formato não criptografado. Você não deve armazenar ou enviar o arquivo exportado por canais inseguros (como e-mail). Exclua o arquivo imediatamente após terminar de usá-lo. + Formato do arquivo + Houve um problema ao exportar o seu cofre. Caso o problema persista, você precisará exportar através do cofre web. + Dados exportados com sucesso + Desbloquear com %1$s + Biometria + Segurança + Usar biometria para desbloquear + Muitas tentativas falhas de biometria. + Sobre + Versão + Continuar + Central de Ajuda Bitwarden + Continuar para o Centro de Ajuda? + Saiba mais sobre como usar o Bitwarden no Centro de Ajuda. + Política de privacidade + Continuar para a política de privacidade? + Confira a nossa política de privacidade em bitwarden.com + Chave + Criar código de verificação + Chave é obrigatória. + Nome é necessário. + Enviar registros de erros + Houve um problema ao importar o seu cofre. + Origem do Arquivo + Importar + Cofre importado com sucesso + Chave é inválida. + Salvar como favorito + Favorito + Favoritos + Backup + Backup de dados + Os dados do Bitwarden Authenticator são salvos e podem ser restaurados com os backups agendados regularmente. + Saber mais + Não é possível importar de arquivos protegidos por senha 2FAS. Tente novamente com um arquivo exportado que não esteja protegido por senha. + A importação de arquivos CSV do Bitwarden não é suportada. Tente novamente com um arquivo JSON exportado. + Baixe o aplicativo Bitwarden + Armazene todos os seus logins e sincronize códigos de verificação diretamente com o app Autenticador. + Baixe agora + Sincronizar com o aplicativo Bitwarden + Não é possível sincronizar os códigos do aplicativo Bitwarden. Certifique-se de que ambos os aplicativos estão atualizados. Você ainda pode acessar os códigos existentes no aplicativo Bitwarden. + %1$s ├%2$s + Sincronizar com o aplicativo Bitwarden + Ir para as configurações + Permitir a sincronização do app Autenticador em configurações para ver todos os seus códigos de verificação aqui. + Algo deu errado + Por favor, tente novamente + Mover para o Bitwarden + Opção padrão de salvar + Salvar no Bitwarden + Salvar aqui + Nenhum + Selecione onde você gostaria de salvar os novos códigos de verificação. + Confirmar + Código de verificação criado + Salve esta chave de autenticador aqui, ou adicione-a a um login no seu aplicativo Bitwarden. + Salvar opção como padrão + Conta sincronizada do aplicativo Bitwarden + Adicionar o código ao Bitwarden + Adicionar código localmente + Códigos locais + Informações necessárias faltando + "As informações necessárias estão faltando (ex.: 'serviços' ou 'segredo'). Verifique o seu arquivo e tente novamente. Visite bitwarden.com/help para obter suporte" + "O arquivo não pode ser processado" + "O arquivo não pôde ser processado. Certifique-se de que é um JSON válido e tente novamente. Precisa de ajuda? Visite bitwarden.com/help" + Obter Ajuda + diff --git a/authenticator/src/main/res/values-pt-rPT/strings.xml b/authenticator/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000000..41d955ed73 --- /dev/null +++ b/authenticator/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Verificação biométrica + Cancelar + Adicionar item + Ocorreu um erro. + Não foi possível processar o seu pedido. Por favor, tente novamente ou contacte-nos. + Ligação à Internet necessária + Por favor, ligue-se à Internet antes de continuar. + OK + A sincronizar + Copiar + Editar + Fechar + Bitwarden Authenticator + Nome + Adicionar rotação de item + Ler um código QR + Introduzir uma chave de configuração + Ler código QR + Aponte a sua câmara para o código QR. + Não foi possível ler o código QR. + Introduzir a chave manualmente. + Não é possível adicionar uma chave de autenticação? + Assim que a chave for introduzida com sucesso,\nselecione Adicionar TOTP para guardar a chave em segurança + Adicionar TOTP + Chave de autenticação + Não, obrigado + Definições + Ative a permissão da câmara para utilizar o digitalizador + Lista de itens vazios + Não tem itens para apresentar. + Adicione um novo código para proteger as suas contas. + Adicionar código + Não é possível ler a chave. + Códigos de verificação adicionados + Nome de utilizador + Período de atualização + Algoritmo + Ocultar + Mostrar + Avançadas + Ocultar opções avançadas + Número de dígitos + Guardar + O campo %1$s é obrigatório. + %d segundos + A guardar + Item guardado + Informações + Tipo de OTP + Códigos de verificação + Não existem itens que correspondam à pesquisa + Voltar + Limpar + Procurar códigos + Opções + Tentar novamente + Aparência + Predefinido (Sistema) + Tema + Escuro + Claro + Idioma + O idioma foi alterado para %1$s. Por favor, reinicie a aplicação para ver as alterações + Proteja as suas contas com o Bitwarden Authenticator + Obtenha códigos de verificação para todas as suas contas que suportam a verificação de dois passos. + Utilize a câmara do seu dispositivo para ler códigos + Digitalize o código QR nas suas definições de verificação de dois passos para qualquer conta. + Inicie sessão utilizando códigos únicos + Ao utilizar a verificação de dois passos, introduz o seu nome de utilizador e palavra-passe e um código gerado nesta aplicação. + Continuar + Saltar + Começar + Códigos únicos + Ajuda + Iniciar tutorial + %1$s copiado(a) + Eliminar item + Item eliminado + Eliminar + Pretende mesmo eliminar permanentemente? Esta ação não pode ser anulada. + Dados + Exportar + A carregar + Confirmar a exportação + Esta exportação contém os seus dados num formato não encriptado. Não deve armazenar ou enviar o ficheiro exportado através de canais não seguros (como o e-mail). Elimine-o imediatamente após terminar a sua utilização. + Formato do ficheiro + Ocorreu um problema ao exportar o seu cofre. Se o problema persistir, terá de exportar a partir do cofre Web. + Dados exportados com sucesso + Desbloquear com %1$s + Biometria + Segurança + Utilizar a biometria para desbloquear + Demasiadas tentativas falhadas de biometria. + Acerca de + Versão + Continuar + Centro de ajuda do Bitwarden + Continuar para o Centro de ajuda? + Saiba mais sobre como utilizar o Bitwarden Authenticator no Centro de ajuda. + Política de privacidade + Continuar para a política de privacidade? + Consulte a nossa política de privacidade em bitwarden.com + Chave + Criar código de verificação + É necessária uma chave. + O nome é obrigatório. + Submeter registo de falhas + Ocorreu um problema ao importar o seu cofre. + Fonte do ficheiro + Importar + Cofre importado com sucesso + A chave é inválida. + Guardar como favorito + Adicionar aos favoritos + Favoritos + Cópia de segurança + Cópia de segurança dos dados + Os dados do Bitwarden Authenticator são armazenados em cópia de segurança e podem ser restaurados com as cópias de segurança regulares do dispositivo. + Saber mais + A importação de ficheiros 2FAS protegidos por palavra-passe não é suportada. Tente novamente com um ficheiro exportado que não esteja protegido por palavra-passe. + A importação de ficheiros Bitwarden CSV não é suportada. Tente novamente com um ficheiro JSON exportado. + Descarregar a app Bitwarden + Armazene todas as suas credenciais e sincronize os códigos de verificação diretamente com a app Authenticator. + Descarregar agora + Sincronizar com a app Bitwarden + Não é possível sincronizar códigos da app Bitwarden. Certifique-se de que ambas as aplicações estão atualizadas. Ainda pode aceder aos seus códigos existentes na app Bitwarden. + %1$s | %2$s + Sincronizar com a app Bitwarden + Ir para as definições + Permita a sincronização da app Authenticator nas definições para ver todos os seus códigos de verificação aqui. + Alguma coisa correu mal + Por favor, tente novamente + Mover para o Bitwarden + Opção de guardar predefinida + Guardar no Bitwarden + Guardar aqui + Nenhum + Selecione o local onde pretende guardar os novos códigos de verificação. + Confirmar + Código de verificação criado + Guarde esta chave de autenticação aqui ou adicione-a a uma credencial na sua app Bitwarden. + Guardar opção como predefinição + Conta sincronizada a partir da app Bitwarden + Adicionar código ao Bitwarden + Adicionar código localmente + Códigos locais + Informações necessárias em falta + "A informação necessária está em falta (por exemplo, \"serviços\" ou \"segredo\"). Verifique o seu ficheiro e tente novamente. Visite bitwarden.com/help para obter suporte" + "O ficheiro não pôde ser processado" + "O ficheiro não pôde ser processado. Certifique-se de que se trata de um JSON válido e tente novamente. Precisa de ajuda? Visite bitwarden.com/help" + Obter ajuda + diff --git a/authenticator/src/main/res/values-ro-rRO/strings.xml b/authenticator/src/main/res/values-ro-rRO/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-ro-rRO/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-ru-rRU/strings.xml b/authenticator/src/main/res/values-ru-rRU/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-ru-rRU/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-si-rLK/strings.xml b/authenticator/src/main/res/values-si-rLK/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-si-rLK/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-sk-rSK/strings.xml b/authenticator/src/main/res/values-sk-rSK/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-sk-rSK/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-sl-rSI/strings.xml b/authenticator/src/main/res/values-sl-rSI/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-sl-rSI/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-sr-rSP/strings.xml b/authenticator/src/main/res/values-sr-rSP/strings.xml new file mode 100644 index 0000000000..6435c6e3f0 --- /dev/null +++ b/authenticator/src/main/res/values-sr-rSP/strings.xml @@ -0,0 +1,155 @@ + + + Аутентификатор + Биометријска провера + Откажи + Додај ставку + Појавила се грешка. + Ваш захтев није успео да буде обрађен. Покушајте поново или нас контактирајте. + Неопходна је веза са интернетом + Повежите се на Интернет пре него што наставите. + У реду + Синхронизација + Копирај + Уреди + Затвори + Bitwarden Authenticator + Име + Додај ротацију ставке + Скенирај QR кôд + Унесите кључ за подешавање + Скенирајте QR кôд + Усмерите камеру на QR код. + Није могуће скенирати QR кôд. + Унесите кључ ручно. + Не може да се дода верификациони кôд? + Када се кључ успешно унесе, \nодабрати Додати ТОТП да би сигурносно сачували кључ + Додати ТОТП + Кључ аутентификатора + Не, хвала + Подешавања + Омогућите дозволу камере за коришћење скенера + Празан списак + Немате ниједну ставку за приказ. + Додајте нови кôд да бисте заштитили своје налоге. + Додај кôд + Није могуће прочитати кључ. + Верификациони кôд је додат + Корисничко име + Период освежавања + Алгоритам + Сакриј + Прикажи + Напредно + Скупи напредне опције + Број цифара + Сачувај + Поље %1$s је обавезно. + %d сек. + Чување + Ставка сачувана + Информација + Тип ОТП + Верификациони кôдови + Нема ставки које одговарају претрази + Назад + Очисти + Претражи кодове + Опције + Покушајте поново + Изглед + Подразумевано (системско) + Тема + Тамна + Светла + Језик + Језик је промењен у %1$s. Поново покрените апликацију да бисте видели промену + Осигурајте своје налоге помоћу Bitwarden Authenticator-а + Добијте верификационе кодове за све ваше налоге које подржају верификације у 2 корака. + Користите камеру уређаја за скенирање кодова + Скенирајте QR кôд у подешавањима верификације у 2 корака за било који налог. + Пријавите се користећи јединствене кодове + Када користите верификацију у 2 корака, унећете своје корисничко име и лозинку и кôд генерисан у овој апликацији. + Настави + Прескочи + Почните + Јединствени кодови + Помоћ + Покрените водич + %1$s копирано + Обриши ставку + Ставка обрисана + Обриши + Да ли сигурно желите да трајно избришете? Ово се не може поништити. + Подаци + Извези + Учитавање + Потврдите извоз + Овај извоз садржи податке у нешифрираном формату. Не бисте смели да сачувате или шаљете извезену датотеку преко несигурних канала (као што је имејл). Избришите датотеку одмах након што завршите са коришћењем. + Формат датотеке + Дошло је до проблема при извозу сефа. Ако се проблем настави, мораћете да извезете из веб сефа. + Податци успешно извежени + Откључај са %1$s + Биометријом + Сигурност + Користи биометријско откључавање + Превише неуспешних покушаја биометрије. + О апликацији + Верзија + Настави + Bitwarden помоћни центар + Настави на помоћни центар? + Learn more about how to use Bitwarden Authenticator у помоћни центар. + Полиса приватности + Желите ли да наставите са политиком приватности? + Погледајте нашу политику приватности на bitwarden.com + Кључ + Креирати верификациони кôд + Кључ је неопходан. + Име је неопходно. + Пошаљите евиденцију о паду + Дошло је до проблема при увозу вашег сефа. + Изворна датотека + Увоз + Увоз сефа је успео + Кључ је неважећи. + Сачувај као омиљен + Омиљено + Омиљени + Резервна копија + Повратак података + Направљена је резервна копија података Bitwarden Authenticator-а и могу се вратити помоћу редовних резервних копија уређаја. + Сазнај више + Увоз из 2ФА датотека заштићених лозинком није подржан. Покушајте поново са извезеном датотеком која није заштићена лозинком. + Увоз Bitwarden CSV датотека није подржан. Покушајте поново са извезеном JSON датотеком. + Преузмите апликацију Bitwarden + Чувајте све своје пријаве и синхронизујте верификационе кôдове директно са апликацијом Authenticator. + Преузми сада + Синхронизујте са апликацијом Bitwarden + Није могуће синхронизовати кодове из апликације Bitwarden. Уверите се да су обе апликације ажуриране. И даље можете да приступите својим постојећим кодовима у апликацији Bitwarden. + %1$s | %2$s + Синхронизујте са апликацијом Bitwarden + Иди у подешавања + Дозволите синхронизацију апликације Authenticator у подешавањима да бисте видели све своје верификационе кôдове овде. + Нешто није у реду + Покушајте поново + Пречи на Bitwarden + Подразумевана опција чувања + Сачувај у Bitwarden + Сачувај овде + Ниједан + Изаберите где желите да сачувате нове верификационе кодове. + Потврди + Верификациони кôд је креиран + Сачувајте овај кључ за аутентификацију овде или га додајте у пријаву у своју Bitwarden апликацију. + Сачувај опцију као подразумевану + Налог је синхронизован из апликације Bitwarden + Додај кôд у Bitwarden + Додај кôд локално + Локални кодови + Недостају потребне информације + "Потребне информације недостају (нпр. \"услуге\" или \"тајна\"). Проверите своју датотеку и покушајте поново. Посетите bitwarden.com/help за подршку" + "Није могуће обрадити датотеку" + "Датотека се не може обрадити. Осигурајте да је валидан JSON и покушајте поново. Требате помоћ? Посетите bitwarden.com/help" + Потражи помоћ + diff --git a/authenticator/src/main/res/values-sv-rSE/strings.xml b/authenticator/src/main/res/values-sv-rSE/strings.xml new file mode 100644 index 0000000000..5bdb30cd34 --- /dev/null +++ b/authenticator/src/main/res/values-sv-rSE/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometrisk verifiering + Avbryt + Lägg till objekt + Ett fel har inträffat. + We were unable to process your request. Please try again or contact us. + Internetanslutning krävs + Anslut till internet innan du fortsätter. + OK + Synkroniserar + Kopiera + Redigera + Stäng + Bitwarden Authenticator + Namn + Add Item Rotation + Skanna en QR-kod + Enter a setup key + Skanna QR-kod + Rikta din kamera mot QR-koden. + Kan inte skanna QR-kod. + Ange nyckel manuellt. + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Lägg till TOTP + Autentiseringsnyckel + Nej tack + Inställningar + Bevilja kamerabehörighet för att använda skannern + Empty Item Listing + Du har inga objekt att visa. + Lägg till en ny kod för att säkra dina konton. + Lägg till kod + Cannot read key. + Verifieringskod har lagts till + Användarnamn + Refresh period + Algoritm + Dölj + Visa + Avancerat + Collapse advanced options + Antal siffror + Spara + The %1$s field is required. + %d sekunder + Sparar + Objekt sparat + Information + OTP type + Verifieringskoder + There are no items that match the search + Tillbaka + Rensa + Search codes + Alternativ + Försök igen + Utseende + Standard (System) + Tema + Mörkt + Ljust + Språk + The language has been changed to %1$s. Please restart the app to see the change + Säkra dina konton med Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Använd enhetens kamera för att skanna koder + Scan the QR code in your 2-step verification settings for any account. + Logga in med unika koder + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Fortsätt + Hoppa över + Kom igång + Unika koder + Hjälp + Launch tutorial + %1$s kopierades + Radera objekt + Item deleted + Radera + Är du säker på att du vill radera permanent? Detta går inte att ångra. + Data + Exportera + Laddar + Bekräfta export + Denna export innehåller din data i ett okrypterat format. Du bör inte lagra eller skicka den exporterade filen över osäkra kanaler (t.ex. e-post). Radera den omedelbart när du är färdig med den. + Filformat + Det gick inte att exportera ditt valv. Om problemet kvarstår måste du exportera från webbvalvet istället. + Data exported successfully + Lås upp med %1$s + Biometri + Säkerhet + Use biometrics to unlock + Too many failed biometrics attempts. + Om + Version + Fortsätt + Bitwarden Hjälpcenter + Fortsätt till Hjälpcenter? + Läs mer om hur du använder Bitwarden Authenticator i hjälpcentret. + Integritetspolicy + Fortsätt till integritetspolicy? + Kolla in vår integritetspolicy på bitwarden.com + Nyckel + Skapa verifieringskod + Key is required. + Name is required. + Skicka kraschloggar + There was a problem importing your vault. + File Source + Importera + Vault import successful + Nyckel är ogiltig. + Spara som favorit + Favorit + Favoriter + Backup + Data backup + Bitwarden Authenticator-data säkerhetskopieras och kan återställas med dina regelbundna schemalagda säkerhetskopior. + Läs mer + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Import av Bitwarden CSV-filer stöds inte. Försök igen med en exporterad JSON-fil. + Download the Bitwarden app + Spara alla inloggningar och synkronisera verifieringskoder direkt med Authenticator-appen. + Download now + Synkronisera med Bitwarden-appen + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Synkronisera med Bitwarden-appen + Gå till inställningar + Allow Authenticator app syncing in settings to view all of your verification codes here. + Något gick fel + Försök igen + Move to Bitwarden + Default save option + Spara på Bitwarden + Spara här + Ingen + Select where you would like to save new verification codes. + Bekräfta + Verifieringskod skapades + Save this authenticator key here, or add it to a login in your Bitwarden app. + Spara alternativ som standard + Konto synkroniserat från Bitwarden-appen + Add code to Bitwarden + Lägg till kod lokalt + Lokala koder + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-ta-rIN/strings.xml b/authenticator/src/main/res/values-ta-rIN/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-ta-rIN/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-te-rIN/strings.xml b/authenticator/src/main/res/values-te-rIN/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-te-rIN/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-th-rTH/strings.xml b/authenticator/src/main/res/values-th-rTH/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-th-rTH/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-tr-rTR/strings.xml b/authenticator/src/main/res/values-tr-rTR/strings.xml new file mode 100644 index 0000000000..e76cd725c3 --- /dev/null +++ b/authenticator/src/main/res/values-tr-rTR/strings.xml @@ -0,0 +1,155 @@ + + + Kimlik Doğrulayıcı + Biyometrik doğrulama + İptal + Öğe ekle + Bir hata oluştu. + İsteğinizi işleyemedik. Lütfen tekrar deneyin veya bize ulaşın. + İnternet bağlantısı gerekli + Devam etmeden önce lütfen internete bağlanın. + Tamam + Senkronizasyon + Kopyala + Düzenle + Kapat + Bitwarden Kimlik Doğrulayıcı + Ad + Öğe Rotasyonu Ekle + Bir QR kodu tarayın + Bir kurulum anahtarı girin + QR kodunu tarayın + Kameranızı QR koduna yöneltin. + QR kodu taranamıyor. + Anahtarı elle girin. + Doğrulama anahtarını ekleyemediniz mi? + Anahtar başarıyla girildikten sonra,\nanahtarı güvenli bir şekilde saklamak için TOTP ekle\'yi seçin + TOTP ekle + Kimlik Doğrulayıcı anahtarı + Hayır teşekkürler + Ayarlar + Tarayıcıyı kullanmak için kamera iznini etkinleştirin + Boş Öğe Listesi + Görüntülenecek öğeniz yok. + Hesaplarınızı güvence altına almak için yeni bir kod ekleyin. + Kod ekle + Anahtar okunamıyor. + Doğrulama kodu eklendi + Kullanıcı adı + Yenileme süresi + Algoritma + Gizle + Göster + Gelişmiş + Gelişmiş seçenekleri daralt + Rakam sayısı + Kaydet + %1$s alanı gerekli. + %d saniye + Kaydediliyor + Öğe kaydedildi + Bilgi + OTP türü + Doğrulama kodları + Aramaya uygun öğe yok + Geri + Temizle + Kodları ara + Seçenekler + Tekrar deneyin + Görünüm + Varsayılan (Sistem) + Tema + Koyu + Açık + Dil + Dil %1$s olarak değiştirildi. Değişikliği görmek için uygulamayı yeniden başlatın + Hesaplarınızı Bitwarden Kimlik Doğrulayıcı ile güvence altına alın + 2 adımlı doğrulamayı destekleyen tüm hesaplarınız için doğrulama kodları alın. + Kodları taramak için cihaz kameranızı kullanın + Herhangi bir hesap için 2 adımlı doğrulama ayarlarınızdaki QR kodunu tarayın. + Benzersiz kodlarla oturum açın + 2 adımlı doğrulama kullanırken, kullanıcı adınızı ve şifrenizi ve bu uygulamada oluşturulan bir kodu girersiniz. + Devam et + Atla + Başlayın + Benzersiz kodlar + Yardım + Eğitimi başlat + %1$s kopyalandı + Öğeyi sil + Öğe silindi + Sil + Gerçekten kalıcı olarak silmek istiyor musunuz? Bu işlem geri alınamaz. + Veri + Dışa aktar + Yükleniyor + Dışa aktarmayı onayla + Bu dışa aktarım, verilerinizi şifrelenmemiş bir formatta içerir. Dışa aktarılan dosyayı güvenli olmayan kanallar üzerinden (e-posta gibi) saklamamalı veya göndermemelisiniz. İşiniz bittiğinde hemen silin. + Dosya formatı + Kasayı dışa aktarırken bir sorun oluştu. Sorun devam ederse, web kasasından dışa aktarmanız gerekecek. + Veriler başarıyla dışa aktarıldı + %1$s ile kilidi aç + Biyometri + Güvenlik + Kilidi açmak için biyometri kullanın + Çok fazla başarısız biyometrik deneme. + Hakkında + Versiyon + Devam et + Bitwarden Yardım Merkezi + Yardım Merkezine devam edilsin mi? + Bitwarden Kimlik Doğrulayıcıyı nasıl kullanacağınızı Yardım Merkezi\'nde öğrenin. + Gizlilik politikası + Gizlilik politikasına devam edilsin mi? + Gizlilik politikamıza bitwarden.com adresinden göz atın + Anahtar + Doğrulama kodu oluştur + Anahtar gerekli. + Ad gerekli. + Çökme günlüklerini gönder + Kasayı içe aktarırken bir sorun oluştu. + Dosya Kaynağı + İçe aktar + Kasa içe aktarma başarılı + Anahtar geçersiz. + Favori olarak kaydet + Favori + Favoriler + Yedekle + Veri yedekleme + Bitwarden Kimlik Doğrulayıcı verileri yedeklenir ve düzenli olarak planlanan cihaz yedeklemelerinizle geri yüklenebilir. + Daha fazla bilgi edin + Şifre korumalı 2FAS dosyalarından içe aktarma desteklenmiyor. Şifre korumasız bir dışa aktarılan dosya ile tekrar deneyin. + Bitwarden CSV dosyalarını içe aktarma desteklenmiyor. Bunun yerine dışa aktarılan bir JSON dosyası ile tekrar deneyin. + Bitwarden uygulamasını indir + Tüm giriş bilgilerinizi saklayın ve doğrulama kodlarını doğrudan Authenticator uygulaması ile senkronize edin. + Şimdi indir + Bitwarden uygulaması ile senkronize et + Bitwarden uygulamasından kodlar senkronize edilemiyor. Her iki uygulamanın da güncel olduğundan emin olun. Bitwarden uygulamasında mevcut kodlarınıza hala erişebilirsiniz. + %1$s | %2$s + Bitwarden uygulaması ile senkronize et + Ayarlara git + Tüm doğrulama kodlarınızı burada görüntülemek için ayarlardan Authenticator uygulaması senkronizasyonuna izin verin. + Bir sorun oluştu + Lütfen tekrar deneyin + Bitwarden\'a taşı + Varsayılan kaydetme seçeneği + Bitwarden\'a Kaydet + Buraya kaydet + Yok + Yeni doğrulama kodlarını nereye kaydetmek istediğinizi seçin. + Onayla + Doğrulama kodu oluşturuldu + Bu kimlik doğrulayıcı anahtarı buraya kaydedin veya Bitwarden uygulamanızdaki bir girişe ekleyin. + Seçeneği varsayılan olarak kaydet + Bitwarden uygulamasından eşitlenen hesap + Bitwarden\'a kod ekle + Kodu yerel olarak ekle + Yerel kodlar + Gerekli Bilgiler Eksik + "Gerekli bilgi eksik (örneğin, 'services' veya 'secret'). Dosyanızı kontrol edin ve tekrar deneyin. Destek için bitwarden.com/help adresini ziyaret edin" + "Dosya İşlenemedi" + "Dosya işlenemedi. Geçerli JSON olduğundan emin olun ve tekrar deneyin. Yardıma mı ihtiyacınız var? bitwarden.com/help adresini ziyaret edin" + Yardım Al + diff --git a/authenticator/src/main/res/values-uk-rUA/strings.xml b/authenticator/src/main/res/values-uk-rUA/strings.xml new file mode 100644 index 0000000000..2fb0f2b8f9 --- /dev/null +++ b/authenticator/src/main/res/values-uk-rUA/strings.xml @@ -0,0 +1,155 @@ + + + Автентифікатор + Біометрична перевірка + Скасувати + Додати запис + Сталася помилка. + Нам не вдалося обробити ваш запит. Спробуйте ще раз або зв\'яжіться з нами. + Необхідно під\'єднатися до інтернету + Щоб продовжити, під\'єднайтеся до інтернету. + OK + Синхронізація + Копіювати + Змінити + Закрити + Bitwarden Authenticator + Назва + Додати оновлення запису + Сканувати QR-код + Введіть ключ встановлення + Сканувати QR-код + Наведіть камеру на QR-код. + Не вдається сканувати QR-код. + Введіть ключ вручну. + Не вдається додати ключ автентифікації? + Після введення ключа виберіть\n\"Додати TOTP\", щоб надійно його зберегти + Додати TOTP + Ключ автентифікації + Ні, дякую + Налаштування + Увімкніть доступ до камери для використання сканера + Порожній список записів + У вас немає записів для показу. + Додайте новий код, щоб захистити свої облікові записи. + Додати код + Неможливо прочитати ключ. + Код підтвердження додано + Ім\'я користувача + Період оновлення + Алгоритм + Приховати + Показати + Додатково + Згорнути додаткові параметри + Кількість цифр + Зберегти + Поле %1$s є обов\'язковим. + %d секунд + Збереження + Запис збережено + Інформація + Тип OTP + Коди підтвердження + Немає записів, що відповідають пошуку + Назад + Стерти + Шукати коди + Налаштування + Спробуйте знову + Вигляд + Типова (Система) + Тема + Темна + Світла + Мова + Мову змінено на %1$s. Для застосування змін перезапустіть програму + Захистіть свої облікові записи за допомогою Bitwarden Authenticator + Отримуйте коди підтвердження для всіх облікових записів, які підтримують двоетапну перевірку. + Використовуйте камеру пристрою для сканування кодів + Скануйте QR-код у налаштуваннях двоетапної перевірки будь-якого облікового запису. + Входьте з використанням унікальних кодів + Використовуючи двоетапну перевірку, ви вводите ім\'я користувача, пароль, а також одноразовий код, згенерований цією програмою. + Продовжити + Пропустити + Початок роботи + Унікальні коди + Допомога + Запустити знайомство + %1$s скопійовано + Видалити запис + Запис видалено + Видалити + Ви дійсно хочете остаточно видалити? Цю дію неможливо скасувати. + Дані + Експорт + Завантаження + Підтвердити експорт + Ваші експортовані дані незашифровані. Не зберігайте і не надсилайте їх незахищеними каналами (як-от електронна пошта). Після використання негайно видаліть їх. + Формат файлу + Під час експорту ваших даних виникла проблема. Якщо вона не зникне, необхідно виконати експорт із вебсховища. + Дані успішно експортовано + Розблокування з %1$s + Біометрія + Безпека + Використовувати біометрію для розблокування + Забагато невдалих спроб біометричної перевірки. + Про програму + Версія + Продовжити + Довідковий центр Bitwarden + Відкрити довідковий центр? + Дізнайтеся більше про використання Bitwarden Authenticator у довідковому центрі. + Політика приватності + Перейти до політики приватності? + Ознайомтеся з нашою політикою приватності на bitwarden.com + Ключ + Створити код підтвердження + Необхідно ввести ключ. + Необхідно ввести назву. + Надсилати звіти про збої + Виникла проблема з імпортом вашого сховища. + Джерело файлу + Імпорт + Сховище успішно імпортовано + Недійсний ключ. + Зберегти, як обране + Обране + Обране + Резервне копіювання + Резервне копіювання даних + Резервне копіювання даних Bitwarden Authenticator відбувається за допомогою пристрою. Відновити дані можна будь-коли. + Докладніше + Імпортування даних із захищених паролем файлів 2FAS не підтримується. Повторіть спробу з експортованим файлом, який не захищено паролем. + Імпорт CSV-файлів Bitwarden не підтримується. Повторіть спробу з експортованим файлом JSON. + Завантажити програму Bitwarden + Зберігайте всі паролі та синхронізуйте коди підтвердження безпосередньо з програмою Authenticator. + Завантажити + Синхронізувати з програмою Bitwarden + Неможливо синхронізувати коди з програмою Bitwarden. Переконайтеся, що у вас найновіші версії обох програм. Ви все одно можете отримати доступ до наявних кодів у програмі Bitwarden. + %1$s | %2$s + Синхронізувати з програмою Bitwarden + Відкрити налаштування + Дозвольте синхронізацію програми Authenticator у налаштуваннях, щоб бачити тут свої коди підтвердження. + Щось пішло не так + Повторіть спробу + Перемістити до Bitwarden + Типовий параметр збереження + Зберегти в Bitwarden + Зберегти тут + Немає + Виберіть, де ви бажаєте зберігати нові коди підтвердження. + Підтвердити + Код підтвердження створено + Збережіть цей ключ автентифікації тут, або додайте його до запису в програмі Bitwarden. + Зробити цю дію типовою + Обліковий запис синхронізовано з програми Bitwarden + Додати код у Bitwarden + Додати код локально + Локальні коди + Відсутня необхідна інформація + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-v30/manifest.xml b/authenticator/src/main/res/values-v30/manifest.xml new file mode 100644 index 0000000000..5403cd7ad7 --- /dev/null +++ b/authenticator/src/main/res/values-v30/manifest.xml @@ -0,0 +1,5 @@ + + + + 0 + diff --git a/authenticator/src/main/res/values-vi-rVN/strings.xml b/authenticator/src/main/res/values-vi-rVN/strings.xml new file mode 100644 index 0000000000..0d10164f1f --- /dev/null +++ b/authenticator/src/main/res/values-vi-rVN/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Xác minh sinh trắc học + Hủy bỏ + Thêm mục + Đã xảy ra lỗi. + Chúng tôi không thể xử lý yêu cầu của bạn. Vui lòng thử lại hoặc liên hệ hỗ trợ. + Yêu cầu kết nối mạng + Hãy kết nối mạng trước khi tiếp tục. + Đồng Ý + Đang đồng bộ + Sao chép + Sửa + Đóng + Bitwarden Authenticator + Tên + Thêm chu kỳ làm mới mục + Quét mã QR + Nhập cài đặt khoá + Quét mã QR + Đưa camera của bạn hướng về phía mã QR. + Không thể quét mã QR. + Nhập khóa thủ công. + Cannot add authenticator key? + Khi nhập khóa thành công,\nchọn Thêm TOTP để lưu khóa an toàn + Thêm TOTP + Khóa xác thực + Không, cảm ơn + Cài đặt + Bật quyền của máy ảnh để sử dụng trình quét + Danh sách mục trống + Bạn không có mục nào để hiển thị. + Thêm mã mới để bảo mật tài khoản của bạn. + Thêm mã + Không thể đọc khoá. + Đã thêm mã xác minh + Tên người dùng + Chu kỳ làm mới + Thuật toán + Ẩn + Hiện + Nâng cao + Thu gọn các tuỳ chọn nâng cao + Số lượng chữ số + Lưu + Vui lòng nhập %1$s. + %d giây + Đang lưu + Đã lưu mục + Thông tin + Loại OTP + Mã xác minh + Không tìm thấy + Quay lại + Xóa + Tìm kiếm mã + Tùy chọn + Thử lại + Giao diện + Giống như hệ thống + Chủ đề + Tối + Sáng + Ngôn ngữ + Đã đổi ngôn ngữ thành %1$s. Khởi động lại ứng dụng để áp dụng + Bảo mật tài khoản của bạn với Bitwarden Authenticator + Nhận mã xác minh cho tất cả các tài khoản của bạn hỗ trợ xác minh 2 bước. + Sử dụng camera của thiết bị để quét mã + Quét mã QR trong các cài đặt xác minh hai bước của bạn cho bất kỳ tài khoản nào. + Đăng nhập bằng mã duy nhất + Khi sử dụng xác minh 2 bước, bạn sẽ nhập tên người dùng và mật khẩu của mình cũng như mã được tạo trong ứng dụng này. + Tiếp tục + Bỏ qua + Bắt đầu + Mã duy nhất + Trợ giúp + Bắt đầu hướng dẫn + Đã sao chép %1$s + Xóa mục + Đã xóa mục + Xóa + Bạn có chắc chắn muốn xóa vĩnh viễn không? Thao tác này không thể hoàn tác. + Dữ liệu + Xuất dữ liệu + Đang tải + Xác nhận xuất + Tập tin xuất này chứa dữ liệu mã xác minh của bạn dưới một định dạng không được mã hóa. Bạn không nên lưu trữ hay gửi tập tin này thông qua phương thức không an toàn (như email). Xóa nó ngay lập tức khi bạn đã dùng xong. + Định dạng tập tin + Xảy ra lỗi khi xuất kho của bạn. Nếu vấn đề vẫn tiếp diễn, hãy xuất trên bản web. + Đã xuất dữ liệu thành công + Mở khóa bằng %1$s + Sinh trắc học + Bảo mật + Dùng sinh trắc học để mở khóa + Xác thực sinh trắc thất bại quá nhiều lần. + Thông tin + Phiên bản + Tiếp tục + Trung tâm hỗ trợ Bitwarden + Tiếp tục tới Trung tâm trợ giúp? + Tìm hiểu thêm về cách sử dụng Bitwarden trong Trung tâm trợ giúp. + Chính sách Bảo mật + Đồng ý với chính sách bảo mật? + Đọc chính sách bảo mật trên bitwarden.com + Khóa + Tạo mã xác minh + Khoá là bắt buộc. + Tên là bắt buộc. + Gửi nhật ký lỗi + Xảy ra lỗi khi nhập kho của bạn. + Tập tin nguồn + Nhập + Nhập kho hoàn tất + Khoá không hợp lệ. + Lưu thành ưa thích + Yêu thích + Yêu thích + Sao lưu + Sao lưu dữ liệu + Dữ liệu của Bitwarden Authenticator đã được sao lưu và có thể được khôi phục bằng bản sao lưu thiết bị định kỳ của bạn. + Tìm hiểu thêm + Ứng dụng không hỗ trợ nhập từ các tập tin được bảo mật bằng mật khẩu 2FAS. Vui lòng thử lại với một tập tin xuất khẩu không được bảo mật bằng mật khẩu. + Nhập tập tin CSV Bitwarden không được hỗ trợ. Vui lòng thử lại với tập tin JSON đã xuất. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-zh-rCN/strings.xml b/authenticator/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000000..d8df471a51 --- /dev/null +++ b/authenticator/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,156 @@ + + + 身份验证器 + 生物识别验证 + Cancel + 添加项目 + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + 确定 + 同步中 + Copy + Edit + Close + Bitwarden Authenticator + Name + 添加轮换项目 + 扫描二维码 + 输入设置密钥 + 扫描二维码 + 将您的相机对准二维码 + 无法扫描二维码。 + 手动输入密钥。 + Cannot add authenticator key? + 成功输入密钥后,\n点击「添加 TOTP」即可安全地存储密钥 + 添加 TOTP + 验证器密钥 + 不,谢谢 + Settings + 启用相机权限以扫描二维码 + 空列表 + 您没有任何可显示的项目。 + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + 隐藏 + 显示 + Advanced + 折叠高级选项 + Number of digits + Save + The %1$s field is required. + %d 秒 + 保存中 + 项目已保存 + 信息 + OTP 类型 + Verification codes + There are no items that match the search + 返回 + 清除 + 搜索动态密码 + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + 为您所有支持两步验证的帐户获取验证码。 + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + 当使用两步验证时,您将输入您的用户名和密码以及在此应用中生成的代码。 + Continue + Skip + Get started + 唯一验证码 + Help + Launch tutorial + %1$s copied + 删除项目 + Item deleted + Delete + 您确定要永久删除吗?此操作无法撤销。 + Data + Export + 加载中 + 确认导出 + 导出的数据包含未加密的格式。请不要在不安全的渠道(如电子邮件)上存储或发送导出的文件。使 +用后请立即删除。 + File format + 导出密码库出现问题。如果问题持续存在,请从网页版密码库导出。 + 数据导出成功 + Unlock with %1$s + 生物识别 + Security + Use biometrics to unlock + 生物识别尝试次数过多。 + About + Version + Continue + Bitwarden Help Center + 前往帮助中心吗? + 在帮助中心了解更多如何使用 Bitwarden Authenticator 的信息。 + 隐私政策 + Continue to privacy policy? + 在 bitwarden.com 上查看我们的隐私政策。 + Key + 创建验证码 + 密钥不能为空。 + 账户名不能为空。 + 提交崩溃日志 + 导入密码库时出现问题。 + 文件来源 + Import + 密码库导入成功 + 密钥无效。 + 收藏 + Favorite + Favorites + Backup + 数据备份 + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + 不支持导入 Bitwarden CSV 文件。请使用导出的 JSON 文件重试。 + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + 现在就下载 + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values-zh-rTW/strings.xml b/authenticator/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000000..75c2c3c63a --- /dev/null +++ b/authenticator/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,155 @@ + + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values/colors.xml b/authenticator/src/main/res/values/colors.xml new file mode 100644 index 0000000000..88a112415d --- /dev/null +++ b/authenticator/src/main/res/values/colors.xml @@ -0,0 +1,81 @@ + + + + @color/blue_175DDC + @color/white_FFFFFF + @color/blue_DAE2FF + @color/blue_001848 + @color/grey_585E71 + @color/white_FFFFFF + @color/grey_DDE2F9 + @color/blue_151B2C + @color/blue_3B5BA9 + @color/white_FFFFFF + @color/blue_DAE2FF + @color/blue_001848 + @color/red_BA1A1A + @color/white_FFFFFF + @color/red_FFDAD6 + @color/red_410002 + @color/grey_DBD9DD + @color/grey_FBF8FD + @color/grey_DDE3EA + @color/grey_FBF8FD + @color/white_FFFFFF + @color/white_F5F3F7 + @color/white_EFEDF1 + @color/white_E9E7EC + @color/white_E4E2E6 + @color/grey_1B1B1F + @color/grey_45464F + @color/grey_757780 + @color/white_C5C6D0 + @color/blue_020F66 + @color/grey_F2F0F4 + @color/blue_B2C5FF + @color/black_000000 + + + + @color/blue_B2C5FF + @color/blue_002B74 + @color/blue_003FA3 + @color/blue_DAE2FF + @color/grey_C0C6DD + @color/grey_2A3042 + @color/grey_404659 + @color/grey_DDE2F9 + @color/blue_B2C5FF + @color/blue_002B73 + @color/blue_1F438F + @color/blue_DAE2FF + @color/red_FFB4AB + @color/red_690005 + @color/red_93000A + @color/red_FFDAD6 + @color/grey_131316 + @color/grey_131316 + @color/grey_45464F + @color/grey_39393C + @color/grey_0D0E11 + @color/grey_1B1B1F + @color/grey_1F1F23 + @color/grey_292A2D + @color/grey_343438 + @color/grey_C7C6CA + @color/white_C5C6D0 + @color/grey_8F909A + @color/grey_45464F + @color/white_E4E2E6 + @color/grey_1B1B1F + @color/blue_0055D4 + @color/black_000000 + + + + @color/grey_333333 + @color/magenta_C01176 + @color/magenta_F08DC7 + @color/blue_B2C5FF + + \ No newline at end of file diff --git a/authenticator/src/main/res/values/colors_palette.xml b/authenticator/src/main/res/values/colors_palette.xml new file mode 100644 index 0000000000..6b01e9b3bc --- /dev/null +++ b/authenticator/src/main/res/values/colors_palette.xml @@ -0,0 +1,62 @@ + + + + #DAE2FF + #B2C5FF + #175DDC + #0055D4 + #3B5BA9 + #1F438F + #003FA3 + #002B74 + #002B73 + #001848 + #020F66 + #151B2C + + + #FBF8FD + #DDE3EA + #DDE2F9 + #F2F0F4 + #DBD9DD + #C7C6CA + #8F909A + #C0C6DD + #757780 + #585E71 + #45464F + #404659 + #333333 + #303034 + #2A3042 + #39393C + #292A2D + #343438 + #1F1F23 + #1B1B1F + #131316 + #0D0E11 + + + #FFDAD6 + #FFB4AB + #BA1A1A + #93000A + #690005 + #410002 + + + #FFFFFF + #F5F3F7 + #EFEDF1 + #E9E7EC + #C5C6D0 + #E4E2E6 + + + #000000 + #FFF08DC7 + #FFC01176 + + diff --git a/authenticator/src/main/res/values/ic_launcher_background.xml b/authenticator/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000..7ee472988b --- /dev/null +++ b/authenticator/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + @color/blue_020F66 + diff --git a/authenticator/src/main/res/values/manifest.xml b/authenticator/src/main/res/values/manifest.xml new file mode 100644 index 0000000000..c1a5e49e0d --- /dev/null +++ b/authenticator/src/main/res/values/manifest.xml @@ -0,0 +1,5 @@ + + + + 2 + diff --git a/authenticator/src/main/res/values/strings.xml b/authenticator/src/main/res/values/strings.xml new file mode 100644 index 0000000000..148f630599 --- /dev/null +++ b/authenticator/src/main/res/values/strings.xml @@ -0,0 +1,154 @@ + + Authenticator + Biometric verification + Cancel + Add item + An error has occurred. + We were unable to process your request. Please try again or contact us. + Internet connection required + Please connect to the internet before continuing. + OK + Syncing + Copy + Edit + Close + Bitwarden Authenticator + Name + Add Item Rotation + Scan a QR code + Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner + Empty Item Listing + You don\'t have any items to display. + Add a new code to secure your accounts. + Add code + Cannot read key. + Verification code added + Username + Refresh period + Algorithm + Hide + Show + Advanced + Collapse advanced options + Number of digits + Save + The %1$s field is required. + %d seconds + Saving + Item saved + Information + OTP type + Verification codes + There are no items that match the search + Back + Clear + Search codes + Options + Try again + Appearance + Default (System) + Theme + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Secure your accounts with Bitwarden Authenticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you\'ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Unique codes + Help + Launch tutorial + %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone. + Data + Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\'ll need to export from the web vault. + Data exported successfully + Unlock with %1$s + Biometrics + Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com + Key + Create Verification code + Key is required. + Name is required. + Submit crash logs + There was a problem importing your vault. + File Source + Import + Vault import successful + Key is invalid. + Save as a favorite + Favorite + Favorites + Backup + Data backup + Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups. + Learn more + Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected. + Importing Bitwarden CSV files is not supported. Try again with an exported JSON file. + Download the Bitwarden app + Store all of your logins and sync verification codes directly with the Authenticator app. + Download now + Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s + Sync with the Bitwarden app + Go to settings + Allow Authenticator app syncing in settings to view all of your verification codes here. + Something went wrong + Please try again + Move to Bitwarden + Default save option + Save to Bitwarden + Save here + None + Select where you would like to save new verification codes. + Confirm + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default + Account synced from Bitwarden app + Add code to Bitwarden + Add code locally + Local codes + Required Information Missing + "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" + "File Could Not Be Processed" + "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" + Get Help + diff --git a/authenticator/src/main/res/values/strings_non_localized.xml b/authenticator/src/main/res/values/strings_non_localized.xml new file mode 100644 index 0000000000..491cda2503 --- /dev/null +++ b/authenticator/src/main/res/values/strings_non_localized.xml @@ -0,0 +1,17 @@ + + + .json + .csv + Bitwarden (.json) + 2FAS (no password) + LastPass (.json) + Aegis (.json) + + + Feature Flags: + Debug Menu + Reset values + Bitwarden authentication enabled + Password manager sync + + diff --git a/authenticator/src/main/res/values/styles.xml b/authenticator/src/main/res/values/styles.xml new file mode 100644 index 0000000000..92757f7013 --- /dev/null +++ b/authenticator/src/main/res/values/styles.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/authenticator/src/main/res/xml/backup_rules.xml b/authenticator/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000000..8a4f1631c0 --- /dev/null +++ b/authenticator/src/main/res/xml/backup_rules.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/authenticator/src/main/res/xml/data_extraction_rules.xml b/authenticator/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000000..426b68af44 --- /dev/null +++ b/authenticator/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/MainViewModelTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/MainViewModelTest.kt new file mode 100644 index 0000000000..6b979590d2 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/MainViewModelTest.kt @@ -0,0 +1,72 @@ +package com.bitwarden.authenticator + +import app.cash.turbine.test +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.data.platform.repository.util.FakeServerConfigRepository +import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class MainViewModelTest : BaseViewModelTest() { + + private val mutableAppThemeFlow = MutableStateFlow(AppTheme.DEFAULT) + private val mutableScreenCaptureAllowedFlow = MutableStateFlow(false) + private val settingsRepository = mockk { + every { appTheme } returns AppTheme.DEFAULT + every { appThemeStateFlow } returns mutableAppThemeFlow + every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedFlow + } + private val fakeServerConfigRepository = FakeServerConfigRepository() + private lateinit var mainViewModel: MainViewModel + + @BeforeEach + fun setUp() { + mainViewModel = MainViewModel( + settingsRepository, + fakeServerConfigRepository, + ) + } + + @Test + fun `on AppThemeChanged should update state`() { + assertEquals( + MainState( + theme = AppTheme.DEFAULT, + ), + mainViewModel.stateFlow.value, + ) + mainViewModel.trySendAction( + MainAction.Internal.ThemeUpdate( + theme = AppTheme.DARK, + ), + ) + assertEquals( + MainState( + theme = AppTheme.DARK, + ), + mainViewModel.stateFlow.value, + ) + + verify { + settingsRepository.appTheme + settingsRepository.appThemeStateFlow + } + } + + @Test + fun `send NavigateToDebugMenu action when OpenDebugMenu action is sent`() = runTest { + mainViewModel.trySendAction(MainAction.OpenDebugMenu) + + mainViewModel.eventFlow.test { + awaitItem() // ignore first event + assertEquals(MainEvent.NavigateToDebugMenu, awaitItem()) + } + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceTest.kt new file mode 100644 index 0000000000..b738d1d97f --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -0,0 +1,40 @@ +package com.bitwarden.authenticator.data.auth.datasource.disk + +import com.bitwarden.authenticator.data.platform.base.FakeSharedPreferences +import com.bitwarden.authenticatorbridge.util.generateSecretKey +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class AuthDiskSourceTest { + + private val fakeEncryptedSharedPreferences = FakeSharedPreferences() + private val fakeSharedPreferences = FakeSharedPreferences() + + private val authDiskSource = AuthDiskSourceImpl( + encryptedSharedPreferences = fakeEncryptedSharedPreferences, + sharedPreferences = fakeSharedPreferences, + ) + + @Test + @Suppress("MaxLineLength") + fun `authenticatorBridgeSymmetricSyncKey should store and update from EncryptedSharedPreferences`() { + val sharedPrefsKey = "bwSecureStorage:authenticatorSyncSymmetricKey" + + // Shared preferences and the repository start with the same value: + assertNull(authDiskSource.authenticatorBridgeSymmetricSyncKey) + assertNull(fakeEncryptedSharedPreferences.getString(sharedPrefsKey, null)) + + // Updating the repository updates shared preferences: + val symmetricKey = generateSecretKey().getOrThrow().encoded + authDiskSource.authenticatorBridgeSymmetricSyncKey = symmetricKey + assertEquals( + symmetricKey.toString(Charsets.ISO_8859_1), + fakeEncryptedSharedPreferences.getString(sharedPrefsKey, null), + ) + + // Retrieving the key from repository should give same byte array despite String conversion: + assertTrue(authDiskSource.authenticatorBridgeSymmetricSyncKey.contentEquals(symmetricKey)) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt new file mode 100644 index 0000000000..f5cb87320c --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -0,0 +1,23 @@ +package com.bitwarden.authenticator.data.auth.datasource.disk.util + +import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource + +class FakeAuthDiskSource : AuthDiskSource { + + private var lastActiveTimeMillis: Long? = null + private var userBiometricUnlockKey: String? = null + + override fun getLastActiveTimeMillis(): Long? = lastActiveTimeMillis + + override fun storeLastActiveTimeMillis(lastActiveTimeMillis: Long?) { + this@FakeAuthDiskSource.lastActiveTimeMillis = lastActiveTimeMillis + } + + override fun getUserBiometricUnlockKey(): String? = userBiometricUnlockKey + + override fun storeUserBiometricUnlockKey(biometricsKey: String?) { + this@FakeAuthDiskSource.userBiometricUnlockKey = biometricsKey + } + + override var authenticatorBridgeSymmetricSyncKey: ByteArray? = null +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/datasource/disk/util/FakeAuthenticatorDiskSource.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/datasource/disk/util/FakeAuthenticatorDiskSource.kt new file mode 100644 index 0000000000..5663444d82 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/datasource/disk/util/FakeAuthenticatorDiskSource.kt @@ -0,0 +1,23 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.disk.util + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +class FakeAuthenticatorDiskSource : AuthenticatorDiskSource { + private val mutableItemFlow = MutableSharedFlow>() + private val storedItems = mutableListOf() + + override suspend fun saveItem(vararg authenticatorItem: AuthenticatorItemEntity) { + storedItems.addAll(authenticatorItem) + mutableItemFlow.emit(storedItems) + } + + override fun getItems(): Flow> = mutableItemFlow + + override suspend fun deleteItem(itemId: String) { + storedItems.removeIf { it.id == itemId } + mutableItemFlow.emit(storedItems) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/datasource/entity/AuthenticatorItemEntityUtil.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/datasource/entity/AuthenticatorItemEntityUtil.kt new file mode 100644 index 0000000000..48f5a2fa98 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/datasource/entity/AuthenticatorItemEntityUtil.kt @@ -0,0 +1,19 @@ +package com.bitwarden.authenticator.data.authenticator.datasource.entity + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager + +fun createMockAuthenticatorItemEntity(number: Int): AuthenticatorItemEntity = + AuthenticatorItemEntity( + id = "mockId-$number", + key = "mockKey-$number", + type = AuthenticatorItemType.TOTP, + algorithm = TotpCodeManager.ALGORITHM_DEFAULT, + period = TotpCodeManager.PERIOD_SECONDS_DEFAULT, + digits = TotpCodeManager.TOTP_DIGITS_DEFAULT, + issuer = "mockIssuer-$number", + userId = null, + accountName = "mockAccountName-$number", + favorite = false, + ) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/manager/util/TotpCodeManagerTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/manager/util/TotpCodeManagerTest.kt new file mode 100644 index 0000000000..647c0b9cfd --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/manager/util/TotpCodeManagerTest.kt @@ -0,0 +1,36 @@ +package com.bitwarden.authenticator.data.authenticator.manager.util + +import app.cash.turbine.test +import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource +import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManagerImpl +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +class TotpCodeManagerTest { + + private val clock: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ) + private val authenticatorSdkSource: AuthenticatorSdkSource = mockk() + + private val manager = TotpCodeManagerImpl( + authenticatorSdkSource = authenticatorSdkSource, + clock = clock, + ) + + @Test + fun `getTotpCodesFlow should return flow that emits empty list when input list is empty`() = + runTest { + manager.getTotpCodesFlow(emptyList()).test { + assertEquals(emptyList(), awaitItem()) + awaitComplete() + } + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/manager/util/VerificationCodeItemUtil.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/manager/util/VerificationCodeItemUtil.kt new file mode 100644 index 0000000000..4edcd3fd34 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/manager/util/VerificationCodeItemUtil.kt @@ -0,0 +1,29 @@ +package com.bitwarden.authenticator.data.authenticator.manager.util + +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem + +/** + * Creates a mock [VerificationCodeItem] for testing purposes. + * + * @param number A number used to generate unique values for the mock item. + * @param favorite Whether the mock item should be marked as favorite. Defaults to false. + * @return A [VerificationCodeItem] with mock data based on the provided number. + */ +fun createMockVerificationCodeItem( + number: Int, + favorite: Boolean = false, +): VerificationCodeItem = + VerificationCodeItem( + code = "mockCode-$number", + periodSeconds = 30, + timeLeftSeconds = 120, + issueTime = 0, + id = "mockId-$number", + label = "mockLabel-$number", + issuer = "mockIssuer-$number", + source = AuthenticatorItem.Source.Local( + cipherId = "mockId-$number", + isFavorite = favorite, + ), + ) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryTest.kt new file mode 100644 index 0000000000..27a91eeaab --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryTest.kt @@ -0,0 +1,246 @@ +package com.bitwarden.authenticator.data.authenticator.repository + +import app.cash.turbine.test +import com.bitwarden.authenticator.data.authenticator.datasource.disk.util.FakeAuthenticatorDiskSource +import com.bitwarden.authenticator.data.authenticator.datasource.entity.createMockAuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.manager.FileManager +import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.authenticator.repository.util.toAuthenticatorItems +import com.bitwarden.authenticator.data.platform.base.FakeDispatcherManager +import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager +import com.bitwarden.authenticator.data.platform.manager.imports.ImportManager +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.data.platform.repository.model.DataState +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState +import com.bitwarden.authenticatorbridge.model.SharedAccountData +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AuthenticatorRepositoryTest { + + private val mutableAccountSyncStateFlow = + MutableStateFlow(AccountSyncState.Loading) + private val fakeAuthenticatorDiskSource = FakeAuthenticatorDiskSource() + private val mockAuthenticatorBridgeManager: AuthenticatorBridgeManager = mockk { + every { accountSyncStateFlow } returns mutableAccountSyncStateFlow + } + private val mockTotpCodeManager = mockk() + private val mockFileManager = mockk() + private val mockImportManager = mockk() + private val mockDispatcherManager = FakeDispatcherManager() + private val mockFeatureFlagManager = mockk { + every { getFeatureFlag(FlagKey.PasswordManagerSync) } returns true + } + private val settingsRepository: SettingsRepository = mockk { + every { previouslySyncedBitwardenAccountIds } returns emptySet() + } + + private val authenticatorRepository = AuthenticatorRepositoryImpl( + authenticatorDiskSource = fakeAuthenticatorDiskSource, + authenticatorBridgeManager = mockAuthenticatorBridgeManager, + featureFlagManager = mockFeatureFlagManager, + totpCodeManager = mockTotpCodeManager, + fileManager = mockFileManager, + importManager = mockImportManager, + dispatcherManager = mockDispatcherManager, + settingRepository = settingsRepository, + ) + + @BeforeEach + fun setup() { + mockkStatic(List::toAuthenticatorItems) + } + + @AfterEach + fun teardown() { + unmockkStatic(List::toAuthenticatorItems) + } + + @Test + fun `ciphersStateFlow initial state should be loading`() = runTest { + authenticatorRepository.ciphersStateFlow.test { + assertEquals( + DataState.Loading, + awaitItem(), + ) + } + } + + @Test + fun `sharedCodesStateFlow value should be FeatureNotEnabled when feature flag is off`() { + every { + mockFeatureFlagManager.getFeatureFlag(FlagKey.PasswordManagerSync) + } returns false + val repository = AuthenticatorRepositoryImpl( + authenticatorDiskSource = fakeAuthenticatorDiskSource, + authenticatorBridgeManager = mockAuthenticatorBridgeManager, + featureFlagManager = mockFeatureFlagManager, + totpCodeManager = mockTotpCodeManager, + fileManager = mockFileManager, + importManager = mockImportManager, + dispatcherManager = mockDispatcherManager, + settingRepository = settingsRepository, + ) + assertEquals( + SharedVerificationCodesState.FeatureNotEnabled, + repository.sharedCodesStateFlow.value, + ) + } + + @Test + fun `ciphersStateFlow should emit sorted authenticator items when disk source changes`() = + runTest { + val mockItem = createMockAuthenticatorItemEntity(1) + fakeAuthenticatorDiskSource.saveItem(mockItem) + assertEquals( + DataState.Loaded(listOf(mockItem)), + authenticatorRepository.ciphersStateFlow.value, + ) + } + + @Test + fun `sharedCodesStateFlow should emit FeatureNotEnabled when feature flag is off`() = runTest { + every { + mockFeatureFlagManager.getFeatureFlag(FlagKey.PasswordManagerSync) + } returns false + val repository = AuthenticatorRepositoryImpl( + authenticatorDiskSource = fakeAuthenticatorDiskSource, + authenticatorBridgeManager = mockAuthenticatorBridgeManager, + featureFlagManager = mockFeatureFlagManager, + totpCodeManager = mockTotpCodeManager, + fileManager = mockFileManager, + importManager = mockImportManager, + dispatcherManager = mockDispatcherManager, + settingRepository = settingsRepository, + ) + repository.sharedCodesStateFlow.test { + assertEquals( + SharedVerificationCodesState.FeatureNotEnabled, + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `sharedCodesStateFlow should emit AppNotInstalled when authenticatorBridgeManager emits AppNotInstalled`() = + runTest { + mutableAccountSyncStateFlow.value = AccountSyncState.AppNotInstalled + authenticatorRepository.sharedCodesStateFlow.test { + assertEquals(SharedVerificationCodesState.AppNotInstalled, awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `sharedCodesStateFlow should emit SyncNotEnabled when authenticatorBridgeManager emits SyncNotEnabled`() = + runTest { + mutableAccountSyncStateFlow.value = AccountSyncState.SyncNotEnabled + authenticatorRepository.sharedCodesStateFlow.test { + assertEquals(SharedVerificationCodesState.SyncNotEnabled, awaitItem()) + } + } + + @Test + fun `sharedCodesStateFlow should emit Error when authenticatorBridgeManager emits Error`() = + runTest { + mutableAccountSyncStateFlow.value = AccountSyncState.Error + authenticatorRepository.sharedCodesStateFlow.test { + assertEquals(SharedVerificationCodesState.Error, awaitItem()) + } + } + + @Test + fun `sharedCodesStateFlow should emit Loading when authenticatorBridgeManager emits Loading`() = + runTest { + mutableAccountSyncStateFlow.value = AccountSyncState.Loading + authenticatorRepository.sharedCodesStateFlow.test { + assertEquals(SharedVerificationCodesState.Loading, awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `sharedCodesStateFlow should emit OsVersionNotSupported when authenticatorBridgeManager emits OsVersionNotSupported`() = + runTest { + mutableAccountSyncStateFlow.value = AccountSyncState.OsVersionNotSupported + authenticatorRepository.sharedCodesStateFlow.test { + assertEquals(SharedVerificationCodesState.OsVersionNotSupported, awaitItem()) + } + } + + @Test + fun `sharedCodesStateFlow should emit Success when authenticatorBridgeManager emits Success`() = + runTest { + val sharedAccounts = emptyList() + val authenticatorItems = mockk>() + val verificationCodes = mockk>() + every { sharedAccounts.toAuthenticatorItems() } returns authenticatorItems + every { + mockTotpCodeManager.getTotpCodesFlow(authenticatorItems) + } returns flowOf(verificationCodes) + authenticatorRepository.sharedCodesStateFlow.test { + assertEquals(SharedVerificationCodesState.Loading, awaitItem()) + mutableAccountSyncStateFlow.value = AccountSyncState.Success(sharedAccounts) + assertEquals(SharedVerificationCodesState.Success(verificationCodes), awaitItem()) + } + } + + @Test + @Suppress("MaxLineLength") + fun `firstTimeAccountSyncFlow should emit the first time an account syncs and update SettingsRepository`() = + runTest { + every { settingsRepository.previouslySyncedBitwardenAccountIds = setOf("1") } just runs + val sharedAccounts = listOf( + SharedAccountData.Account( + userId = "1", + name = null, + email = "test@test.com", + environmentLabel = "bitwarden.com", + totpUris = emptyList(), + ), + ) + authenticatorRepository.firstTimeAccountSyncFlow.test { + mutableAccountSyncStateFlow.value = AccountSyncState.Success(sharedAccounts) + awaitItem() + } + verify { settingsRepository.previouslySyncedBitwardenAccountIds = setOf("1") } + } + + @Test + @Suppress("MaxLineLength") + fun `firstTimeAccountSyncFlow should not emit if a synced account is already in previouslySyncedBitwardenAccountIds`() = + runTest { + every { settingsRepository.previouslySyncedBitwardenAccountIds } returns setOf("1") + val sharedAccounts = listOf( + SharedAccountData.Account( + userId = "1", + name = null, + email = "test@test.com", + environmentLabel = "bitwarden.com", + totpUris = emptyList(), + ), + ) + authenticatorRepository.firstTimeAccountSyncFlow.test { + mutableAccountSyncStateFlow.value = AccountSyncState.Success(sharedAccounts) + expectNoEvents() + } + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/util/AuthenticatorItemEntityExtensionsTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/util/AuthenticatorItemEntityExtensionsTest.kt new file mode 100644 index 0000000000..7551d301ee --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/util/AuthenticatorItemEntityExtensionsTest.kt @@ -0,0 +1,42 @@ +package com.bitwarden.authenticator.data.authenticator.repository.util + +import com.bitwarden.authenticator.data.authenticator.datasource.entity.createMockAuthenticatorItemEntity +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AuthenticatorItemEntityExtensionsTest { + @Suppress("MaxLineLength") + @Test + fun `toSortAlphabetically should sort ciphers by name`() { + val list = listOf( + createMockAuthenticatorItemEntity(1).copy(issuer = "c"), + createMockAuthenticatorItemEntity(1).copy(issuer = "B"), + createMockAuthenticatorItemEntity(1).copy(issuer = "z"), + createMockAuthenticatorItemEntity(1).copy(issuer = "8"), + createMockAuthenticatorItemEntity(1).copy(issuer = "7"), + createMockAuthenticatorItemEntity(1).copy(issuer = "_"), + createMockAuthenticatorItemEntity(1).copy(issuer = "A"), + createMockAuthenticatorItemEntity(1).copy(issuer = "D"), + createMockAuthenticatorItemEntity(1).copy(issuer = "AbA"), + createMockAuthenticatorItemEntity(1).copy(issuer = "aAb"), + ) + + val expected = listOf( + createMockAuthenticatorItemEntity(1).copy(issuer = "_"), + createMockAuthenticatorItemEntity(1).copy(issuer = "7"), + createMockAuthenticatorItemEntity(1).copy(issuer = "8"), + createMockAuthenticatorItemEntity(1).copy(issuer = "aAb"), + createMockAuthenticatorItemEntity(1).copy(issuer = "A"), + createMockAuthenticatorItemEntity(1).copy(issuer = "AbA"), + createMockAuthenticatorItemEntity(1).copy(issuer = "B"), + createMockAuthenticatorItemEntity(1).copy(issuer = "c"), + createMockAuthenticatorItemEntity(1).copy(issuer = "D"), + createMockAuthenticatorItemEntity(1).copy(issuer = "z"), + ) + + assertEquals( + expected, + list.sortAlphabetically(), + ) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/util/SharedVerificationCodesStateExtensionsTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/util/SharedVerificationCodesStateExtensionsTest.kt new file mode 100644 index 0000000000..0d41bc596b --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/util/SharedVerificationCodesStateExtensionsTest.kt @@ -0,0 +1,38 @@ +package com.bitwarden.authenticator.data.authenticator.repository.util + +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SharedVerificationCodesStateExtensionsTest { + + @Test + @Suppress("MaxLineLength") + fun `isSyncWithBitwardenEnabled should return true only when SharedVerificationCodesState is Success `() { + assertFalse(SharedVerificationCodesState.AppNotInstalled.isSyncWithBitwardenEnabled) + assertFalse(SharedVerificationCodesState.Error.isSyncWithBitwardenEnabled) + assertFalse(SharedVerificationCodesState.FeatureNotEnabled.isSyncWithBitwardenEnabled) + assertFalse(SharedVerificationCodesState.Loading.isSyncWithBitwardenEnabled) + assertFalse(SharedVerificationCodesState.OsVersionNotSupported.isSyncWithBitwardenEnabled) + assertFalse(SharedVerificationCodesState.SyncNotEnabled.isSyncWithBitwardenEnabled) + assertTrue(SharedVerificationCodesState.Success(emptyList()).isSyncWithBitwardenEnabled) + } + + @Test + @Suppress("MaxLineLength") + fun `itemsOrEmpty should return a non empty list only when state is Success `() { + assertTrue(SharedVerificationCodesState.AppNotInstalled.itemsOrEmpty.isEmpty()) + assertTrue(SharedVerificationCodesState.Error.itemsOrEmpty.isEmpty()) + assertTrue(SharedVerificationCodesState.FeatureNotEnabled.itemsOrEmpty.isEmpty()) + assertTrue(SharedVerificationCodesState.Loading.itemsOrEmpty.isEmpty()) + assertTrue(SharedVerificationCodesState.OsVersionNotSupported.itemsOrEmpty.isEmpty()) + assertTrue(SharedVerificationCodesState.SyncNotEnabled.itemsOrEmpty.isEmpty()) + assertFalse( + SharedVerificationCodesState.Success( + listOf(mockk()), + ).itemsOrEmpty.isEmpty(), + ) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/util/SymmetricKeyStorageProviderTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/util/SymmetricKeyStorageProviderTest.kt new file mode 100644 index 0000000000..c75bff7161 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/util/SymmetricKeyStorageProviderTest.kt @@ -0,0 +1,50 @@ +package com.bitwarden.authenticator.data.authenticator.repository.util + +import com.bitwarden.authenticator.data.auth.datasource.disk.util.FakeAuthDiskSource +import com.bitwarden.authenticatorbridge.util.generateSecretKey +import com.bitwarden.authenticatorbridge.util.toSymmetricEncryptionKeyData +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SymmetricKeyStorageProviderTest { + + private val fakeAuthDiskSource = FakeAuthDiskSource() + + private val provider = SymmetricKeyStorageProviderImpl( + authDiskSource = fakeAuthDiskSource, + ) + + @Test + fun `symmetricKey get should return null when disk source has no symmetric key`() { + fakeAuthDiskSource.authenticatorBridgeSymmetricSyncKey = null + assertNull(provider.symmetricKey) + } + + @Test + fun `symmetricKey get should return symmetric key when disk source has symmetric key`() { + val key = generateSecretKey().getOrThrow() + fakeAuthDiskSource.authenticatorBridgeSymmetricSyncKey = key.encoded + assertEquals( + key.encoded.toSymmetricEncryptionKeyData(), + provider.symmetricKey, + ) + } + + @Test + fun `symmetricKey set should store key in AuthDiskSource`() { + val key = generateSecretKey().getOrThrow() + fakeAuthDiskSource.authenticatorBridgeSymmetricSyncKey = null + + provider.symmetricKey = key.encoded.toSymmetricEncryptionKeyData() + assertTrue( + key.encoded.contentEquals( + fakeAuthDiskSource.authenticatorBridgeSymmetricSyncKey, + ), + ) + + provider.symmetricKey = null + assertNull(fakeAuthDiskSource.authenticatorBridgeSymmetricSyncKey) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/base/BaseServiceTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/base/BaseServiceTest.kt new file mode 100644 index 0000000000..d99167b15c --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/base/BaseServiceTest.kt @@ -0,0 +1,35 @@ +package com.bitwarden.authenticator.data.platform.base + +import com.bitwarden.authenticator.data.platform.datasource.network.core.ResultCallAdapterFactory +import com.bitwarden.authenticator.data.platform.datasource.network.di.PlatformNetworkModule +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory + +/** + * Base class for service tests. Provides common mock web server and retrofit setup. + */ +abstract class BaseServiceTest { + + protected val json = PlatformNetworkModule.providesJson() + + protected val server = MockWebServer().apply { start() } + + protected val url: HttpUrl = server.url("/") + + protected val urlPrefix: String get() = "http://${server.hostName}:${server.port}" + + protected val retrofit: Retrofit = Retrofit.Builder() + .baseUrl(url.toString()) + .addCallAdapterFactory(ResultCallAdapterFactory()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + @AfterEach + fun after() { + server.shutdown() + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/base/FakeDispatcherManager.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/base/FakeDispatcherManager.kt new file mode 100644 index 0000000000..a991ce914b --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/base/FakeDispatcherManager.kt @@ -0,0 +1,37 @@ +package com.bitwarden.authenticator.data.platform.base + +import com.bitwarden.authenticator.data.platform.manager.DispatcherManager +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.MainCoroutineDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +/** + * A faked implementation of [DispatcherManager] that uses [UnconfinedTestDispatcher]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class FakeDispatcherManager( + override val default: CoroutineDispatcher = UnconfinedTestDispatcher(), + override val io: CoroutineDispatcher = UnconfinedTestDispatcher(), + override val unconfined: CoroutineDispatcher = UnconfinedTestDispatcher(), +) : DispatcherManager { + override val main: MainCoroutineDispatcher = Dispatchers.Main + + /** + * Updates the main dispatcher to use the provided [dispatcher]. Used in conjunction with + * [resetMain]. + */ + fun setMain(dispatcher: CoroutineDispatcher) { + Dispatchers.setMain(dispatcher) + } + + /** + * Restores the main dispatcher to it's default state. Used in conjunction with [setMain]. + */ + fun resetMain() { + Dispatchers.resetMain() + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/base/FakeSharedPreferences.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/base/FakeSharedPreferences.kt new file mode 100644 index 0000000000..b1b67882d7 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/base/FakeSharedPreferences.kt @@ -0,0 +1,109 @@ +package com.bitwarden.authenticator.data.platform.base + +import android.content.SharedPreferences + +/** + * A faked implementation of [SharedPreferences] that is backed by an internal, memory-based map. + */ +class FakeSharedPreferences : SharedPreferences { + private val sharedPreferences: MutableMap = mutableMapOf() + private val listeners = mutableSetOf() + + override fun contains(key: String): Boolean = + sharedPreferences.containsKey(key) + + override fun edit(): SharedPreferences.Editor = Editor() + + override fun getAll(): Map = sharedPreferences + + override fun getBoolean(key: String, defaultValue: Boolean): Boolean = + getValue(key, defaultValue) + + override fun getFloat(key: String, defaultValue: Float): Float = + getValue(key, defaultValue) + + override fun getInt(key: String, defaultValue: Int): Int = + getValue(key, defaultValue) + + override fun getLong(key: String, defaultValue: Long): Long = + getValue(key, defaultValue) + + override fun getString(key: String, defaultValue: String?): String? = + getValue(key, defaultValue) + + override fun getStringSet(key: String, defaultValue: Set?): Set? = + getValue(key, defaultValue) + + override fun registerOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener, + ) { + listeners += listener + } + + override fun unregisterOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener, + ) { + listeners -= listener + } + + private inline fun getValue( + key: String, + defaultValue: T, + ): T = sharedPreferences[key] as? T ?: defaultValue + + inner class Editor : SharedPreferences.Editor { + private val pendingSharedPreferences = sharedPreferences.toMutableMap() + + override fun apply() { + sharedPreferences.apply { + clear() + putAll(pendingSharedPreferences) + + // Notify listeners + listeners.forEach { listener -> + pendingSharedPreferences.keys.forEach { key -> + listener.onSharedPreferenceChanged(this@FakeSharedPreferences, key) + } + } + } + } + + override fun clear(): SharedPreferences.Editor = + apply { pendingSharedPreferences.clear() } + + override fun commit(): Boolean { + apply() + return true + } + + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = + putValue(key, value) + + override fun putFloat(key: String, value: Float): SharedPreferences.Editor = + putValue(key, value) + + override fun putInt(key: String, value: Int): SharedPreferences.Editor = + putValue(key, value) + + override fun putLong(key: String, value: Long): SharedPreferences.Editor = + putValue(key, value) + + override fun putString(key: String, value: String?): SharedPreferences.Editor = + putValue(key, value) + + override fun putStringSet(key: String, value: Set?): SharedPreferences.Editor = + putValue(key, value) + + override fun remove(key: String): SharedPreferences.Editor = + apply { pendingSharedPreferences.remove(key) } + + private inline fun putValue( + key: String, + value: T, + ): SharedPreferences.Editor = apply { + value + ?.let { pendingSharedPreferences[key] = it } + ?: pendingSharedPreferences.remove(key) + } + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSourceTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSourceTest.kt new file mode 100644 index 0000000000..40215aa5e7 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSourceTest.kt @@ -0,0 +1,116 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import androidx.core.content.edit +import app.cash.turbine.test +import com.bitwarden.authenticator.data.platform.base.FakeSharedPreferences +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.data.platform.datasource.network.di.PlatformNetworkModule +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson.EnvironmentJson +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson.ServerJson +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.time.Instant + +class ConfigDiskSourceTest { + private val json = PlatformNetworkModule.providesJson() + + private val fakeSharedPreferences = FakeSharedPreferences() + + private val configDiskSource = ConfigDiskSourceImpl( + sharedPreferences = fakeSharedPreferences, + json = json, + ) + + @Test + fun `serverConfig should pull from and update SharedPreferences`() = + runTest { + val serverConfigKey = "bwPreferencesStorage:serverConfigurations" + + // Shared preferences and the repository start with the same value. + assertNull(configDiskSource.serverConfig) + assertNull(fakeSharedPreferences.getString(serverConfigKey, null)) + + // Updating the repository updates shared preferences + configDiskSource.serverConfig = SERVER_CONFIG + assertEquals( + json.parseToJsonElement( + SERVER_CONFIG_JSON, + ), + json.parseToJsonElement( + fakeSharedPreferences.getString(serverConfigKey, null)!!, + ), + ) + + // Update SharedPreferences updates the repository + fakeSharedPreferences.edit { putString(serverConfigKey, null) } + assertNull(configDiskSource.serverConfig) + } + + @Test + fun `serverConfigFlow should react to changes in serverConfig`() = + runTest { + configDiskSource.serverConfigFlow.test { + // The initial values of the Flow and the property are in sync + assertNull(configDiskSource.serverConfig) + assertNull(awaitItem()) + + // Updating the repository updates shared preferences + configDiskSource.serverConfig = SERVER_CONFIG + assertEquals(SERVER_CONFIG, awaitItem()) + } + } +} + +private const val SERVER_CONFIG_JSON = """ +{ + "lastSync": 1698408000000, + "serverData": { + "version": "2024.7.0", + "gitHash": "25cf6119-dirty", + "server": { + "name": "example", + "url": "https://localhost:8080" + }, + "environment": { + "vault": "https://localhost:8080", + "api": "http://localhost:4000", + "identity": "http://localhost:33656", + "notifications": "http://localhost:61840", + "sso": "http://localhost:51822" + }, + "featureStates": { + "duo-redirect": true, + "flexible-collections-v-1": false + } + } +} + +""" +private val SERVER_CONFIG = ServerConfig( + lastSync = Instant.parse("2023-10-27T12:00:00Z").toEpochMilli(), + serverData = ConfigResponseJson( + type = null, + version = "2024.7.0", + gitHash = "25cf6119-dirty", + server = ServerJson( + name = "example", + url = "https://localhost:8080", + ), + environment = EnvironmentJson( + cloudRegion = null, + vaultUrl = "https://localhost:8080", + apiUrl = "http://localhost:4000", + identityUrl = "http://localhost:33656", + notificationsUrl = "http://localhost:61840", + ssoUrl = "http://localhost:51822", + ), + featureStates = mapOf( + "duo-redirect" to JsonPrimitive(true), + "flexible-collections-v-1" to JsonPrimitive(false), + ), + ), +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagDiskSourceTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagDiskSourceTest.kt new file mode 100644 index 0000000000..28d30b14f1 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagDiskSourceTest.kt @@ -0,0 +1,71 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import androidx.core.content.edit +import app.cash.turbine.test +import com.bitwarden.authenticator.data.platform.base.FakeSharedPreferences +import com.bitwarden.authenticator.data.platform.datasource.disk.model.FeatureFlagsConfiguration +import com.bitwarden.authenticator.data.platform.datasource.network.di.PlatformNetworkModule +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class FeatureFlagDiskSourceTest { + private val json = PlatformNetworkModule.providesJson() + + private val fakeSharedPreferences = FakeSharedPreferences() + + private val featureFlagDiskSource = FeatureFlagDiskSourceImpl( + sharedPreferences = fakeSharedPreferences, + json = json, + ) + + @Test + fun `featureFlagsConfiguration should pull from and update SharedPreferences`() { + val featureFlagsConfigKey = "bwPreferencesStorage:featureFlags" + + assertNull(featureFlagDiskSource.featureFlagsConfiguration) + assertNull(fakeSharedPreferences.getString(featureFlagsConfigKey, null)) + + featureFlagDiskSource.featureFlagsConfiguration = FEATURE_FLAGS_CONFIGURATION + assertEquals( + json.parseToJsonElement( + FEATURE_FLAGS_CONFIGURATION_JSON, + ), + json.parseToJsonElement( + fakeSharedPreferences.getString(featureFlagsConfigKey, null)!!, + ), + ) + + fakeSharedPreferences.edit { putString(featureFlagsConfigKey, null) } + assertNull(featureFlagDiskSource.featureFlagsConfiguration) + } + + @Test + fun `featureFlagsConfigFlow should react to changes in featureFlagsConfig`() = runTest { + featureFlagDiskSource.featureFlagsConfigurationFlow.test { + assertNull(featureFlagDiskSource.featureFlagsConfiguration) + assertNull(awaitItem()) + + featureFlagDiskSource.featureFlagsConfiguration = FEATURE_FLAGS_CONFIGURATION + assertEquals(FEATURE_FLAGS_CONFIGURATION, awaitItem()) + } + } +} + +private const val FEATURE_FLAGS_CONFIGURATION_JSON = """ +{ + "featureFlags" : { + "bitwarden-authentication-enabled" : true + } +} + +""" + +private val FEATURE_FLAGS_CONFIGURATION = FeatureFlagsConfiguration( + featureFlags = mapOf( + FlagKey.BitwardenAuthenticationEnabled.keyName to JsonPrimitive(true), + ), +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagOverrideDiskSourceTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagOverrideDiskSourceTest.kt new file mode 100644 index 0000000000..1ded2007a5 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/FeatureFlagOverrideDiskSourceTest.kt @@ -0,0 +1,115 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import androidx.core.content.edit +import com.bitwarden.authenticator.data.platform.base.FakeSharedPreferences +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class FeatureFlagOverrideDiskSourceTest { + private val fakeSharedPreferences = FakeSharedPreferences() + + private val featureFlagOverrideDiskSource = FeatureFlagOverrideDiskSourceImpl( + sharedPreferences = fakeSharedPreferences, + ) + + @Test + fun `call to save feature flag should update SharedPreferences for booleans`() { + val key = FlagKey.DummyBoolean + assertFalse( + fakeSharedPreferences.getBoolean( + key.keyName, + false, + ), + ) + val value = true + featureFlagOverrideDiskSource.saveFeatureFlag(key, value) + assertTrue( + fakeSharedPreferences.getBoolean( + key.keyName, + false, + ), + ) + } + + @Test + fun `call to get feature flag should return correct value for booleans`() { + val key = FlagKey.DummyBoolean + fakeSharedPreferences.edit { + putBoolean(key.keyName, true) + } + + val actual = featureFlagOverrideDiskSource.getFeatureFlag(key) + assertTrue(actual!!) + } + + @Test + fun `call to save feature flag should update SharedPreferences for strings`() { + val key = FlagKey.DummyString + assertNull( + fakeSharedPreferences.getString( + key.keyName, + null, + ), + ) + val expectedValue = "string" + featureFlagOverrideDiskSource.saveFeatureFlag(key, expectedValue) + assertEquals( + fakeSharedPreferences.getString( + key.keyName, + null, + ), + expectedValue, + ) + } + + @Test + fun `call to get feature flag should return correct value for strings`() { + val key = FlagKey.DummyString + assertNull(featureFlagOverrideDiskSource.getFeatureFlag(key)) + val expectedValue = "string" + fakeSharedPreferences.edit { + putString(key.keyName, expectedValue) + } + + val actual = featureFlagOverrideDiskSource.getFeatureFlag(key) + assertEquals(actual, expectedValue) + } + + @Test + fun `call to save feature flag should update SharedPreferences for ints`() { + val key = FlagKey.DummyInt() + assertEquals( + fakeSharedPreferences.getInt( + key.keyName, + 0, + ), + 0, + ) + val expectedValue = 1 + featureFlagOverrideDiskSource.saveFeatureFlag(key, expectedValue) + assertEquals( + fakeSharedPreferences.getInt( + key.keyName, + 0, + ), + expectedValue, + ) + } + + @Test + fun `call to get feature flag should return correct value for ints`() { + val key = FlagKey.DummyInt() + assertNull(featureFlagOverrideDiskSource.getFeatureFlag(key)) + val expectedValue = 1 + fakeSharedPreferences.edit { + putInt(key.keyName, expectedValue) + } + + val actual = featureFlagOverrideDiskSource.getFeatureFlag(key) + assertEquals(actual, expectedValue) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/SettingDiskSourceTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/SettingDiskSourceTest.kt new file mode 100644 index 0000000000..90ea9d972a --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/SettingDiskSourceTest.kt @@ -0,0 +1,131 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import androidx.core.content.edit +import app.cash.turbine.test +import com.bitwarden.authenticator.data.platform.base.FakeSharedPreferences +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SettingDiskSourceTest { + + private val sharedPreferences: FakeSharedPreferences = FakeSharedPreferences() + + private val settingDiskSource = SettingsDiskSourceImpl( + sharedPreferences, + ) + + @Test + fun `hasUserDismissedDownloadBitwardenCard should read and write from shared preferences`() { + val sharedPrefsKey = "bwPreferencesStorage:hasUserDismissedDownloadBitwardenCard" + + // Shared preferences and the disk source start with the same value: + assertNull(settingDiskSource.hasUserDismissedDownloadBitwardenCard) + assertNull(sharedPreferences.getString(sharedPrefsKey, null)) + + // Updating the disk source updates shared preferences: + settingDiskSource.hasUserDismissedDownloadBitwardenCard = false + assertFalse(sharedPreferences.getBoolean(sharedPrefsKey, true)) + + sharedPreferences.edit { + putBoolean(sharedPrefsKey, true) + } + assertTrue(settingDiskSource.hasUserDismissedDownloadBitwardenCard!!) + } + + @Test + fun `hasUserDismissedSyncWithBitwardenCard should read and write from shared preferences`() { + val sharedPrefsKey = "bwPreferencesStorage:hasUserDismissedSyncWithBitwardenCard" + + // Shared preferences and the disk source start with the same value: + assertNull(settingDiskSource.hasUserDismissedSyncWithBitwardenCard) + assertNull(sharedPreferences.getString(sharedPrefsKey, null)) + + // Updating the disk source updates shared preferences: + settingDiskSource.hasUserDismissedSyncWithBitwardenCard = false + assertFalse(sharedPreferences.getBoolean(sharedPrefsKey, true)) + + sharedPreferences.edit { + putBoolean(sharedPrefsKey, true) + } + assertTrue(settingDiskSource.hasUserDismissedSyncWithBitwardenCard!!) + } + + @Test + fun `defaultSaveOption should read and write from shared preferences`() = runTest { + val sharedPrefsKey = "bwPreferencesStorage:defaultSaveOption" + + settingDiskSource.defaultSaveOptionFlow.test { + // Verify initial value is null and disk source should default to NONE + assertNull(sharedPreferences.getString(sharedPrefsKey, null)) + assertEquals( + DefaultSaveOption.NONE, + settingDiskSource.defaultSaveOption, + ) + assertEquals( + DefaultSaveOption.NONE, + awaitItem(), + ) + + // Updating the shared preferences should update disk source + sharedPreferences.edit { + putString( + sharedPrefsKey, + DefaultSaveOption.BITWARDEN_APP.value, + ) + } + assertEquals( + DefaultSaveOption.BITWARDEN_APP, + settingDiskSource.defaultSaveOption, + ) + + // Updating the disk source should update shared preferences + settingDiskSource.defaultSaveOption = DefaultSaveOption.LOCAL + assertEquals( + DefaultSaveOption.LOCAL.value, + sharedPreferences.getString(sharedPrefsKey, null), + ) + assertEquals( + DefaultSaveOption.LOCAL, + awaitItem(), + ) + + // Incorrect value should default to DefaultSaveOption.NONE + sharedPreferences.edit { + putString( + sharedPrefsKey, + "invalid", + ) + } + assertEquals( + DefaultSaveOption.NONE, + settingDiskSource.defaultSaveOption, + ) + } + } + + @Test + fun `previouslySyncedBitwardenAccountIds should read and write from shared preferences`() { + val sharedPrefsKey = "bwPreferencesStorage:previouslySyncedBitwardenAccountIds" + + // Disk source should read value from shared preferences: + sharedPreferences.edit { + putStringSet(sharedPrefsKey, setOf("a")) + } + assertEquals( + setOf("a"), + settingDiskSource.previouslySyncedBitwardenAccountIds, + ) + + // Updating the disk source should update shared preferences: + settingDiskSource.previouslySyncedBitwardenAccountIds = setOf("1", "2") + assertEquals( + setOf("1", "2"), + settingDiskSource.previouslySyncedBitwardenAccountIds, + ) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/util/FakeConfigDiskSource.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/util/FakeConfigDiskSource.kt new file mode 100644 index 0000000000..0d727b381a --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/util/FakeConfigDiskSource.kt @@ -0,0 +1,25 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk.util + +import com.bitwarden.authenticator.data.platform.datasource.disk.ConfigDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onSubscription + +class FakeConfigDiskSource : ConfigDiskSource { + private var serverConfigValue: ServerConfig? = null + + override var serverConfig: ServerConfig? + get() = serverConfigValue + set(value) { + serverConfigValue = value + mutableServerConfigFlow.tryEmit(value) + } + + override val serverConfigFlow: Flow + get() = mutableServerConfigFlow + .onSubscription { emit(serverConfig) } + + private val mutableServerConfigFlow = + bufferedMutableSharedFlow(replay = 1) +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/util/FakeFeatureFlagDiskSource.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/util/FakeFeatureFlagDiskSource.kt new file mode 100644 index 0000000000..ed2b7d6ccd --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/util/FakeFeatureFlagDiskSource.kt @@ -0,0 +1,27 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk.util + +import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.model.FeatureFlagsConfiguration +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onSubscription + +/** + * A faked implementation of [FeatureFlagDiskSource] for testing. + */ +class FakeFeatureFlagDiskSource : FeatureFlagDiskSource { + + private var configuration: FeatureFlagsConfiguration? = null + private val mutableConfigurationFlow = + bufferedMutableSharedFlow(replay = 1) + + override var featureFlagsConfiguration: FeatureFlagsConfiguration? + get() = configuration + set(value) { + configuration = value + mutableConfigurationFlow.tryEmit(value) + } + override val featureFlagsConfigurationFlow: Flow + get() = mutableConfigurationFlow + .onSubscription { emit(configuration) } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/core/ResultCallAdapterTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/core/ResultCallAdapterTest.kt new file mode 100644 index 0000000000..6149e77909 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/core/ResultCallAdapterTest.kt @@ -0,0 +1,51 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.core + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import retrofit2.Retrofit +import retrofit2.create +import retrofit2.http.GET + +class ResultCallAdapterTest { + + private val server: MockWebServer = MockWebServer().apply { start() } + private val testService: FakeService = + Retrofit + .Builder() + .baseUrl(server.url("/").toString()) + // add the adapter being tested + .addCallAdapterFactory(ResultCallAdapterFactory()) + .build() + .create() + + @AfterEach + fun after() { + server.shutdown() + } + + @Test + fun `when server returns error response code result should be failure`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + val result = testService.requestWithUnitData() + Assertions.assertTrue(result.isFailure) + } + + @Test + fun `when server returns successful response result should be success`() = runBlocking { + server.enqueue(MockResponse()) + val result = testService.requestWithUnitData() + Assertions.assertTrue(result.isSuccess) + } +} + +/** + * Fake retrofit service used for testing call adapters. + */ +private interface FakeService { + @GET("/fake") + suspend fun requestWithUnitData(): Result +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/BaseUrlInterceptorTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/BaseUrlInterceptorTest.kt new file mode 100644 index 0000000000..fe3fe0f6e2 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/BaseUrlInterceptorTest.kt @@ -0,0 +1,36 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.interceptor + +import okhttp3.Request +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +class BaseUrlInterceptorTest { + private val baseUrlInterceptor = BaseUrlInterceptor() + + @Test + fun `intercept with a null base URL should proceed with the original request`() { + val request = Request.Builder().url("http://www.fake.com/").build() + val chain = FakeInterceptorChain(request) + + val response = baseUrlInterceptor.intercept(chain) + + assertEquals(request, response.request) + assertEquals("http", response.request.url.scheme) + assertEquals("www.fake.com", response.request.url.host) + } + + @Test + fun `intercept with a non-null base URL should update the base URL used by the request`() { + baseUrlInterceptor.baseUrl = "https://api.bitwarden.com" + + val request = Request.Builder().url("http://www.fake.com/").build() + val chain = FakeInterceptorChain(request) + + val response = baseUrlInterceptor.intercept(chain) + + assertNotEquals(request, response.request) + assertEquals("https", response.request.url.scheme) + assertEquals("api.bitwarden.com", response.request.url.host) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/BaseUrlInterceptorsTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/BaseUrlInterceptorsTest.kt new file mode 100644 index 0000000000..f5e9487e81 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/BaseUrlInterceptorsTest.kt @@ -0,0 +1,69 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.interceptor + +import com.bitwarden.authenticator.data.auth.datasource.disk.model.EnvironmentUrlDataJson +import com.bitwarden.authenticator.data.platform.repository.model.Environment +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BaseUrlInterceptorsTest { + private val baseUrlInterceptors = BaseUrlInterceptors() + + @Test + fun `the default environment should be US and all interceptors should have the correct URLs`() { + assertEquals( + Environment.Us, + baseUrlInterceptors.environment, + ) + assertEquals( + "https://vault.bitwarden.com/api", + baseUrlInterceptors.apiInterceptor.baseUrl, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `setting the environment should update all the interceptors correctly for a non-blank base URL`() { + baseUrlInterceptors.environment = Environment.Eu + + assertEquals( + "https://vault.bitwarden.eu/api", + baseUrlInterceptors.apiInterceptor.baseUrl, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `setting the environment should update all the interceptors correctly for a blank base URL and all URLs filled`() { + baseUrlInterceptors.environment = Environment.SelfHosted( + environmentUrlData = EnvironmentUrlDataJson( + base = " ", + api = "https://api.com", + identity = "https://identity.com", + events = "https://events.com", + ), + ) + + assertEquals( + "https://api.com", + baseUrlInterceptors.apiInterceptor.baseUrl, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `setting the environment should update all the interceptors correctly for a blank base URL and some or all URLs absent`() { + baseUrlInterceptors.environment = Environment.SelfHosted( + environmentUrlData = EnvironmentUrlDataJson( + base = " ", + api = "", + identity = "", + icon = " ", + ), + ) + + assertEquals( + "https://api.bitwarden.com", + baseUrlInterceptors.apiInterceptor.baseUrl, + ) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/FakeInterceptorChain.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/FakeInterceptorChain.kt new file mode 100644 index 0000000000..a0b1e4751e --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/FakeInterceptorChain.kt @@ -0,0 +1,69 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.interceptor + +import okhttp3.Call +import okhttp3.Connection +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import java.util.concurrent.TimeUnit + +/** + * Helper class for implementing a [Interceptor.Chain] in a way that a [Request] passed in to + * [proceed] will be returned in a valid [Response] object that can be queried. This wrapping is + * performed by the [responseProvider]. + */ +class FakeInterceptorChain( + private val request: Request, + private val responseProvider: (Request) -> Response = DEFAULT_RESPONSE_PROVIDER, +) : Interceptor.Chain { + override fun request(): Request = request + + override fun proceed(request: Request): Response = responseProvider(request) + + override fun connection(): Connection = notImplemented() + + override fun call(): Call = notImplemented() + + override fun connectTimeoutMillis(): Int = notImplemented() + + override fun withConnectTimeout( + timeout: Int, + unit: TimeUnit, + ): Interceptor.Chain = notImplemented() + + override fun readTimeoutMillis(): Int = notImplemented() + + override fun withReadTimeout( + timeout: Int, + unit: TimeUnit, + ): Interceptor.Chain = notImplemented() + + override fun writeTimeoutMillis(): Int = notImplemented() + + override fun withWriteTimeout( + timeout: Int, + unit: TimeUnit, + ): Interceptor.Chain = notImplemented() + + private fun notImplemented(): Nothing { + throw NotImplementedError("This is not yet required by tests") + } + + companion object { + /** + * A default response provider that provides a basic successful response. This is useful + * when the details of the response are not as important as retrieving the [Request] that + * was used to build it. + */ + val DEFAULT_RESPONSE_PROVIDER: (Request) -> Response = { request -> + Response + .Builder() + .code(200) + .message("OK") + .protocol(Protocol.HTTP_1_1) + .request(request) + .build() + } + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/HeadersInterceptorTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/HeadersInterceptorTest.kt new file mode 100644 index 0000000000..9f7597971f --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/interceptor/HeadersInterceptorTest.kt @@ -0,0 +1,37 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.interceptor + +import android.os.Build +import com.bitwarden.authenticator.BuildConfig +import com.bitwarden.authenticator.ui.platform.base.BaseRobolectricTest +import okhttp3.Request +import org.junit.Assert.assertEquals +import org.junit.Test + +class HeadersInterceptorTest : BaseRobolectricTest() { + + private val headersInterceptors = HeadersInterceptor() + + @Test + fun `intercept should modify original request to include custom headers`() { + // We reference the real BuildConfig here, since we don't want the test to break on every + // version bump. We are also doing the same thing for Build when the SDK gets incremented. + val versionName = BuildConfig.VERSION_NAME + val buildType = BuildConfig.BUILD_TYPE + val release = Build.VERSION.RELEASE + val sdk = Build.VERSION.SDK_INT + val originalRequest = Request.Builder().url("http://www.fake.com/").build() + val chain = FakeInterceptorChain(originalRequest) + + val response = headersInterceptors.intercept(chain) + + val request = response.request + @Suppress("MaxLineLength") + assertEquals( + "Bitwarden_Mobile/$versionName ($buildType) (Android $release; SDK $sdk; Model robolectric)", + request.header("User-Agent"), + ) + assertEquals("mobile", request.header("Bitwarden-Client-Name")) + assertEquals(versionName, request.header("Bitwarden-Client-Version")) + assertEquals("0", request.header("Device-Type")) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/retrofit/RetrofitsTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/retrofit/RetrofitsTest.kt new file mode 100644 index 0000000000..1518ac1029 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/retrofit/RetrofitsTest.kt @@ -0,0 +1,126 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.retrofit + +import com.bitwarden.authenticator.data.platform.datasource.network.interceptor.BaseUrlInterceptors +import com.bitwarden.authenticator.data.platform.datasource.network.interceptor.HeadersInterceptor +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import okhttp3.Interceptor +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import retrofit2.Retrofit +import retrofit2.create +import retrofit2.http.GET + +class RetrofitsTest { + private val baseUrlInterceptors = mockk { + every { apiInterceptor } returns mockk { + mockIntercept { isApiInterceptorCalled = true } + } + } + private val headersInterceptors = mockk { + mockIntercept { isheadersInterceptorCalled = true } + } + private val json = Json + private val server = MockWebServer() + + private val retrofits = RetrofitsImpl( + baseUrlInterceptors = baseUrlInterceptors, + headersInterceptor = headersInterceptors, + json = json, + ) + + private var isAuthInterceptorCalled = false + private var isApiInterceptorCalled = false + private var isheadersInterceptorCalled = false + private var isRefreshAuthenticatorCalled = false + + @Before + fun setUp() { + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `unauthenticatedApiRetrofit should not invoke the RefreshAuthenticator`() = runBlocking { + val testApi = retrofits + .unauthenticatedApiRetrofit + .createMockRetrofit() + .create() + + server.enqueue(MockResponse().setResponseCode(401).setBody("""{}""")) + + testApi.test() + + assertFalse(isRefreshAuthenticatorCalled) + } + + @Test + fun `unauthenticatedApiRetrofit should invoke the correct interceptors`() = runBlocking { + val testApi = retrofits + .unauthenticatedApiRetrofit + .createMockRetrofit() + .create() + + server.enqueue(MockResponse().setBody("""{}""")) + + testApi.test() + + assertFalse(isAuthInterceptorCalled) + assertTrue(isApiInterceptorCalled) + assertTrue(isheadersInterceptorCalled) + } + + @Test + fun `createStaticRetrofit when unauthenticated should invoke the correct interceptors`() = + runBlocking { + val testApi = retrofits + .createStaticRetrofit(isAuthenticated = false) + .createMockRetrofit() + .create() + + server.enqueue(MockResponse().setBody("""{}""")) + + testApi.test() + + assertFalse(isAuthInterceptorCalled) + assertFalse(isApiInterceptorCalled) + assertTrue(isheadersInterceptorCalled) + } + + private fun Retrofit.createMockRetrofit(): Retrofit = + this + .newBuilder() + .baseUrl(server.url("/").toString()) + .build() +} + +interface TestApi { + @GET("/test") + suspend fun test(): Result +} + +/** + * Mocks the given [Interceptor] such that the [Interceptor.intercept] is a no-op but triggers the + * [isCalledCallback]. + */ +private fun Interceptor.mockIntercept(isCalledCallback: () -> Unit) { + val chainSlot = slot() + every { intercept(capture(chainSlot)) } answers { + isCalledCallback() + val chain = chainSlot.captured + chain.proceed(chain.request()) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/serializer/ZonedDateTimeSerializerTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/serializer/ZonedDateTimeSerializerTest.kt new file mode 100644 index 0000000000..0696611710 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/serializer/ZonedDateTimeSerializerTest.kt @@ -0,0 +1,99 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.serializer + +import com.bitwarden.authenticator.data.platform.datasource.network.di.PlatformNetworkModule +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.encodeToJsonElement +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime + +class ZonedDateTimeSerializerTest { + private val json = PlatformNetworkModule.providesJson() + + @Test + fun `properly deserializes raw JSON to ZonedDateTime`() { + assertEquals( + ZonedDateTimeData( + dataAsZonedDateTime = ZonedDateTime.of( + 2023, + 10, + 6, + 17, + 22, + 28, + 440000000, + ZoneOffset.UTC, + ), + ), + json.decodeFromString( + """ + { + "dataAsZonedDateTime": "2023-10-06T17:22:28.44Z" + } + """, + ), + ) + } + + @Test + fun `properly deserializes raw JSON with nano seconds to ZonedDateTime`() { + assertEquals( + ZonedDateTimeData( + dataAsZonedDateTime = ZonedDateTime.of( + 2023, + 8, + 1, + 16, + 13, + 3, + 502391000, + ZoneOffset.UTC, + ), + ), + json.decodeFromString( + """ + { + "dataAsZonedDateTime": "2023-08-01T16:13:03.502391Z" + } + """, + ), + ) + } + + @Test + fun `properly serializes external model back to raw JSON`() { + assertEquals( + json.parseToJsonElement( + """ + { + "dataAsZonedDateTime": "2023-10-06T17:22:28.440Z" + } + """, + ), + json.encodeToJsonElement( + ZonedDateTimeData( + dataAsZonedDateTime = ZonedDateTime.of( + 2023, + 10, + 6, + 17, + 22, + 28, + 440000000, + ZoneId.of("UTC"), + ), + ), + ), + ) + } +} + +@Serializable +private data class ZonedDateTimeData( + @Serializable(ZonedDateTimeSerializer::class) + @SerialName("dataAsZonedDateTime") + val dataAsZonedDateTime: ZonedDateTime, +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigServiceTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigServiceTest.kt new file mode 100644 index 0000000000..7ff6ba4283 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigServiceTest.kt @@ -0,0 +1,67 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.service + +import com.bitwarden.authenticator.data.platform.base.BaseServiceTest +import com.bitwarden.authenticator.data.platform.datasource.network.api.ConfigApi +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson +import com.bitwarden.authenticator.data.platform.util.asSuccess +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.mockwebserver.MockResponse +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import retrofit2.create + +class ConfigServiceTest : BaseServiceTest() { + + private val api: ConfigApi = retrofit.create() + private val service = ConfigServiceImpl(api) + + @Test + fun `getConfig should call ConfigApi`() = runTest { + server.enqueue(MockResponse().setBody(CONFIG_RESPONSE_JSON)) + assertEquals(CONFIG_RESPONSE.asSuccess(), service.getConfig()) + } +} + +private const val CONFIG_RESPONSE_JSON = """ +{ + "object": "config", + "version": "1", + "gitHash": "gitHash", + "server": { + "name": "default", + "url": "url" + }, + "environment": { + "cloudRegion": "US", + "vault": "vaultUrl", + "api": "apiUrl", + "identity": "identityUrl", + "notifications": "notificationsUrl", + "sso": "ssoUrl" + }, + "featureStates": { + "feature one": false + } +} +""" +private val CONFIG_RESPONSE = ConfigResponseJson( + type = "config", + version = "1", + gitHash = "gitHash", + server = ConfigResponseJson.ServerJson( + name = "default", + url = "url", + ), + environment = ConfigResponseJson.EnvironmentJson( + cloudRegion = "US", + vaultUrl = "vaultUrl", + apiUrl = "apiUrl", + notificationsUrl = "notificationsUrl", + identityUrl = "identityUrl", + ssoUrl = "ssoUrl", + ), + featureStates = mapOf( + "feature one" to JsonPrimitive(false), + ), +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/manager/DebugMenuFeatureFlagManagerTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/manager/DebugMenuFeatureFlagManagerTest.kt new file mode 100644 index 0000000000..8ee1c25e30 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/manager/DebugMenuFeatureFlagManagerTest.kt @@ -0,0 +1,116 @@ +package com.bitwarden.authenticator.data.platform.manager + +import app.cash.turbine.test +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DebugMenuFeatureFlagManagerTest { + + private val mockFeatureFlagManager = mockk(relaxed = true) { + every { getFeatureFlag(any()) } returns true + } + + private val mutableOverridesUpdateFlow = bufferedMutableSharedFlow() + private val mockDebugMenuRepository = mockk(relaxed = true) { + every { updateFeatureFlag(any(), any()) } just runs + every { featureFlagOverridesUpdatedFlow } returns mutableOverridesUpdateFlow + } + + private val debugMenuFeatureFlagManager = DebugMenuFeatureFlagManagerImpl( + defaultFeatureFlagManager = mockFeatureFlagManager, + debugMenuRepository = mockDebugMenuRepository, + ) + + @Test + fun `If value exists in repository return that value for requested FlagKey`() { + val flagKey = FlagKey.DummyBoolean + val expectedValue = true + every { mockDebugMenuRepository.getFeatureFlag(flagKey) } returns expectedValue + + assertTrue(debugMenuFeatureFlagManager.getFeatureFlag(flagKey)) + + verify(exactly = 0) { mockFeatureFlagManager.getFeatureFlag(flagKey) } + } + + @Test + fun `If value does not exist in repository return that value from the default manager`() { + val flagKey = FlagKey.DummyBoolean + + every { mockDebugMenuRepository.getFeatureFlag(flagKey) } returns null + + assertTrue(debugMenuFeatureFlagManager.getFeatureFlag(flagKey)) + + verify(exactly = 1) { mockFeatureFlagManager.getFeatureFlag(flagKey) } + } + + @Suppress("MaxLineLength") + @Test + fun `get feature flag with force refresh will call the default manager to use as the fallback value`() = + runTest { + val flagKey = FlagKey.DummyBoolean + val expectedValue = true + + coEvery { + mockFeatureFlagManager.getFeatureFlag(key = flagKey, forceRefresh = true) + } returns expectedValue + every { mockDebugMenuRepository.getFeatureFlag(flagKey) } returns null + + assertTrue( + debugMenuFeatureFlagManager.getFeatureFlag( + key = flagKey, + forceRefresh = true, + ), + ) + + coVerify(exactly = 1) { + mockFeatureFlagManager.getFeatureFlag( + key = flagKey, + forceRefresh = true, + ) + } + } + + @Test + fun `when repository update flow emits, the feature flag flow will refresh to the value`() = + runTest { + val flagKey = FlagKey.DummyBoolean + every { mockDebugMenuRepository.getFeatureFlag(flagKey) } returns true + + debugMenuFeatureFlagManager + .getFeatureFlagFlow(flagKey) + .test { + mutableOverridesUpdateFlow.emit(Unit) + assertEquals(true, awaitItem()) + cancel() + } + } + + @Suppress("MaxLineLength") + @Test + fun `when repository update flow emits the flow will refresh to the value from default manager if repo returns null`() = + runTest { + val flagKey = FlagKey.DummyBoolean + every { mockDebugMenuRepository.getFeatureFlag(flagKey) } returns null + + debugMenuFeatureFlagManager + .getFeatureFlagFlow(flagKey) + .test { + mutableOverridesUpdateFlow.emit(Unit) + assertEquals(true, awaitItem()) + cancel() + } + verify(exactly = 1) { mockFeatureFlagManager.getFeatureFlag(flagKey) } + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManagerTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManagerTest.kt new file mode 100644 index 0000000000..165542cc64 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManagerTest.kt @@ -0,0 +1,286 @@ +package com.bitwarden.authenticator.data.platform.manager + +import app.cash.turbine.test +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson.EnvironmentJson +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson.ServerJson +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.data.platform.repository.util.FakeServerConfigRepository +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import java.time.Instant + +class FeatureFlagManagerTest { + private val fakeServerConfigRepository = FakeServerConfigRepository() + + private val manager = FeatureFlagManagerImpl( + serverConfigRepository = fakeServerConfigRepository, + ) + + @Test + fun `ServerConfigRepository flow with value should trigger new flags`() = runTest { + fakeServerConfigRepository.serverConfigValue = null + assertNull( + fakeServerConfigRepository.serverConfigValue, + ) + + // This should trigger a new server config to be fetched + fakeServerConfigRepository.serverConfigValue = SERVER_CONFIG + + manager.getFeatureFlagFlow(FlagKey.DummyBoolean).test { + assertNotNull( + awaitItem(), + ) + } + } + + @Test + fun `ServerConfigRepository flow with null should trigger default flag value value`() = + runTest { + fakeServerConfigRepository.serverConfigValue = null + + manager.getFeatureFlagFlow(FlagKey.DummyBoolean).test { + assertFalse( + awaitItem(), + ) + } + } + + @Test + fun `getFeatureFlag Boolean should return value if exists`() = runTest { + val flagValue = manager.getFeatureFlag( + key = FlagKey.DummyBoolean, + forceRefresh = true, + ) + assertTrue(flagValue) + } + + @Test + fun `getFeatureFlag Boolean should return default value if doesn't exists`() = runTest { + fakeServerConfigRepository.serverConfigValue = SERVER_CONFIG.copy( + serverData = SERVER_CONFIG + .serverData + .copy( + featureStates = mapOf("flag-example" to JsonPrimitive(123)), + ), + ) + + val flagValue = manager.getFeatureFlag( + key = FlagKey.PasswordManagerSync, + forceRefresh = false, + ) + assertFalse(flagValue) + } + + @Test + fun `getFeatureFlag Int should return value if exists`() = runTest { + fakeServerConfigRepository.serverConfigValue = SERVER_CONFIG.copy( + serverData = SERVER_CONFIG + .serverData + .copy( + featureStates = mapOf("dummy-int" to JsonPrimitive(123)), + ), + ) + + val flagValue = manager.getFeatureFlag( + key = FlagKey.DummyInt(), + forceRefresh = false, + ) + + assertEquals( + 123, + flagValue, + ) + } + + @Test + fun `getFeatureFlag Int should return default value if doesn't exists`() = runTest { + fakeServerConfigRepository.serverConfigValue = SERVER_CONFIG.copy( + serverData = SERVER_CONFIG + .serverData + .copy( + featureStates = mapOf("flag-example" to JsonPrimitive(123)), + ), + ) + + val flagValue = manager.getFeatureFlag( + key = FlagKey.DummyInt(), + forceRefresh = false, + ) + + assertEquals( + Int.MIN_VALUE, + flagValue, + ) + } + + @Test + fun `getFeatureFlag String should return value if exists`() = runTest { + fakeServerConfigRepository.serverConfigValue = SERVER_CONFIG.copy( + serverData = SERVER_CONFIG + .serverData + .copy( + featureStates = mapOf("dummy-string" to JsonPrimitive("niceValue")), + ), + ) + + val flagValue = manager.getFeatureFlag( + key = FlagKey.DummyString, + forceRefresh = false, + ) + + assertEquals( + "niceValue", + flagValue, + ) + } + + @Test + fun `getFeatureFlag String should return default value if doesn't exists`() = + runTest { + fakeServerConfigRepository.serverConfigValue = SERVER_CONFIG.copy( + serverData = SERVER_CONFIG + .serverData + .copy( + featureStates = mapOf("flag-example" to JsonPrimitive("niceValue")), + ), + ) + + val flagValue = manager.getFeatureFlag( + key = FlagKey.DummyString, + forceRefresh = false, + ) + + assertEquals( + "defaultValue", + flagValue, + ) + } + + @Test + fun `getFeatureFlag Boolean should return default value if no flags available`() = runTest { + fakeServerConfigRepository.serverConfigValue = null + + val flagValue = manager.getFeatureFlag( + key = FlagKey.PasswordManagerSync, + forceRefresh = false, + ) + + assertFalse( + flagValue, + ) + } + + @Test + fun `getFeatureFlag Int should return default value if no flags available`() = runTest { + fakeServerConfigRepository.serverConfigValue = null + + val flagValue = manager.getFeatureFlag( + key = FlagKey.DummyInt(), + forceRefresh = false, + ) + + assertEquals( + Int.MIN_VALUE, + flagValue, + ) + } + + @Test + fun `getFeatureFlag Int should return default value when not remotely controlled`() = runTest { + fakeServerConfigRepository.serverConfigValue = null + + val flagValue = manager.getFeatureFlag( + key = FlagKey.DummyInt(isRemotelyConfigured = false), + forceRefresh = false, + ) + + assertEquals( + Int.MIN_VALUE, + flagValue, + ) + } + + @Test + fun `getFeatureFlag String should return default value if no flags available`() = runTest { + fakeServerConfigRepository.serverConfigValue = null + + val flagValue = manager.getFeatureFlag( + key = FlagKey.DummyString, + forceRefresh = false, + ) + + assertEquals( + "defaultValue", + flagValue, + ) + } + + @Test + fun `synchronous getFeatureFlag should return stored value when present`() { + fakeServerConfigRepository.serverConfigValue = SERVER_CONFIG.copy( + serverData = SERVER_CONFIG.serverData.copy( + featureStates = mapOf("dummy-int" to JsonPrimitive(true)), + ), + ) + + val flagValue = manager.getFeatureFlag(key = FlagKey.DummyInt()) + + assertEquals(Int.MIN_VALUE, flagValue) + } + + @Test + fun `synchronous getFeatureFlag should return default value if flag is incorrect type`() { + val value = "nonDefaultValue" + fakeServerConfigRepository.serverConfigValue = SERVER_CONFIG.copy( + serverData = SERVER_CONFIG.serverData.copy( + featureStates = mapOf("dummy-string" to JsonPrimitive(value)), + ), + ) + + val flagValue = manager.getFeatureFlag(key = FlagKey.DummyString) + + assertEquals(value, flagValue) + } + + @Test + fun `synchronous getFeatureFlag should return default value if no flags available`() { + fakeServerConfigRepository.serverConfigValue = null + + val flagValue = manager.getFeatureFlag(key = FlagKey.DummyString) + + assertEquals("defaultValue", flagValue) + } +} + +private val SERVER_CONFIG = ServerConfig( + lastSync = Instant.parse("2023-10-27T12:00:00Z").toEpochMilli(), + serverData = ConfigResponseJson( + type = null, + version = "2024.7.0", + gitHash = "25cf6119-dirty", + server = ServerJson( + name = "example", + url = "https://localhost:8080", + ), + environment = EnvironmentJson( + cloudRegion = null, + vaultUrl = "https://localhost:8080", + apiUrl = "http://localhost:4000", + identityUrl = "http://localhost:33656", + notificationsUrl = "http://localhost:61840", + ssoUrl = "http://localhost:51822", + ), + featureStates = mapOf( + "dummy-boolean" to JsonPrimitive(true), + "flexible-collections-v-1" to JsonPrimitive(false), + ), + ), +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/manager/FlagKeyTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/manager/FlagKeyTest.kt new file mode 100644 index 0000000000..843939f01a --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/manager/FlagKeyTest.kt @@ -0,0 +1,39 @@ +package com.bitwarden.authenticator.data.platform.manager + +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class FlagKeyTest { + @Test + fun `Feature flags have the correct key name set`() { + assertEquals( + FlagKey.BitwardenAuthenticationEnabled.keyName, + "bitwarden-authentication-enabled", + ) + assertEquals( + FlagKey.PasswordManagerSync.keyName, + "enable-pm-bwa-sync", + ) + } + + @Test + fun `All feature flags have the correct default value set`() { + assertTrue( + listOf( + FlagKey.BitwardenAuthenticationEnabled, + FlagKey.PasswordManagerSync, + ).all { + !it.defaultValue + }, + ) + } + + @Test + fun `All feature flags are correctly set to be remotely configured`() { + assertTrue(FlagKey.PasswordManagerSync.isRemotelyConfigured) + assertFalse(FlagKey.BitwardenAuthenticationEnabled.isRemotelyConfigured) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/manager/imports/ImportManagerTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/manager/imports/ImportManagerTest.kt new file mode 100644 index 0000000000..fc0672dab2 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/manager/imports/ImportManagerTest.kt @@ -0,0 +1,74 @@ +package com.bitwarden.authenticator.data.platform.manager.imports + +import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.platform.manager.imports.model.ExportParseResult +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportDataResult +import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat +import com.bitwarden.authenticator.data.platform.manager.imports.parsers.BitwardenExportParser +import com.bitwarden.authenticator.ui.platform.base.util.asText +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.runs +import io.mockk.unmockkConstructor +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ImportManagerTest { + private val mockAuthenticatorDiskSource = mockk() + + private val manager = ImportManagerImpl( + authenticatorDiskSource = mockAuthenticatorDiskSource, + ) + + @BeforeEach + fun setup() { + mockkConstructor(BitwardenExportParser::class) + } + + @AfterEach + fun tearDown() { + unmockkConstructor(BitwardenExportParser::class) + } + + @Test + fun `ImportManager returns success result from ExportParser and saves items to disk`() = + runTest { + val listOfItems = emptyList() + + coEvery { + mockAuthenticatorDiskSource.saveItem(*listOfItems.toTypedArray()) + } just runs + + every { + anyConstructed().parseForResult(any()) + } returns ExportParseResult.Success(listOfItems) + + val result = manager.import(ImportFileFormat.BITWARDEN_JSON, DEFAULT_BYTE_ARRAY) + assertEquals(ImportDataResult.Success, result) + coVerify(exactly = 1) { + mockAuthenticatorDiskSource.saveItem(*listOfItems.toTypedArray()) + } + } + + @Test + fun `ImportManager returns correct error result from ExportParser`() = runTest { + val errorMessage = "borked".asText() + + every { + anyConstructed().parseForResult(any()) + } returns ExportParseResult.Error(message = errorMessage) + + val result = manager.import(ImportFileFormat.BITWARDEN_JSON, DEFAULT_BYTE_ARRAY) + assertEquals(ImportDataResult.Error(message = errorMessage), result) + } +} + +private val DEFAULT_BYTE_ARRAY = "".toByteArray() diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepositoryTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepositoryTest.kt new file mode 100644 index 0000000000..019ec2e8f2 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepositoryTest.kt @@ -0,0 +1,167 @@ +package com.bitwarden.authenticator.data.platform.repository + +import app.cash.turbine.test +import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagOverrideDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +class DebugMenuRepositoryTest { + private val mockFeatureFlagOverrideDiskSource = + mockk { + every { getFeatureFlag(FlagKey.DummyBoolean) } returns true + every { getFeatureFlag(FlagKey.DummyString) } returns TEST_STRING_VALUE + every { getFeatureFlag(FlagKey.DummyInt()) } returns TEST_INT_VALUE + every { saveFeatureFlag(any(), any()) } just io.mockk.runs + } + private val mutableServerConfigStateFlow = + MutableStateFlow( + null, + ) + private val mockServerConfigRepository = + mockk { + every { serverConfigStateFlow } returns mutableServerConfigStateFlow + } + + private val debugMenuRepository = + DebugMenuRepositoryImpl( + featureFlagOverrideDiskSource = mockFeatureFlagOverrideDiskSource, + serverConfigRepository = mockServerConfigRepository, + ) + + @Test + fun `updateFeatureFlag should save the feature flag to disk`() { + debugMenuRepository.updateFeatureFlag( + FlagKey.DummyBoolean, + true, + ) + verify(exactly = 1) { + mockFeatureFlagOverrideDiskSource.saveFeatureFlag( + FlagKey.DummyBoolean, + true, + ) + } + } + + @Test + fun `updateFeatureFlag should cause the feature flag overrides updated flow to emit`() = + runTest { + debugMenuRepository.updateFeatureFlag( + FlagKey.DummyBoolean, + true, + ) + debugMenuRepository.featureFlagOverridesUpdatedFlow.test { + awaitItem() // initial value on subscription + awaitItem() + cancel() + } + } + + @Test + fun `getFeatureFlag should return the feature flag boolean value from disk`() { + Assertions.assertTrue(debugMenuRepository.getFeatureFlag(FlagKey.DummyBoolean)!!) + } + + @Test + fun `getFeatureFlag should return the feature flag string value from disk`() { + Assertions.assertEquals( + TEST_STRING_VALUE, + debugMenuRepository.getFeatureFlag(FlagKey.DummyString)!!, + ) + } + + @Test + fun `getFeatureFlag should return the feature flag int value from disk`() { + Assertions.assertEquals( + TEST_INT_VALUE, + debugMenuRepository.getFeatureFlag(FlagKey.DummyInt())!!, + ) + } + + @Test + fun `getFeatureFlag should return null if the feature flag does not exist in disk`() { + every { mockFeatureFlagOverrideDiskSource.getFeatureFlag(any()) } returns null + Assertions.assertNull(debugMenuRepository.getFeatureFlag(FlagKey.DummyBoolean)) + } + + @Suppress("MaxLineLength") + @Test + fun `resetFeatureFlagOverrides should reset flags to default values if they don't exist in server config`() = + runTest { + debugMenuRepository.resetFeatureFlagOverrides() + verify(exactly = 1) { + mockFeatureFlagOverrideDiskSource.saveFeatureFlag( + FlagKey.PasswordManagerSync, + FlagKey.PasswordManagerSync.defaultValue, + ) + mockFeatureFlagOverrideDiskSource.saveFeatureFlag( + FlagKey.BitwardenAuthenticationEnabled, + FlagKey.BitwardenAuthenticationEnabled.defaultValue, + ) + } + debugMenuRepository.featureFlagOverridesUpdatedFlow.test { + awaitItem() // initial value on subscription + awaitItem() + expectNoEvents() + } + } + + @Suppress("MaxLineLength") + @Test + fun `resetFeatureFlagOverrides should save all feature flags to values from the server config if remote configured is on`() = + runTest { + val mockServerData = + mockk( + relaxed = true, + ) { + every { featureStates } returns mapOf( + FlagKey.PasswordManagerSync.keyName to JsonPrimitive( + true, + ), + FlagKey.BitwardenAuthenticationEnabled.keyName to JsonPrimitive( + false, + ), + ) + } + val mockServerConfig = + mockk( + relaxed = true, + ) { + every { serverData } returns mockServerData + } + mutableServerConfigStateFlow.value = mockServerConfig + + debugMenuRepository.resetFeatureFlagOverrides() + + Assertions.assertTrue(FlagKey.PasswordManagerSync.isRemotelyConfigured) + Assertions.assertFalse(FlagKey.BitwardenAuthenticationEnabled.isRemotelyConfigured) + verify(exactly = 1) { + mockFeatureFlagOverrideDiskSource.saveFeatureFlag( + FlagKey.PasswordManagerSync, + true, + ) + mockFeatureFlagOverrideDiskSource.saveFeatureFlag( + FlagKey.BitwardenAuthenticationEnabled, + false, + ) + } + + debugMenuRepository.featureFlagOverridesUpdatedFlow.test { + awaitItem() // initial value on subscription + awaitItem() + cancel() + } + } +} + +private const val TEST_STRING_VALUE = "test" +private const val TEST_INT_VALUE = 100 diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/FeatureFlagRepositoryTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/FeatureFlagRepositoryTest.kt new file mode 100644 index 0000000000..a77b0e2d58 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/FeatureFlagRepositoryTest.kt @@ -0,0 +1,53 @@ +package com.bitwarden.authenticator.data.platform.repository + +import app.cash.turbine.test +import com.bitwarden.authenticator.data.platform.base.FakeDispatcherManager +import com.bitwarden.authenticator.data.platform.datasource.disk.model.FeatureFlagsConfiguration +import com.bitwarden.authenticator.data.platform.datasource.disk.util.FakeFeatureFlagDiskSource +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull + +class FeatureFlagRepositoryTest { + + private val fakeFeatureFlagDiskSource = FakeFeatureFlagDiskSource() + private val featureFlagRepo = FeatureFlagRepositoryImpl( + featureFlagDiskSource = fakeFeatureFlagDiskSource, + dispatcherManager = FakeDispatcherManager(), + ) + + @Suppress("MaxLineLength") + @Test + fun `getFeatureFlagsConfiguration should init configuration with local flags when there is none in state`() = + runTest { + assertNull(fakeFeatureFlagDiskSource.featureFlagsConfiguration) + + featureFlagRepo.getFeatureFlagsConfiguration() + + assertEquals( + FEATURE_FLAGS_CONFIG, + fakeFeatureFlagDiskSource.featureFlagsConfiguration, + ) + } + + @Test + fun `featureFlagsConfigurationFlow should react to feature flag configuration changes`() = + runTest { + featureFlagRepo.getFeatureFlagsConfiguration() + + featureFlagRepo.featureFlagConfigStateFlow.test { + assertEquals(fakeFeatureFlagDiskSource.featureFlagsConfiguration, awaitItem()) + } + } +} + +private val FEATURE_FLAGS_CONFIG = + FeatureFlagsConfiguration( + featureFlags = mapOf( + FlagKey.BitwardenAuthenticationEnabled.keyName to + JsonPrimitive(FlagKey.BitwardenAuthenticationEnabled.defaultValue), + ), + ) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepositoryTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepositoryTest.kt new file mode 100644 index 0000000000..dbb1b41d21 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepositoryTest.kt @@ -0,0 +1,169 @@ +package com.bitwarden.authenticator.data.platform.repository + +import app.cash.turbine.test +import com.bitwarden.authenticator.data.platform.base.FakeDispatcherManager +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.data.platform.datasource.disk.util.FakeConfigDiskSource +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson.EnvironmentJson +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson.ServerJson +import com.bitwarden.authenticator.data.platform.datasource.network.service.ConfigService +import com.bitwarden.authenticator.data.platform.manager.DispatcherManager +import com.bitwarden.authenticator.data.platform.util.asSuccess +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +class ServerConfigRepositoryTest { + private val fakeDispatcherManager: DispatcherManager = FakeDispatcherManager() + private val fakeConfigDiskSource = FakeConfigDiskSource() + private val configService: ConfigService = mockk { + coEvery { + getConfig() + } returns CONFIG_RESPONSE_JSON.asSuccess() + } + + private val fixedClock: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ) + + private val repository = ServerConfigRepositoryImpl( + configDiskSource = fakeConfigDiskSource, + configService = configService, + clock = fixedClock, + dispatcherManager = fakeDispatcherManager, + ) + + @BeforeEach + fun setUp() { + fakeConfigDiskSource.serverConfig = null + } + + @Test + fun `getServerConfig should fetch a new server configuration with force refresh as true`() = + runTest { + coEvery { + configService.getConfig() + } returns CONFIG_RESPONSE_JSON.copy(version = "NEW VERSION").asSuccess() + + fakeConfigDiskSource.serverConfig = SERVER_CONFIG.copy( + lastSync = fixedClock.instant().toEpochMilli(), + ) + + assertEquals( + fakeConfigDiskSource.serverConfig, + SERVER_CONFIG, + ) + + repository.getServerConfig(forceRefresh = true) + + assertNotEquals( + fakeConfigDiskSource.serverConfig, + SERVER_CONFIG, + ) + } + + @Test + fun `getServerConfig should fetch a new server configuration if there is none in state`() = + runTest { + assertNull( + fakeConfigDiskSource.serverConfig, + ) + + repository.getServerConfig(forceRefresh = false) + + assertEquals( + fakeConfigDiskSource.serverConfig, + SERVER_CONFIG, + ) + } + + @Test + fun `getServerConfig should return state server config if refresh is not necessary`() = + runTest { + val testConfig = SERVER_CONFIG.copy( + lastSync = fixedClock.instant().plusSeconds(1000L).toEpochMilli(), + serverData = CONFIG_RESPONSE_JSON.copy( + version = "new version!!", + ), + ) + fakeConfigDiskSource.serverConfig = testConfig + + coEvery { + configService.getConfig() + } returns CONFIG_RESPONSE_JSON.asSuccess() + + repository.getServerConfig(forceRefresh = false) + + assertEquals( + fakeConfigDiskSource.serverConfig, + testConfig, + ) + } + + @Test + fun `serverConfigStateFlow should react to new server configurations`() = runTest { + repository.getServerConfig(forceRefresh = true) + + repository.serverConfigStateFlow.test { + assertEquals(fakeConfigDiskSource.serverConfig, awaitItem()) + } + } +} + +private val SERVER_CONFIG = ServerConfig( + lastSync = Instant.parse("2023-10-27T12:00:00Z").toEpochMilli(), + serverData = ConfigResponseJson( + type = null, + version = "2024.7.0", + gitHash = "25cf6119-dirty", + server = ServerJson( + name = "example", + url = "https://localhost:8080", + ), + environment = EnvironmentJson( + cloudRegion = null, + vaultUrl = "https://localhost:8080", + apiUrl = "http://localhost:4000", + identityUrl = "http://localhost:33656", + notificationsUrl = "http://localhost:61840", + ssoUrl = "http://localhost:51822", + ), + featureStates = mapOf( + "duo-redirect" to JsonPrimitive(true), + "flexible-collections-v-1" to JsonPrimitive(false), + ), + ), +) + +private val CONFIG_RESPONSE_JSON = ConfigResponseJson( + type = null, + version = "2024.7.0", + gitHash = "25cf6119-dirty", + server = ServerJson( + name = "example", + url = "https://localhost:8080", + ), + environment = EnvironmentJson( + cloudRegion = null, + vaultUrl = "https://localhost:8080", + apiUrl = "http://localhost:4000", + identityUrl = "http://localhost:33656", + notificationsUrl = "http://localhost:61840", + ssoUrl = "http://localhost:51822", + ), + featureStates = mapOf( + "duo-redirect" to JsonPrimitive(true), + "flexible-collections-v-1" to JsonPrimitive(false), + ), +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt new file mode 100644 index 0000000000..09920bad9d --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt @@ -0,0 +1,137 @@ +package com.bitwarden.authenticator.data.platform.repository + +import app.cash.turbine.test +import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource +import com.bitwarden.authenticator.data.auth.datasource.disk.util.FakeAuthDiskSource +import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource +import com.bitwarden.authenticator.data.platform.base.FakeDispatcherManager +import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SettingsRepositoryTest { + + private val settingsDiskSource: SettingsDiskSource = mockk { + every { getAlertThresholdSeconds() } returns 7 + } + private val authDiskSource: AuthDiskSource = FakeAuthDiskSource() + private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk() + private val authenticatorSdkSource: AuthenticatorSdkSource = mockk() + + private val settingsRepository = SettingsRepositoryImpl( + settingsDiskSource = settingsDiskSource, + authDiskSource = authDiskSource, + biometricsEncryptionManager = biometricsEncryptionManager, + authenticatorSdkSource = authenticatorSdkSource, + dispatcherManager = FakeDispatcherManager(), + ) + + @Test + fun `hasUserDismissedDownloadBitwardenCard should return false when disk source is null`() { + every { settingsDiskSource.hasUserDismissedDownloadBitwardenCard } returns null + assertFalse(settingsRepository.hasUserDismissedDownloadBitwardenCard) + } + + @Test + fun `hasUserDismissedDownloadBitwardenCard should return false when disk source is false`() { + every { settingsDiskSource.hasUserDismissedDownloadBitwardenCard } returns false + assertFalse(settingsRepository.hasUserDismissedDownloadBitwardenCard) + } + + @Test + fun `hasUserDismissedDownloadBitwardenCard should return true when disk source is true`() { + every { settingsDiskSource.hasUserDismissedDownloadBitwardenCard } returns true + assertTrue(settingsRepository.hasUserDismissedDownloadBitwardenCard) + } + + @Test + fun `hasUserDismissedSyncWithBitwardenCard should return false when disk source is null`() { + every { settingsDiskSource.hasUserDismissedSyncWithBitwardenCard } returns null + assertFalse(settingsRepository.hasUserDismissedSyncWithBitwardenCard) + } + + @Test + fun `hasUserDismissedSyncWithBitwardenCard should return false when disk source is false`() { + every { settingsDiskSource.hasUserDismissedSyncWithBitwardenCard } returns false + assertFalse(settingsRepository.hasUserDismissedSyncWithBitwardenCard) + } + + @Test + fun `hasUserDismissedSyncWithBitwardenCard should return true when disk source is true`() { + every { settingsDiskSource.hasUserDismissedSyncWithBitwardenCard } returns true + assertTrue(settingsRepository.hasUserDismissedSyncWithBitwardenCard) + } + + @Test + fun `hasUserDismissedSyncWithBitwardenCard set should set disk source`() { + every { settingsDiskSource.hasUserDismissedSyncWithBitwardenCard = true } just runs + settingsRepository.hasUserDismissedSyncWithBitwardenCard = true + verify { settingsRepository.hasUserDismissedSyncWithBitwardenCard = true } + } + + @Test + fun `defaultSaveOption should pull from and update SettingsDiskSource`() { + // Reading from repository should read from disk source: + every { settingsDiskSource.defaultSaveOption } returns DefaultSaveOption.NONE + assertEquals( + DefaultSaveOption.NONE, + settingsRepository.defaultSaveOption, + ) + verify { settingsDiskSource.defaultSaveOption } + + // Writing to repository should write to disk source: + every { settingsDiskSource.defaultSaveOption = DefaultSaveOption.BITWARDEN_APP } just runs + settingsRepository.defaultSaveOption = DefaultSaveOption.BITWARDEN_APP + verify { settingsDiskSource.defaultSaveOption = DefaultSaveOption.BITWARDEN_APP } + } + + @Test + fun `defaultSaveOptionFlow should match SettingsDiskSource`() = runTest { + // Reading from repository should read from disk source: + val expectedOptions = listOf( + DefaultSaveOption.NONE, + DefaultSaveOption.LOCAL, + DefaultSaveOption.BITWARDEN_APP, + DefaultSaveOption.NONE, + ) + every { settingsDiskSource.defaultSaveOptionFlow } returns flow { + expectedOptions.forEach { emit(it) } + } + + settingsRepository.defaultSaveOptionFlow.test { + expectedOptions.forEach { + assertEquals(it, awaitItem()) + } + awaitComplete() + } + } + + @Test + fun `previouslySyncedBitwardenAccountIds should pull from and update SettingsDiskSource`() { + // Reading from repository should read from disk source: + every { settingsDiskSource.previouslySyncedBitwardenAccountIds } returns emptySet() + assertEquals( + emptySet(), + settingsRepository.previouslySyncedBitwardenAccountIds, + ) + verify { settingsDiskSource.previouslySyncedBitwardenAccountIds } + + // Writing to repository should write to disk source: + every { + settingsDiskSource.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3") + } just runs + settingsRepository.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3") + verify { settingsDiskSource.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3") } + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt new file mode 100644 index 0000000000..05f5a08d1f --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt @@ -0,0 +1,278 @@ +package com.bitwarden.authenticator.data.platform.repository.util + +import com.bitwarden.authenticator.data.auth.datasource.disk.model.EnvironmentUrlDataJson +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +class EnvironmentUrlsDataJsonExtensionsTest { + @Test + fun `baseApiUrl should return base if it is present`() { + Assertions.assertEquals( + "base/api", + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.baseApiUrl, + ) + } + + @Test + fun `baseApiUrl should return api value if base is empty`() { + Assertions.assertEquals( + "api", + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.copy( + base = "", + ).baseApiUrl, + ) + } + + @Test + fun `baseApiUrl should return default url if base is empty and api is null`() { + Assertions.assertEquals( + "https://api.bitwarden.com", + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.copy( + base = "", + api = null, + ).baseApiUrl, + ) + } + + @Test + fun `baseWebVaultUrlOrNull should return webVault when populated`() { + val result = + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.baseWebVaultUrlOrNull + Assertions.assertEquals("webVault", result) + } + + @Test + fun `baseWebVaultUrlOrNull should return base when webvault is not populated`() { + val result = + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA + .copy(webVault = null) + .baseWebVaultUrlOrNull + Assertions.assertEquals("base", result) + } + + @Test + fun `baseWebVaultUrlOrNull should return null when webvault and base are not populated`() { + val result = + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA + .copy( + webVault = null, + base = "", + ) + .baseWebVaultUrlOrNull + Assertions.assertNull(result) + } + + @Test + fun `baseWebVaultUrlOrDefault should return webVault when populated`() { + val result = + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.baseWebVaultUrlOrDefault + Assertions.assertEquals("webVault", result) + } + + @Test + fun `baseWebVaultUrlOrDefault should return base when webvault is not populated`() { + val result = + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA + .copy(webVault = null) + .baseWebVaultUrlOrDefault + Assertions.assertEquals("base", result) + } + + @Suppress("MaxLineLength") + @Test + fun `baseWebVaultUrlOrDefault should return the default when webvault and base are not populated`() { + val result = + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA + .copy( + webVault = null, + base = "", + ) + .baseWebVaultUrlOrDefault + Assertions.assertEquals("https://vault.bitwarden.com", result) + } + + @Test + fun `baseWebSendUrl should return the correct result when webVault when populated`() { + val result = + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.baseWebSendUrl + Assertions.assertEquals("webVault/#/send/", result) + } + + @Test + fun `baseWebSendUrl should return the correct result when webvault is not populated`() { + val result = + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA + .copy(webVault = null) + .baseWebSendUrl + Assertions.assertEquals("base/#/send/", result) + } + + @Test + fun `baseWebSendUrl should return the default when webvault and base are not populated`() { + val result = + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA + .copy( + webVault = null, + base = "", + ) + .baseWebSendUrl + Assertions.assertEquals("https://send.bitwarden.com/#", result) + } + + @Test + fun `labelOrBaseUrlHost should correctly convert US environment to the correct label`() { + val environment = + EnvironmentUrlDataJson.Companion.DEFAULT_US + Assertions.assertEquals( + com.bitwarden.authenticator.data.platform.repository.model.Environment.Us.label, + environment.labelOrBaseUrlHost, + ) + } + + @Test + fun `labelOrBaseUrlHost should correctly convert EU environment to the correct label`() { + val environment = + EnvironmentUrlDataJson.Companion.DEFAULT_EU + Assertions.assertEquals( + com.bitwarden.authenticator.data.platform.repository.model.Environment.Eu.label, + environment.labelOrBaseUrlHost, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `labelOrBaseUrlHost should correctly convert self hosted environment to the correct label`() { + val environment = + EnvironmentUrlDataJson(base = "https://vault.qa.bitwarden.pw") + Assertions.assertEquals( + "vault.qa.bitwarden.pw", + environment.labelOrBaseUrlHost, + ) + } + + @Test + fun `toEnvironmentUrls should correctly convert US urls to the expected type`() { + Assertions.assertEquals( + com.bitwarden.authenticator.data.platform.repository.model.Environment.Us, + EnvironmentUrlDataJson.Companion.DEFAULT_US.toEnvironmentUrls(), + ) + } + + @Test + fun `toEnvironmentUrls should correctly convert EU urls to the expected type`() { + Assertions.assertEquals( + com.bitwarden.authenticator.data.platform.repository.model.Environment.Eu, + EnvironmentUrlDataJson.Companion.DEFAULT_EU.toEnvironmentUrls(), + ) + } + + @Test + fun `toEnvironmentUrls should correctly convert custom urls to the expected type`() { + Assertions.assertEquals( + com.bitwarden.authenticator.data.platform.repository.model.Environment.SelfHosted( + environmentUrlData = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA, + ), + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.toEnvironmentUrls(), + ) + } + + @Test + fun `toEnvironmentUrlsOrDefault should correctly convert US urls to the expected type`() { + Assertions.assertEquals( + com.bitwarden.authenticator.data.platform.repository.model.Environment.Us, + EnvironmentUrlDataJson.Companion.DEFAULT_US.toEnvironmentUrlsOrDefault(), + ) + } + + @Test + fun `toEnvironmentUrlsOrDefault should correctly convert EU urls to the expected type`() { + Assertions.assertEquals( + com.bitwarden.authenticator.data.platform.repository.model.Environment.Eu, + EnvironmentUrlDataJson.Companion.DEFAULT_EU.toEnvironmentUrlsOrDefault(), + ) + } + + @Test + fun `toEnvironmentUrlsOrDefault should correctly convert custom urls to the expected type`() { + Assertions.assertEquals( + com.bitwarden.authenticator.data.platform.repository.model.Environment.SelfHosted( + environmentUrlData = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA, + ), + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.toEnvironmentUrlsOrDefault(), + ) + } + + @Test + fun `toEnvironmentUrlsOrDefault should convert null types to US values`() { + Assertions.assertEquals( + com.bitwarden.authenticator.data.platform.repository.model.Environment.Us, + (null as EnvironmentUrlDataJson?).toEnvironmentUrlsOrDefault(), + ) + } + + @Test + fun `toIconBaseurl should return icon if value is present`() { + Assertions.assertEquals( + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.baseIconUrl, + "icon", + ) + } + + @Test + fun `toIconBaseurl should return base value if icon is null`() { + Assertions.assertEquals( + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA + .copy(icon = null) + .baseIconUrl, + "base/icons", + ) + } + + @Test + fun `toIconBaseurl should return default url if base is empty and icon is null`() { + val expectedUrl = "https://icons.bitwarden.net" + + Assertions.assertEquals( + expectedUrl, + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA + .copy( + base = "", + icon = null, + ) + .baseIconUrl, + ) + } + + @Test + fun `toBaseWebVaultImportUrl should return correct url if webVault is empty`() { + val expectedUrl = "base/#/tools/import" + + Assertions.assertEquals( + expectedUrl, + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.copy( + webVault = null, + ) + .toBaseWebVaultImportUrl, + ) + } + + @Test + fun `toBaseWebVaultImportUrl should correctly convert to the import url`() { + val expectedUrl = "webVault/#/tools/import" + + Assertions.assertEquals( + expectedUrl, + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.toBaseWebVaultImportUrl, + ) + } +} + +private val DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA = EnvironmentUrlDataJson( + base = "base", + api = "api", + identity = "identity", + icon = "icon", + notifications = "notifications", + webVault = "webVault", + events = "events", +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/util/FakeFeatureFlagRepository.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/util/FakeFeatureFlagRepository.kt new file mode 100644 index 0000000000..936f52556f --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/util/FakeFeatureFlagRepository.kt @@ -0,0 +1,38 @@ +package com.bitwarden.authenticator.data.platform.repository.util + +import com.bitwarden.authenticator.data.platform.datasource.disk.model.FeatureFlagsConfiguration +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.data.platform.repository.FeatureFlagRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.json.JsonPrimitive + +/** + * Faked implementation of [FeatureFlagRepository] for testing. + */ +class FakeFeatureFlagRepository : FeatureFlagRepository { + var featureFlagsConfiguration: FeatureFlagsConfiguration? + get() = mutableFeatureFlagsConfiguration.value + set(value) { + mutableFeatureFlagsConfiguration.value = value + } + + private val mutableFeatureFlagsConfiguration = + MutableStateFlow(FEATURE_FLAGS_CONFIG) + + override val featureFlagConfigStateFlow: StateFlow = + mutableFeatureFlagsConfiguration + + override suspend fun getFeatureFlagsConfiguration(): FeatureFlagsConfiguration { + return featureFlagsConfiguration + ?: FEATURE_FLAGS_CONFIG + } +} + +private val FEATURE_FLAGS_CONFIG = + FeatureFlagsConfiguration( + featureFlags = mapOf( + FlagKey.BitwardenAuthenticationEnabled.keyName to + JsonPrimitive(FlagKey.BitwardenAuthenticationEnabled.defaultValue), + ), + ) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/util/FakeServerConfigRepository.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/util/FakeServerConfigRepository.kt new file mode 100644 index 0000000000..5a82244208 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/repository/util/FakeServerConfigRepository.kt @@ -0,0 +1,58 @@ +package com.bitwarden.authenticator.data.platform.repository.util + +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson.EnvironmentJson +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson.ServerJson +import com.bitwarden.authenticator.data.platform.repository.ServerConfigRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.json.JsonPrimitive +import java.time.Instant + +class FakeServerConfigRepository : ServerConfigRepository { + var serverConfigValue: ServerConfig? + get() = mutableServerConfigFlow.value + set(value) { + mutableServerConfigFlow.value = value + } + + private val mutableServerConfigFlow = MutableStateFlow(SERVER_CONFIG) + + override suspend fun getServerConfig(forceRefresh: Boolean): ServerConfig? { + if (forceRefresh) { + return SERVER_CONFIG + } + + return serverConfigValue + } + + override val serverConfigStateFlow: StateFlow + get() = mutableServerConfigFlow +} + +private val SERVER_CONFIG = ServerConfig( + lastSync = Instant.parse("2023-10-27T12:00:00Z").toEpochMilli(), + serverData = ConfigResponseJson( + type = null, + version = "2024.7.0", + gitHash = "25cf6119-dirty", + server = ServerJson( + name = "example", + url = "https://localhost:8080", + ), + environment = EnvironmentJson( + cloudRegion = null, + vaultUrl = "https://localhost:8080", + apiUrl = "http://localhost:4000", + identityUrl = "http://localhost:33656", + notificationsUrl = "http://localhost:61840", + ssoUrl = "http://localhost:51822", + ), + featureStates = mapOf( + "duo-redirect" to JsonPrimitive(true), + "flexible-collections-v-1" to JsonPrimitive(false), + "dummy-boolean" to JsonPrimitive(true), + ), + ), +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/util/IntentExtensionsTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/util/IntentExtensionsTest.kt new file mode 100644 index 0000000000..a158eb8ff8 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/util/IntentExtensionsTest.kt @@ -0,0 +1,64 @@ +package com.bitwarden.authenticator.data.platform.util + +import android.content.Intent +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class IntentExtensionsTest { + @Test + fun `isSuspicious should return true when extras are not empty`() { + val intent = mockk { + every { data } returns mockk() + every { extras } returns mockk { + every { isEmpty } returns false + } + } + + assertTrue(intent.isSuspicious) + } + + @Test + fun `isSuspicious should return true when extras are null`() { + val intent = mockk { + every { data } returns mockk() + every { extras } returns null + } + + assertTrue(intent.isSuspicious) + } + + @Test + fun `isSuspicious should return true when data is not null`() { + val intent = mockk { + every { data } returns mockk() + every { extras } returns null + } + + assertTrue(intent.isSuspicious) + } + + @Test + fun `isSuspicious should return false when data and extras are null`() { + val intent = mockk { + every { data } returns null + every { extras } returns null + } + + assertFalse(intent.isSuspicious) + } + + @Test + fun `isSuspicious should return false when data is null and extras are empty`() { + val intent = mockk { + every { data } returns null + every { extras } returns mockk { + every { isEmpty } returns true + } + } + + assertFalse(intent.isSuspicious) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/util/JsonExtensionsTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/util/JsonExtensionsTest.kt new file mode 100644 index 0000000000..dd580bd70d --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/util/JsonExtensionsTest.kt @@ -0,0 +1,55 @@ +package com.bitwarden.authenticator.data.platform.util + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class JsonExtensionsTest { + private val json = Json + + @Test + fun `decodeFromStringOrNull for invalid JSON should return null`() { + assertNull( + json.decodeFromStringOrNull( + """ + {] + """, + ), + ) + } + + @Test + fun `decodeFromStringOrNull for valid JSON but an incorrect model should return null`() { + assertNull( + json.decodeFromStringOrNull( + """ + {} + """, + ), + ) + } + + @Test + fun `decodeFromStringOrNull for valid JSON and a correct model should parse correctly`() { + assertEquals( + TestData( + data = "test", + ), + json.decodeFromStringOrNull( + """ + { + "data": "test" + } + """, + ), + ) + } +} + +@Serializable +private data class TestData( + @SerialName("data") val data: String, +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/util/SpecialCharWithPrecedenceComparatorTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/util/SpecialCharWithPrecedenceComparatorTest.kt new file mode 100644 index 0000000000..09ca65f0d7 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/data/platform/util/SpecialCharWithPrecedenceComparatorTest.kt @@ -0,0 +1,44 @@ +package com.bitwarden.authenticator.data.platform.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SpecialCharWithPrecedenceComparatorTest { + + @Test + fun `Sorting with comparator should return expected result of sorted string`() { + val unsortedList = listOf( + "__Za", + "z", + "___", + "1a3", + "aBc", + "__a", + "__A", + "__a", + "__4", + "Z", + "__3", + "Abc", + ) + val expectedSortedList = listOf( + "___", + "__3", + "__4", + "__a", + "__a", + "__A", + "__Za", + "1a3", + "aBc", + "Abc", + "z", + "Z", + ) + + assertEquals( + expectedSortedList, + unsortedList.sortedWith(SpecialCharWithPrecedenceComparator), + ) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt new file mode 100644 index 0000000000..ced58fe8f4 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt @@ -0,0 +1,320 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTouchInput +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.SharedCodesDisplayState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VaultDropdownMenuAction +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem +import com.bitwarden.authenticator.ui.platform.base.BaseComposeTest +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import com.bitwarden.authenticator.ui.platform.manager.permissions.FakePermissionManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test + +class ItemListingScreenTest : BaseComposeTest() { + + private var onNavigateBackCalled = false + private var onNavigateToSearchCalled = false + private var onNavigateToQrCodeScannerCalled = false + private var onNavigateToManualKeyEntryCalled = false + private var onNavigateToEditItemScreenCalled = false + + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + + private val viewModel: ItemListingViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + every { trySendAction(any()) } just runs + } + + private val intentManager: IntentManager = mockk() + private val permissionsManager = FakePermissionManager() + + @Before + fun setup() { + composeTestRule.setContent { + ItemListingScreen( + viewModel = viewModel, + intentManager = intentManager, + permissionsManager = permissionsManager, + onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToSearch = { onNavigateToSearchCalled = true }, + onNavigateToQrCodeScanner = { onNavigateToQrCodeScannerCalled = true }, + onNavigateToManualKeyEntry = { onNavigateToManualKeyEntryCalled = true }, + onNavigateToEditItemScreen = { onNavigateToEditItemScreenCalled = true }, + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `shared accounts error message should show when view is Content with SharedCodesDisplayState Error`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SharedCodesDisplayState.Error, + ), + ) + + composeTestRule + .onNodeWithText("Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app.") + .assertIsDisplayed() + + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + ), + ) + + composeTestRule + .onNodeWithText("Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app.") + .assertDoesNotExist() + } + + @Test + fun `clicking shared accounts verification code item should send ItemClick action`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SharedCodesDisplayState.Codes( + sections = listOf( + SHARED_ACCOUNTS_SECTION, + ), + ), + ), + ) + + composeTestRule + .onNodeWithText("joe+shared_code_1@test.com") + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + ItemListingAction.ItemClick(SHARED_ACCOUNTS_SECTION.codes[0].authCode), + ) + } + + // Make sure long press sends action as well, since local items have long press options + // but shared items do not: + composeTestRule + .onNodeWithText("joe+shared_code_1@test.com") + .performTouchInput { longClick() } + + verify { + viewModel.trySendAction( + ItemListingAction.ItemClick(SHARED_ACCOUNTS_SECTION.codes[0].authCode), + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `on NavigateToBitwardenSettings receive should launch bitwarden account security deep link`() { + every { intentManager.startMainBitwardenAppAccountSettings() } just runs + mutableEventFlow.tryEmit(ItemListingEvent.NavigateToBitwardenSettings) + verify { intentManager.startMainBitwardenAppAccountSettings() } + } + + @Test + @Suppress("MaxLineLength") + fun `on sync with bitwarden action card click in empty state should send SyncWithBitwardenClick`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.SyncWithBitwarden, + ), + ) + composeTestRule + .onNodeWithText("Sync with Bitwarden app") + .performClick() + verify { viewModel.trySendAction(ItemListingAction.SyncWithBitwardenClick) } + } + + @Test + @Suppress("MaxLineLength") + fun `on sync with bitwarden action card click in full state should send SyncWithBitwardenClick`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + actionCard = ItemListingState.ActionCardState.SyncWithBitwarden, + ), + ) + composeTestRule + .onNodeWithText("Sync with Bitwarden app") + .performClick() + verify { viewModel.trySendAction(ItemListingAction.SyncWithBitwardenClick) } + } + + @Test + @Suppress("MaxLineLength") + fun `on sync with bitwarden action card dismiss in empty state should send SyncWithBitwardenDismiss`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.SyncWithBitwarden, + ), + ) + composeTestRule + .onNodeWithContentDescription("Close") + .performClick() + verify { viewModel.trySendAction(ItemListingAction.SyncWithBitwardenDismiss) } + } + + @Test + @Suppress("MaxLineLength") + fun `on sync with bitwarden action card dismiss in full state should send SyncWithBitwardenDismiss`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + actionCard = ItemListingState.ActionCardState.SyncWithBitwarden, + ), + ) + composeTestRule + .onNodeWithContentDescription("Close") + .performClick() + verify { viewModel.trySendAction(ItemListingAction.SyncWithBitwardenDismiss) } + } + + @Test + fun `clicking Move to Bitwarden should send MoveToBitwardenClick`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = emptyList(), + itemList = listOf(LOCAL_CODE), + sharedItems = SharedCodesDisplayState.Error, + ), + ) + composeTestRule + .onNodeWithText("issuer") + .performTouchInput { longClick() } + + composeTestRule + .onNodeWithText("Move to Bitwarden") + .performClick() + + verify { + viewModel.trySendAction( + ItemListingAction.DropdownMenuClick( + menuAction = VaultDropdownMenuAction.MOVE, + item = LOCAL_CODE, + ), + ) + } + } + + @Test + fun `Move to Bitwarden long press action should not show when showMoveToBitwarden is false`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = emptyList(), + itemList = listOf(LOCAL_CODE.copy(showMoveToBitwarden = false)), + sharedItems = SharedCodesDisplayState.Error, + ), + ) + composeTestRule + .onNodeWithText("issuer") + .performTouchInput { longClick() } + + composeTestRule + .onNodeWithText("Move to Bitwarden") + .assertDoesNotExist() + } + + @Test + fun `on ShowFirstTimeSyncSnackbar receive should show snackbar`() { + mutableStateFlow.update { + DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + ), + ) + } + // Make sure the snackbar isn't showing: + composeTestRule + .onNodeWithText("Account synced from Bitwarden app") + .assertIsNotDisplayed() + + // Send ShowFirstTimeSyncSnackbar event + mutableEventFlow.tryEmit(ItemListingEvent.ShowFirstTimeSyncSnackbar) + + // Make sure the snackbar is showing: + composeTestRule + .onNodeWithText("Account synced from Bitwarden app") + .assertIsDisplayed() + } +} + +private val APP_THEME = AppTheme.DEFAULT +private const val ALERT_THRESHOLD = 7 + +private val LOCAL_CODE = VerificationCodeDisplayItem( + id = "1", + title = "issuer", + subtitle = null, + timeLeftSeconds = 10, + periodSeconds = 30, + alertThresholdSeconds = 7, + authCode = "123456", + favorite = false, + allowLongPressActions = true, + showMoveToBitwarden = true, +) + +private val SHARED_ACCOUNTS_SECTION = SharedCodesDisplayState.SharedCodesAccountSection( + label = "test@test.com".asText(), + codes = listOf( + VerificationCodeDisplayItem( + id = "1", + title = "bitwarden.com", + subtitle = "joe+shared_code_1@test.com", + timeLeftSeconds = 10, + periodSeconds = 30, + alertThresholdSeconds = ALERT_THRESHOLD, + authCode = "123456", + favorite = false, + allowLongPressActions = false, + showMoveToBitwarden = false, + ), + ), +) + +private val DEFAULT_STATE = ItemListingState( + appTheme = APP_THEME, + alertThresholdSeconds = ALERT_THRESHOLD, + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.None, + ), + dialog = null, +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModelTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModelTest.kt new file mode 100644 index 0000000000..318224bad7 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModelTest.kt @@ -0,0 +1,578 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting + +import app.cash.turbine.test +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.platform.manager.BitwardenEncodingManager +import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.data.platform.repository.model.DataState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.SharedCodesDisplayState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VaultDropdownMenuAction +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util.toDisplayItem +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util.toSharedCodesDisplayState +import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ItemListingViewModelTest : BaseViewModelTest() { + + private val mutableAuthenticatorAlertThresholdFlow = + MutableStateFlow(AUTHENTICATOR_ALERT_SECONDS) + private val mutableAppThemeFlow = MutableStateFlow(APP_THEME) + private val mutableVerificationCodesFlow = + MutableStateFlow>>(DataState.Loading) + private val mutableSharedCodesFlow = + MutableStateFlow(SharedVerificationCodesState.Loading) + private val firstTimeAccountSyncChannel: Channel = + Channel(capacity = Channel.UNLIMITED) + + private val authenticatorRepository: AuthenticatorRepository = mockk { + every { totpCodeFlow } returns emptyFlow() + every { getLocalVerificationCodesFlow() } returns mutableVerificationCodesFlow + every { sharedCodesStateFlow } returns mutableSharedCodesFlow + every { firstTimeAccountSyncFlow } returns firstTimeAccountSyncChannel.receiveAsFlow() + } + private val authenticatorBridgeManager: AuthenticatorBridgeManager = mockk() + private val clipboardManager: BitwardenClipboardManager = mockk() + private val encodingManager: BitwardenEncodingManager = mockk() + private val settingsRepository: SettingsRepository = mockk { + every { appTheme } returns mutableAppThemeFlow.value + every { + authenticatorAlertThresholdSeconds + } returns mutableAuthenticatorAlertThresholdFlow.value + every { + authenticatorAlertThresholdSecondsFlow + } returns mutableAuthenticatorAlertThresholdFlow + every { appThemeStateFlow } returns mutableAppThemeFlow + every { hasUserDismissedDownloadBitwardenCard } returns false + } + + @Test + fun `initial state should be correct`() { + val viewModel = createViewModel() + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow value should show download bitwarden action card when local items are empty and shared state is AppNotInstalled`() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.DownloadBitwardenApp, + ), + ) + mutableSharedCodesFlow.value = SharedVerificationCodesState.AppNotInstalled + mutableVerificationCodesFlow.value = DataState.Loaded(emptyList()) + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow value should not show download bitwarden card when local items are empty and shared state is AppNotInstalled but user has dismissed card`() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.None, + ), + ) + every { settingsRepository.hasUserDismissedDownloadBitwardenCard } returns true + mutableSharedCodesFlow.value = SharedVerificationCodesState.AppNotInstalled + mutableVerificationCodesFlow.value = DataState.Loaded(emptyList()) + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow value should show download bitwarden card when there are local items and shared state is AppNotInstalled`() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.DownloadBitwardenApp, + favoriteItems = LOCAL_FAVORITE_ITEMS, + itemList = LOCAL_NON_FAVORITE_ITEMS, + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + ), + ) + every { settingsRepository.hasUserDismissedDownloadBitwardenCard } returns false + mutableVerificationCodesFlow.value = DataState.Loaded(LOCAL_VERIFICATION_ITEMS) + mutableSharedCodesFlow.value = SharedVerificationCodesState.AppNotInstalled + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow value should not show download bitwarden card when there are local items and shared state is AppNotInstalled but user has dismissed card`() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = LOCAL_FAVORITE_ITEMS, + itemList = LOCAL_NON_FAVORITE_ITEMS, + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + ), + ) + every { settingsRepository.hasUserDismissedDownloadBitwardenCard } returns true + mutableVerificationCodesFlow.value = DataState.Loaded(LOCAL_VERIFICATION_ITEMS) + mutableSharedCodesFlow.value = SharedVerificationCodesState.AppNotInstalled + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow sharedItems value should be Error when shared state is Error `() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = LOCAL_FAVORITE_ITEMS, + itemList = LOCAL_NON_FAVORITE_ITEMS, + sharedItems = SharedCodesDisplayState.Error, + ), + ) + mutableVerificationCodesFlow.value = DataState.Loaded(LOCAL_VERIFICATION_ITEMS) + mutableSharedCodesFlow.value = SharedVerificationCodesState.Error + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow sharedItems value should be Codes with empty list when shared state is Success `() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = LOCAL_FAVORITE_ITEMS.map { it.copy(showMoveToBitwarden = true) }, + itemList = LOCAL_NON_FAVORITE_ITEMS.map { it.copy(showMoveToBitwarden = true) }, + sharedItems = SHARED_DISPLAY_ITEMS, + ), + ) + mutableVerificationCodesFlow.value = DataState.Loaded(LOCAL_VERIFICATION_ITEMS) + mutableSharedCodesFlow.value = + SharedVerificationCodesState.Success(SHARED_VERIFICATION_ITEMS) + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow sharedItems value should show items even when local items are empty`() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.None, + ), + ) + mutableVerificationCodesFlow.value = DataState.Loaded(emptyList()) + mutableSharedCodesFlow.value = + SharedVerificationCodesState.Success(emptyList()) + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow viewState value should be NoItems when both local and shared codes are empty`() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SHARED_DISPLAY_ITEMS, + ), + ) + mutableVerificationCodesFlow.value = DataState.Loaded(emptyList()) + mutableSharedCodesFlow.value = + SharedVerificationCodesState.Success(SHARED_VERIFICATION_ITEMS) + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `on DownloadBitwardenClick receive should emit NavigateToBitwardenListing`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ItemListingAction.DownloadBitwardenClick) + assertEquals(ItemListingEvent.NavigateToBitwardenListing, awaitItem()) + } + } + + @Test + @Suppress("MaxLineLength") + fun `on DownloadBitwardenDismiss receive should dismiss action card and store dismissal in settings`() = + runTest { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = LOCAL_FAVORITE_ITEMS, + itemList = LOCAL_NON_FAVORITE_ITEMS, + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + ), + ) + every { settingsRepository.hasUserDismissedDownloadBitwardenCard = true } just runs + every { settingsRepository.hasUserDismissedDownloadBitwardenCard } returns false + mutableVerificationCodesFlow.value = DataState.Loaded(LOCAL_VERIFICATION_ITEMS) + val viewModel = createViewModel() + viewModel.trySendAction(ItemListingAction.DownloadBitwardenDismiss) + verify { settingsRepository.hasUserDismissedDownloadBitwardenCard = true } + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `on DownloadBitwardenDismiss receive in empty state should dismiss action card and store dismissal in settings`() = + runTest { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.None, + ), + ) + every { settingsRepository.hasUserDismissedDownloadBitwardenCard = true } just runs + every { settingsRepository.hasUserDismissedDownloadBitwardenCard } returns false + mutableVerificationCodesFlow.value = DataState.Loaded(emptyList()) + val viewModel = createViewModel() + viewModel.trySendAction(ItemListingAction.DownloadBitwardenDismiss) + verify { settingsRepository.hasUserDismissedDownloadBitwardenCard = true } + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `on SyncWithBitwardenClick receive should emit NavigateToBitwardenSettings`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ItemListingAction.SyncWithBitwardenClick) + assertEquals(ItemListingEvent.NavigateToBitwardenSettings, awaitItem()) + } + } + + @Test + @Suppress("MaxLineLength") + fun `on SyncWithBitwardenDismiss receive should dismiss action card and store dismissal in settings`() = + runTest { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = LOCAL_FAVORITE_ITEMS, + itemList = LOCAL_NON_FAVORITE_ITEMS, + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + ), + ) + mutableSharedCodesFlow.value = SharedVerificationCodesState.SyncNotEnabled + every { settingsRepository.hasUserDismissedSyncWithBitwardenCard = true } just runs + every { settingsRepository.hasUserDismissedSyncWithBitwardenCard } returns false + mutableVerificationCodesFlow.value = DataState.Loaded(LOCAL_VERIFICATION_ITEMS) + val viewModel = createViewModel() + viewModel.trySendAction(ItemListingAction.SyncWithBitwardenDismiss) + verify { settingsRepository.hasUserDismissedSyncWithBitwardenCard = true } + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `on SyncWithBitwardenDismiss receive in empty state should dismiss action card and store dismissal in settings`() = + runTest { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.None, + ), + ) + every { settingsRepository.hasUserDismissedSyncWithBitwardenCard = true } just runs + every { settingsRepository.hasUserDismissedSyncWithBitwardenCard } returns false + mutableVerificationCodesFlow.value = DataState.Loaded(emptyList()) + val viewModel = createViewModel() + viewModel.trySendAction(ItemListingAction.SyncWithBitwardenDismiss) + verify { settingsRepository.hasUserDismissedSyncWithBitwardenCard = true } + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow value should show sync with bitwarden action card when local items are empty and shared state is SyncNotEnabled`() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.SyncWithBitwarden, + ), + ) + every { settingsRepository.hasUserDismissedSyncWithBitwardenCard } returns false + mutableSharedCodesFlow.value = SharedVerificationCodesState.SyncNotEnabled + mutableVerificationCodesFlow.value = DataState.Loaded(emptyList()) + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow value should not show download bitwarden card when local items are empty and shared state is SyncNotEnabled but user has dismissed card`() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.None, + ), + ) + every { settingsRepository.hasUserDismissedSyncWithBitwardenCard } returns true + mutableSharedCodesFlow.value = SharedVerificationCodesState.SyncNotEnabled + mutableVerificationCodesFlow.value = DataState.Loaded(emptyList()) + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow value should show sync with bitwarden card when there are local items and shared state is SyncNotEnabled`() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.SyncWithBitwarden, + favoriteItems = LOCAL_FAVORITE_ITEMS, + itemList = LOCAL_NON_FAVORITE_ITEMS, + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + ), + ) + every { settingsRepository.hasUserDismissedSyncWithBitwardenCard } returns false + mutableVerificationCodesFlow.value = DataState.Loaded(LOCAL_VERIFICATION_ITEMS) + mutableSharedCodesFlow.value = SharedVerificationCodesState.SyncNotEnabled + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow value should not show sync with bitwarden card when there are local items and shared state is AppNotInstalled but user has dismissed card`() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = LOCAL_FAVORITE_ITEMS, + itemList = LOCAL_NON_FAVORITE_ITEMS, + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + ), + ) + every { settingsRepository.hasUserDismissedSyncWithBitwardenCard } returns true + mutableVerificationCodesFlow.value = DataState.Loaded(LOCAL_VERIFICATION_ITEMS) + mutableSharedCodesFlow.value = SharedVerificationCodesState.SyncNotEnabled + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `on MoveToBitwardenClick receive should call startAddTotpLoginItemFlow`() { + val expectedUriString = "expectedUriString" + val entity: AuthenticatorItemEntity = mockk { + every { toOtpAuthUriString() } returns expectedUriString + } + every { + authenticatorRepository.getItemStateFlow("1") + } returns MutableStateFlow(DataState.Loaded(data = entity)) + every { + authenticatorBridgeManager.startAddTotpLoginItemFlow(expectedUriString) + } returns true + + val viewModel = createViewModel() + + viewModel.trySendAction( + ItemListingAction.DropdownMenuClick( + menuAction = VaultDropdownMenuAction.MOVE, + item = LOCAL_CODE, + ), + ) + verify { authenticatorBridgeManager.startAddTotpLoginItemFlow(expectedUriString) } + } + + @Test + @Suppress("MaxLineLength") + fun `on MoveToBitwardenClick should show error dialog when startAddTotpLoginItemFlow returns false`() { + val expectedState = DEFAULT_STATE.copy( + dialog = ItemListingState.DialogState.Error( + title = R.string.something_went_wrong.asText(), + message = R.string.please_try_again.asText(), + ), + ) + val expectedUriString = "expectedUriString" + val entity: AuthenticatorItemEntity = mockk { + every { toOtpAuthUriString() } returns expectedUriString + } + every { + authenticatorRepository.getItemStateFlow("1") + } returns MutableStateFlow(DataState.Loaded(data = entity)) + every { + authenticatorBridgeManager.startAddTotpLoginItemFlow(expectedUriString) + } returns false + + val viewModel = createViewModel() + viewModel.trySendAction( + ItemListingAction.DropdownMenuClick(VaultDropdownMenuAction.MOVE, LOCAL_CODE), + ) + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + verify { authenticatorBridgeManager.startAddTotpLoginItemFlow(expectedUriString) } + } + + @Test + fun `on FirstTimeUserSyncReceive should emit ShowFirstTimeSyncSnackbar`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + firstTimeAccountSyncChannel.send(Unit) + assertEquals(ItemListingEvent.ShowFirstTimeSyncSnackbar, awaitItem()) + } + } + + @Test + fun `should copy text to clipboard when DropdownMenuClick COPY is triggered`() = runTest { + val viewModel = createViewModel() + + every { clipboardManager.setText(text = LOCAL_CODE.authCode) } just runs + + viewModel.eventFlow.test { + viewModel.trySendAction( + ItemListingAction.DropdownMenuClick( + menuAction = VaultDropdownMenuAction.COPY, + item = LOCAL_CODE, + ), + ) + + verify(exactly = 1) { + clipboardManager.setText(text = LOCAL_CODE.authCode) + } + } + } + + @Test + fun `should trigger edit action when DropdownMenuClick EDIT is triggered`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction( + ItemListingAction.DropdownMenuClick(VaultDropdownMenuAction.EDIT, LOCAL_CODE), + ) + + assertEquals( + ItemListingEvent.NavigateToEditItem(LOCAL_CODE.id), + awaitItem(), + ) + } + } + + @Test + fun `should trigger delete prompt when DropdownMenuClick DELETE is triggered`() = runTest { + val viewModel = createViewModel() + + val expectedState = DEFAULT_STATE.copy( + dialog = ItemListingState.DialogState.DeleteConfirmationPrompt( + message = R.string.do_you_really_want_to_permanently_delete_cipher.asText(), + itemId = LOCAL_CODE.id, + ), + ) + + viewModel.trySendAction( + ItemListingAction.DropdownMenuClick( + menuAction = VaultDropdownMenuAction.DELETE, + item = LOCAL_CODE, + ), + ) + + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + + private fun createViewModel() = ItemListingViewModel( + authenticatorRepository = authenticatorRepository, + authenticatorBridgeManager = authenticatorBridgeManager, + clipboardManager = clipboardManager, + encodingManager = encodingManager, + settingsRepository = settingsRepository, + ) +} + +private val APP_THEME: AppTheme = mockk() +private const val AUTHENTICATOR_ALERT_SECONDS = 7 +private val DEFAULT_STATE = ItemListingState( + appTheme = APP_THEME, + alertThresholdSeconds = AUTHENTICATOR_ALERT_SECONDS, + viewState = ItemListingState.ViewState.Loading, + dialog = null, +) + +private val LOCAL_CODE = VerificationCodeDisplayItem( + id = "1", + title = "issuer", + subtitle = null, + timeLeftSeconds = 10, + periodSeconds = 30, + alertThresholdSeconds = 7, + authCode = "123456", + favorite = false, + allowLongPressActions = true, + showMoveToBitwarden = true, +) + +private val LOCAL_VERIFICATION_ITEMS = listOf( + VerificationCodeItem( + code = "123456", + periodSeconds = 60, + timeLeftSeconds = 430, + issueTime = 35L, + id = "1", + issuer = "issuer", + label = "accountName", + source = AuthenticatorItem.Source.Local("1", isFavorite = false), + ), + VerificationCodeItem( + code = "123456", + periodSeconds = 60, + timeLeftSeconds = 430, + issueTime = 35L, + id = "1", + issuer = "issuer", + label = "accountName", + source = AuthenticatorItem.Source.Local("1", isFavorite = true), + ), +) + +private val SHARED_VERIFICATION_ITEMS = listOf( + VerificationCodeItem( + code = "987654", + periodSeconds = 60, + timeLeftSeconds = 430, + issueTime = 35L, + id = "1", + issuer = "sharedIssue", + label = "sharedAccountName", + source = AuthenticatorItem.Source.Shared( + userId = "1", + nameOfUser = null, + email = "email", + environmentLabel = "environmentLabel", + ), + ), +) + +private val LOCAL_DISPLAY_ITEMS = LOCAL_VERIFICATION_ITEMS.map { + it.toDisplayItem( + AUTHENTICATOR_ALERT_SECONDS, + SharedVerificationCodesState.AppNotInstalled, + ) +} + +private val SHARED_DISPLAY_ITEMS = SharedVerificationCodesState.Success(SHARED_VERIFICATION_ITEMS) + .toSharedCodesDisplayState(AUTHENTICATOR_ALERT_SECONDS) + +private val LOCAL_FAVORITE_ITEMS = LOCAL_DISPLAY_ITEMS.filter { it.favorite } +private val LOCAL_NON_FAVORITE_ITEMS = LOCAL_DISPLAY_ITEMS.filterNot { it.favorite } diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/SharedVerificationCodesStateTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/SharedVerificationCodesStateTest.kt new file mode 100644 index 0000000000..ea0f4f2276 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/SharedVerificationCodesStateTest.kt @@ -0,0 +1,112 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util + +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.SharedCodesDisplayState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem +import com.bitwarden.authenticator.ui.platform.base.util.asText +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SharedVerificationCodesStateTest { + + @Test + fun `toSharedCodesDisplayState on empty list should return empty list`() { + val state = SharedVerificationCodesState.Success(emptyList()) + val expected = SharedCodesDisplayState.Codes(emptyList()) + assertEquals( + expected, + state.toSharedCodesDisplayState(ALERT_THRESHOLD), + ) + } + + @Test + fun `toSharedCodesDisplayState should return list of sections grouped by account`() { + val state = SharedVerificationCodesState.Success( + items = listOf( + VerificationCodeItem( + code = "123456", + periodSeconds = 30, + timeLeftSeconds = 10, + issueTime = 100L, + id = "123", + issuer = null, + label = null, + source = AuthenticatorItem.Source.Shared( + userId = "user1", + nameOfUser = "John Appleseed", + email = "John@test.com", + environmentLabel = "bitwarden.com", + ), + ), + VerificationCodeItem( + code = "987654", + periodSeconds = 30, + timeLeftSeconds = 10, + issueTime = 100L, + id = "987", + issuer = "issuer", + label = "accountName", + source = AuthenticatorItem.Source.Shared( + userId = "user1", + nameOfUser = "Jane Doe", + email = "Jane@test.com", + environmentLabel = "bitwarden.eu", + ), + ), + ), + ) + val expected = SharedCodesDisplayState.Codes( + sections = listOf( + SharedCodesDisplayState.SharedCodesAccountSection( + label = R.string.shared_accounts_header.asText( + "John@test.com", + "bitwarden.com", + ), + codes = listOf( + VerificationCodeDisplayItem( + authCode = "123456", + periodSeconds = 30, + timeLeftSeconds = 10, + id = "123", + title = "--", + subtitle = null, + favorite = false, + allowLongPressActions = false, + alertThresholdSeconds = ALERT_THRESHOLD, + showMoveToBitwarden = false, + ), + ), + ), + SharedCodesDisplayState.SharedCodesAccountSection( + label = R.string.shared_accounts_header.asText( + "Jane@test.com", + "bitwarden.eu", + ), + codes = listOf( + VerificationCodeDisplayItem( + authCode = "987654", + periodSeconds = 30, + timeLeftSeconds = 10, + id = "987", + title = "issuer", + subtitle = "accountName", + favorite = false, + allowLongPressActions = false, + alertThresholdSeconds = ALERT_THRESHOLD, + showMoveToBitwarden = false, + ), + ), + ), + ), + ) + assertEquals( + expected, + state.toSharedCodesDisplayState(ALERT_THRESHOLD), + ) + } +} + +private const val ALERT_THRESHOLD = 7 diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensionsTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensionsTest.kt new file mode 100644 index 0000000000..0341cdf629 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensionsTest.kt @@ -0,0 +1,168 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util + +import com.bitwarden.authenticator.data.authenticator.manager.util.createMockVerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class VerificationCodeItemExtensionsTest { + + @Test + fun `toDisplayItem should map Local items correctly`() { + val alertThresholdSeconds = 7 + val favoriteItem = createMockVerificationCodeItem(number = 1, favorite = true) + val nonFavoriteItem = createMockVerificationCodeItem(number = 2) + + val expectedFavoriteItem = VerificationCodeDisplayItem( + id = favoriteItem.id, + title = favoriteItem.issuer!!, + subtitle = favoriteItem.label, + timeLeftSeconds = favoriteItem.timeLeftSeconds, + periodSeconds = favoriteItem.periodSeconds, + alertThresholdSeconds = alertThresholdSeconds, + authCode = favoriteItem.code, + favorite = (favoriteItem.source as AuthenticatorItem.Source.Local).isFavorite, + allowLongPressActions = true, + showMoveToBitwarden = false, + ) + + val expectedNonFavoriteItem = VerificationCodeDisplayItem( + id = nonFavoriteItem.id, + title = nonFavoriteItem.issuer!!, + subtitle = nonFavoriteItem.label, + timeLeftSeconds = nonFavoriteItem.timeLeftSeconds, + periodSeconds = nonFavoriteItem.periodSeconds, + alertThresholdSeconds = alertThresholdSeconds, + authCode = nonFavoriteItem.code, + favorite = (nonFavoriteItem.source as AuthenticatorItem.Source.Local).isFavorite, + allowLongPressActions = true, + showMoveToBitwarden = false, + ) + + assertEquals( + expectedFavoriteItem, + favoriteItem.toDisplayItem( + alertThresholdSeconds = alertThresholdSeconds, + sharedVerificationCodesState = SharedVerificationCodesState.Error, + ), + ) + assertEquals( + expectedNonFavoriteItem, + nonFavoriteItem.toDisplayItem( + alertThresholdSeconds = alertThresholdSeconds, + sharedVerificationCodesState = SharedVerificationCodesState.Error, + ), + ) + } + + @Test + @Suppress("MaxLineLength") + fun `toDisplayItem should only showMoveToBitwarden when SharedVerificationCodesState is Success`() { + val alertThresholdSeconds = 7 + val item = createMockVerificationCodeItem(1) + val expectedDontShowMoveToBitwardenItem = + VerificationCodeDisplayItem( + id = item.id, + title = item.issuer!!, + subtitle = item.label, + timeLeftSeconds = item.timeLeftSeconds, + periodSeconds = item.periodSeconds, + alertThresholdSeconds = alertThresholdSeconds, + authCode = item.code, + favorite = false, + allowLongPressActions = true, + showMoveToBitwarden = false, + ) + + assertEquals( + expectedDontShowMoveToBitwardenItem, + item.toDisplayItem( + alertThresholdSeconds = alertThresholdSeconds, + sharedVerificationCodesState = SharedVerificationCodesState.AppNotInstalled, + ), + ) + assertEquals( + expectedDontShowMoveToBitwardenItem, + item.toDisplayItem( + alertThresholdSeconds = alertThresholdSeconds, + sharedVerificationCodesState = SharedVerificationCodesState.Error, + ), + ) + assertEquals( + expectedDontShowMoveToBitwardenItem, + item.toDisplayItem( + alertThresholdSeconds = alertThresholdSeconds, + sharedVerificationCodesState = SharedVerificationCodesState.FeatureNotEnabled, + ), + ) + assertEquals( + expectedDontShowMoveToBitwardenItem, + item.toDisplayItem( + alertThresholdSeconds = alertThresholdSeconds, + sharedVerificationCodesState = SharedVerificationCodesState.Loading, + ), + ) + assertEquals( + expectedDontShowMoveToBitwardenItem, + item.toDisplayItem( + alertThresholdSeconds = alertThresholdSeconds, + sharedVerificationCodesState = SharedVerificationCodesState.OsVersionNotSupported, + ), + ) + assertEquals( + expectedDontShowMoveToBitwardenItem, + item.toDisplayItem( + alertThresholdSeconds = alertThresholdSeconds, + sharedVerificationCodesState = SharedVerificationCodesState.SyncNotEnabled, + ), + ) + + val expectedShouldShowMoveToBitwardenItem = expectedDontShowMoveToBitwardenItem.copy( + showMoveToBitwarden = true, + ) + assertEquals( + expectedShouldShowMoveToBitwardenItem, + item.toDisplayItem( + alertThresholdSeconds = alertThresholdSeconds, + sharedVerificationCodesState = SharedVerificationCodesState.Success(emptyList()), + ), + ) + } + + @Test + fun `toDisplayItem should map Shared items correctly`() { + val alertThresholdSeconds = 7 + val favoriteItem = createMockVerificationCodeItem(number = 1, favorite = true) + .copy( + source = AuthenticatorItem.Source.Shared( + userId = "1", + nameOfUser = "John Doe", + email = "test@bitwarden.com", + environmentLabel = "bitwarden.com", + ), + ) + + val expectedFavoriteItem = VerificationCodeDisplayItem( + id = favoriteItem.id, + title = favoriteItem.issuer!!, + subtitle = favoriteItem.label, + timeLeftSeconds = favoriteItem.timeLeftSeconds, + periodSeconds = favoriteItem.periodSeconds, + alertThresholdSeconds = alertThresholdSeconds, + authCode = favoriteItem.code, + favorite = false, + allowLongPressActions = false, + showMoveToBitwarden = false, + ) + + assertEquals( + expectedFavoriteItem, + favoriteItem.toDisplayItem( + alertThresholdSeconds = alertThresholdSeconds, + sharedVerificationCodesState = SharedVerificationCodesState.Error, + ), + ) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreenTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreenTest.kt new file mode 100644 index 0000000000..9e8c0281ff --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreenTest.kt @@ -0,0 +1,111 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.ui.platform.base.BaseComposeTest +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import com.bitwarden.authenticator.ui.platform.manager.permissions.FakePermissionManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test + +class ManualCodeEntryScreenTest : BaseComposeTest() { + + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + + private val viewModel: ManualCodeEntryViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + every { trySendAction(any()) } just runs + } + + private val intentManager: IntentManager = mockk() + private val permissionsManager = FakePermissionManager() + + @Before + fun setup() { + composeTestRule.setContent { + ManualCodeEntryScreen( + onNavigateBack = {}, + onNavigateToQrCodeScreen = {}, + viewModel = viewModel, + intentManager = intentManager, + permissionsManager = permissionsManager, + ) + } + } + + @Test + fun `on Add code click should emit SaveLocallyClick`() { + composeTestRule + .onNodeWithText("Add code") + .performClick() + + // Make sure save to bitwaren isn't showing: + composeTestRule + .onNodeWithText("Add code to Bitwarden") + .assertDoesNotExist() + + verify { viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) } + } + + @Test + fun `on Add code to Bitwarden click should emit SaveToBitwardenClick`() { + mutableStateFlow.update { + it.copy(buttonState = ManualCodeEntryState.ButtonState.SaveToBitwardenPrimary) + } + composeTestRule + .onNodeWithText("Save to Bitwarden") + .performClick() + + // Make sure locally only save isn't showing: + composeTestRule + .onNodeWithText("Add code") + .assertDoesNotExist() + + // Make sure locally option is showing: + composeTestRule + .onNodeWithText("Save here") + .assertIsDisplayed() + + verify { viewModel.trySendAction(ManualCodeEntryAction.SaveToBitwardenClick) } + } + + @Test + fun `on Add code locally click should emit SaveLocallyClick`() { + mutableStateFlow.update { + it.copy(buttonState = ManualCodeEntryState.ButtonState.SaveLocallyPrimary) + } + composeTestRule + .onNodeWithText("Save here") + .performClick() + + // Make sure locally only save isn't showing: + composeTestRule + .onNodeWithText("Add code") + .assertDoesNotExist() + + // Make sure save to bitwarden option is showing: + composeTestRule + .onNodeWithText("Save to Bitwarden") + .assertIsDisplayed() + + verify { viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) } + } +} + +private val DEFAULT_STATE = ManualCodeEntryState( + code = "", + issuer = "", + dialog = null, + buttonState = ManualCodeEntryState.ButtonState.LocalOnly, +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModelTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModelTest.kt new file mode 100644 index 0000000000..56304d23b2 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModelTest.kt @@ -0,0 +1,446 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.UUID + +class ManualCodeEntryViewModelTest : BaseViewModelTest() { + + private val mockAuthenticatorRepository = mockk { + every { sharedCodesStateFlow } returns + MutableStateFlow(SharedVerificationCodesState.SyncNotEnabled) + } + private val mockSettingRepository = mockk { + every { defaultSaveOption } returns DefaultSaveOption.NONE + } + private val mockAuthenticatorBridgeManager = mockk() + + @BeforeEach + fun setUp() { + mockkStatic(UUID::class) + every { UUID.randomUUID().toString() } returns "mockUUID" + } + + @AfterEach + fun tearDown() { + unmockkStatic(UUID::class) + } + + @Test + fun `initial state should be correct when saved state is not null`() { + val initialState = ManualCodeEntryState( + code = "ABCD", + issuer = "mockIssuer", + dialog = null, + buttonState = ManualCodeEntryState.ButtonState.LocalOnly, + ) + val viewModel = createViewModel(initialState = initialState) + assertEquals(initialState, viewModel.stateFlow.value) + } + + @Test + fun `initial state should be correct when saved state is null`() { + val viewModel = createViewModel(initialState = null) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `initial button state should be SaveToBitwardenPrimary when sync is enabled and default save option is BITWARDEN_APP`() { + every { + mockAuthenticatorRepository.sharedCodesStateFlow + } returns MutableStateFlow(SharedVerificationCodesState.Success(emptyList())) + every { mockSettingRepository.defaultSaveOption } returns DefaultSaveOption.BITWARDEN_APP + + val viewModel = createViewModel(initialState = null) + + val expectedState = DEFAULT_STATE.copy( + buttonState = ManualCodeEntryState.ButtonState.SaveToBitwardenPrimary, + ) + verify { mockSettingRepository.defaultSaveOption } + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `initial button state should be SaveLocallyPrimary when sync is enabled and default save option is LOCAL`() { + every { + mockAuthenticatorRepository.sharedCodesStateFlow + } returns MutableStateFlow(SharedVerificationCodesState.Success(emptyList())) + every { mockSettingRepository.defaultSaveOption } returns DefaultSaveOption.LOCAL + + val viewModel = createViewModel(initialState = null) + + val expectedState = DEFAULT_STATE.copy( + buttonState = ManualCodeEntryState.ButtonState.SaveLocallyPrimary, + ) + verify { mockSettingRepository.defaultSaveOption } + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `initial button state should be SaveLocallyPrimary when sync is enabled and default save option is NONE`() { + every { + mockAuthenticatorRepository.sharedCodesStateFlow + } returns MutableStateFlow(SharedVerificationCodesState.Success(emptyList())) + every { mockSettingRepository.defaultSaveOption } returns DefaultSaveOption.NONE + + val viewModel = createViewModel(initialState = null) + + val expectedState = DEFAULT_STATE.copy( + buttonState = ManualCodeEntryState.ButtonState.SaveToBitwardenPrimary, + ) + verify { mockSettingRepository.defaultSaveOption } + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `CloseClick should navigate back`() = runTest { + val viewModel = createViewModel() + viewModel.trySendAction(ManualCodeEntryAction.CloseClick) + viewModel.eventFlow.test { + assertEquals( + ManualCodeEntryEvent.NavigateBack, + awaitItem(), + ) + } + } + + @Test + fun `CodeTextChange should update state`() { + val viewModel = createViewModel() + viewModel.trySendAction(ManualCodeEntryAction.CodeTextChange("newCode")) + assertEquals( + DEFAULT_STATE.copy(code = "newCode"), + viewModel.stateFlow.value, + ) + } + + @Test + fun `IssuerTextChange should update state`() { + val viewModel = createViewModel() + viewModel.trySendAction(ManualCodeEntryAction.IssuerTextChange("newIssuer")) + assertEquals( + DEFAULT_STATE.copy(issuer = "newIssuer"), + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `SaveLocallyClick should createItem, show toast, and navigate back on success when code is valid`() = + runTest { + coEvery { + mockAuthenticatorRepository.createItem( + item = AuthenticatorItemEntity( + id = "mockUUID", + key = "ABCD", + issuer = "mockIssuer", + accountName = "", + userId = null, + favorite = false, + type = AuthenticatorItemType.TOTP, + ), + ) + } returns CreateItemResult.Success + + val viewModel = createViewModel( + initialState = DEFAULT_STATE + .copy(code = "ABCD", issuer = "mockIssuer"), + ) + + viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) + + coVerify { + mockAuthenticatorRepository.createItem( + item = AuthenticatorItemEntity( + id = "mockUUID", + key = "ABCD", + issuer = "mockIssuer", + accountName = "", + userId = null, + favorite = false, + type = AuthenticatorItemType.TOTP, + ), + ) + } + viewModel.eventFlow.test { + assertEquals( + ManualCodeEntryEvent.ShowToast(R.string.verification_code_added.asText()), + awaitItem(), + ) + assertEquals( + ManualCodeEntryEvent.NavigateBack, + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `SaveToBitwardenClick should launch add to Bitwarden flow and navigate back on success when code is valid`() = + runTest { + val expectedUri = "otpauth://totp/?secret=ABCD&issuer=mockIssuer" + every { + mockAuthenticatorBridgeManager.startAddTotpLoginItemFlow(expectedUri) + } returns true + val viewModel = createViewModel( + initialState = DEFAULT_STATE + .copy(code = "ABCD", issuer = "mockIssuer"), + ) + viewModel.trySendAction(ManualCodeEntryAction.SaveToBitwardenClick) + verify { + mockAuthenticatorBridgeManager.startAddTotpLoginItemFlow(expectedUri) + } + viewModel.eventFlow.test { + assertEquals( + ManualCodeEntryEvent.NavigateBack, + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `SaveToBitwardenClick should show error when code is valid but startAddTotpLoginItemFlow fails`() = + runTest { + val expectedUri = "otpauth://totp/?secret=ABCD&issuer=mockIssuer" + every { + mockAuthenticatorBridgeManager.startAddTotpLoginItemFlow(expectedUri) + } returns false + val viewModel = createViewModel( + initialState = DEFAULT_STATE + .copy(code = "ABCD", issuer = "mockIssuer"), + ) + viewModel.trySendAction(ManualCodeEntryAction.SaveToBitwardenClick) + verify { mockAuthenticatorBridgeManager.startAddTotpLoginItemFlow(expectedUri) } + val expectedState = DEFAULT_STATE.copy( + code = "ABCD", + issuer = "mockIssuer", + dialog = ManualCodeEntryState.DialogState.Error( + title = R.string.something_went_wrong.asText(), + message = R.string.please_try_again.asText(), + ), + ) + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `SaveLocallyClick should replace whitespace from code`() = runTest { + coEvery { + mockAuthenticatorRepository.createItem( + item = AuthenticatorItemEntity( + id = "mockUUID", + key = "ABCD", + issuer = "mockIssuer", + accountName = "", + userId = null, + favorite = false, + type = AuthenticatorItemType.TOTP, + ), + ) + } returns CreateItemResult.Success + + val viewModel = createViewModel( + initialState = DEFAULT_STATE.copy( + code = "A B C D", + issuer = "mockIssuer", + ), + ) + + viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) + + coVerify { + mockAuthenticatorRepository.createItem( + item = AuthenticatorItemEntity( + id = "mockUUID", + key = "ABCD", + issuer = "mockIssuer", + accountName = "", + userId = null, + favorite = false, + type = AuthenticatorItemType.TOTP, + ), + ) + } + } + + @Test + fun `SaveLocallyClick should show error dialog when code is empty`() = runTest { + val viewModel = createViewModel( + initialState = DEFAULT_STATE.copy( + code = " ", + ), + ) + + viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) + + assertEquals( + ManualCodeEntryState.DialogState.Error( + message = R.string.key_is_required.asText(), + ), + viewModel.stateFlow.value.dialog, + ) + } + + @Test + fun `SaveLocallyClick should show error dialog when code is not base32`() { + val viewModel = createViewModel( + initialState = DEFAULT_STATE.copy( + code = "ABCD12345", + ), + ) + + viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) + + assertEquals( + ManualCodeEntryState.DialogState.Error( + message = R.string.key_is_invalid.asText(), + ), + viewModel.stateFlow.value.dialog, + ) + } + + @Test + fun `SaveLocallyClick should show error dialog when issuer is empty`() { + val viewModel = createViewModel( + initialState = DEFAULT_STATE.copy( + code = "ABCD", + issuer = "", + ), + ) + + viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) + + assertEquals( + ManualCodeEntryState.DialogState.Error( + message = R.string.name_is_required.asText(), + ), + viewModel.stateFlow.value.dialog, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `SaveLocallyClick should set AuthenticatorItemType to STEAM when code starts with steam protocol`() { + coEvery { + mockAuthenticatorRepository.createItem( + item = AuthenticatorItemEntity( + id = "mockUUID", + key = "ABCD", + issuer = "mockIssuer", + accountName = "", + userId = null, + favorite = false, + type = AuthenticatorItemType.STEAM, + ), + ) + } returns CreateItemResult.Success + + val viewModel = createViewModel( + initialState = DEFAULT_STATE.copy( + code = "steam://ABCD", + issuer = "mockIssuer", + ), + ) + + viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) + + coVerify { + mockAuthenticatorRepository.createItem( + item = AuthenticatorItemEntity( + id = "mockUUID", + key = "ABCD", + issuer = "mockIssuer", + accountName = "", + userId = null, + favorite = false, + type = AuthenticatorItemType.STEAM, + ), + ) + } + } + + @Test + fun `ScanQrCodeTextClick should navigate to QR code screen`() = runTest { + val viewModel = createViewModel() + viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick) + viewModel.eventFlow.test { + assertEquals( + ManualCodeEntryEvent.NavigateToQrCodeScreen, + awaitItem(), + ) + } + } + + @Test + fun `SettingsClick should navigate to app settings`() = runTest { + val viewModel = createViewModel() + viewModel.trySendAction(ManualCodeEntryAction.SettingsClick) + viewModel.eventFlow.test { + assertEquals( + ManualCodeEntryEvent.NavigateToAppSettings, + awaitItem(), + ) + } + } + + @Test + fun `DismissDialog should clear dialog state`() { + val viewModel = createViewModel( + initialState = DEFAULT_STATE.copy( + dialog = ManualCodeEntryState.DialogState.Error( + message = R.string.key_is_required.asText(), + ), + ), + ) + viewModel.trySendAction(ManualCodeEntryAction.DismissDialog) + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + } + + private fun createViewModel( + initialState: ManualCodeEntryState? = DEFAULT_STATE, + ): ManualCodeEntryViewModel = + ManualCodeEntryViewModel( + savedStateHandle = SavedStateHandle().apply { set("state", initialState) }, + authenticatorRepository = mockAuthenticatorRepository, + authenticatorBridgeManager = mockAuthenticatorBridgeManager, + settingsRepository = mockSettingRepository, + ) +} + +private val DEFAULT_STATE: ManualCodeEntryState = + ManualCodeEntryState( + code = "", + issuer = "", + dialog = null, + buttonState = ManualCodeEntryState.ButtonState.LocalOnly, + ) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/FakeQrCodeAnalyzer.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/FakeQrCodeAnalyzer.kt new file mode 100644 index 0000000000..6b109a87f8 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/FakeQrCodeAnalyzer.kt @@ -0,0 +1,22 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan + +import androidx.camera.core.ImageProxy +import com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.util.QrCodeAnalyzer + +/** + * A helper class that helps test scan outcomes. + */ +class FakeQrCodeAnalyzer : QrCodeAnalyzer { + + override lateinit var onQrCodeScanned: (String) -> Unit + + /** + * The result of the scan that will be sent to the ViewModel (or `null` to indicate a + * scanning error. + */ + var scanResult: String? = null + + override fun analyze(image: ImageProxy) { + scanResult?.let { onQrCodeScanned.invoke(it) } + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreenTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreenTest.kt new file mode 100644 index 0000000000..761f6c2bc1 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreenTest.kt @@ -0,0 +1,138 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan + +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertTrue + +class QrCodeScanScreenTest : BaseComposeTest() { + + private var onNavigateBackCalled = false + private var onNavigateToManualCodeEntryScreenCalled = false + + private val qrCodeAnalyzer = FakeQrCodeAnalyzer() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + + val viewModel: QrCodeScanViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + every { trySendAction(any()) } just runs + } + + @Before + fun setup() { + composeTestRule.setContent { + QrCodeScanScreen( + viewModel = viewModel, + qrCodeAnalyzer = qrCodeAnalyzer, + onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToManualCodeEntryScreen = { + onNavigateToManualCodeEntryScreenCalled = true + }, + ) + } + } + + @Test + fun `on NavigateBack event receive should call navigate back`() { + mutableEventFlow.tryEmit(QrCodeScanEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `on Save here click should send SaveLocallyClick action`() { + mutableStateFlow.update { + DEFAULT_STATE.copy( + dialog = QrCodeScanState.DialogState.ChooseSaveLocation, + ) + } + composeTestRule + .onNodeWithText("Save here") + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(QrCodeScanAction.SaveLocallyClick(false)) } + + // Click again but with "Save as default" checked: + composeTestRule + .onNodeWithText("Save option as default") + .performClick() + composeTestRule + .onNodeWithText("Save here") + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(QrCodeScanAction.SaveLocallyClick(true)) } + } + + @Test + fun `on Save to Bitwarden click should send SaveToBitwardenClick action`() { + mutableStateFlow.update { + DEFAULT_STATE.copy( + dialog = QrCodeScanState.DialogState.ChooseSaveLocation, + ) + } + composeTestRule + .onNodeWithText("Save to Bitwarden") + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(false)) } + + // Click again but with "Save as default" checked: + composeTestRule + .onNodeWithText("Save option as default") + .performClick() + composeTestRule + .onNodeWithText("Save to Bitwarden") + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(true)) } + } + + @Test + fun `dismissing error dialog should send SaveToBitwardenErrorDismiss`() { + // Make sure dialog isn't showing: + composeTestRule + .onNodeWithText("Something went wrong") + .assertDoesNotExist() + + // Display dialog and click OK + mutableStateFlow.update { + DEFAULT_STATE.copy( + dialog = QrCodeScanState.DialogState.SaveToBitwardenError, + ) + } + composeTestRule + .onNodeWithText("Something went wrong") + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + composeTestRule + .onNodeWithText("OK") + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + .performClick() + + verify { viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenErrorDismiss) } + } +} + +private val DEFAULT_STATE = QrCodeScanState( + dialog = null, +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModelTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModelTest.kt new file mode 100644 index 0000000000..dc868e0b1b --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModelTest.kt @@ -0,0 +1,274 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan + +import android.net.Uri +import app.cash.turbine.test +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class QrCodeScanViewModelTest : BaseViewModelTest() { + + private val authenticatorBridgeManager: AuthenticatorBridgeManager = mockk() + private val authenticatorRepository: AuthenticatorRepository = mockk { + every { + sharedCodesStateFlow + } returns MutableStateFlow(SharedVerificationCodesState.Success(emptyList())) + } + private val settingsRepository: SettingsRepository = mockk { + every { defaultSaveOption } returns DefaultSaveOption.NONE + } + + @BeforeEach + fun setup() { + mockkStatic(Uri::parse) + every { Uri.parse(VALID_TOTP_CODE) } returns VALID_TOTP_URI + } + + @AfterEach + fun teardown() { + unmockkStatic(Uri::parse) + } + + @Test + fun `on SaveToBitwardenClick receive without a pending QR scan should do nothing`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(false)) + } + } + + @Test + @Suppress("MaxLineLength") + fun `on SaveToBitwardenClick receive with pending QR scan but no save to default should launch save to Bitwarden flow`() = + runTest { + val viewModel = createViewModel() + every { + authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) + } returns true + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(false)) + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + verify { authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) } + } + + @Test + @Suppress("MaxLineLength") + fun `on SaveToBitwardenClick receive with pending QR scan but startAddTotpLoginItemFlow fails should show error dialog`() = + runTest { + val viewModel = createViewModel() + every { + authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) + } returns false + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(false)) + } + val expectedState = + DEFAULT_STATE.copy(dialog = QrCodeScanState.DialogState.SaveToBitwardenError) + assertEquals(expectedState, viewModel.stateFlow.value) + verify { authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) } + } + + @Test + @Suppress("MaxLineLength") + fun `on SaveToBitwardenClick receive with pending QR scan but and save to default should launch save to Bitwarden flow and update SettingsRepository`() = + runTest { + val viewModel = createViewModel() + every { + settingsRepository.defaultSaveOption = DefaultSaveOption.BITWARDEN_APP + } just runs + every { + authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) + } returns true + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(true)) + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + verify { authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) } + verify { settingsRepository.defaultSaveOption = DefaultSaveOption.BITWARDEN_APP } + } + + @Test + fun `on SaveLocallyClick receive without a pending QR scan should do nothing`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.SaveLocallyClick(false)) + } + } + + @Test + @Suppress("MaxLineLength") + fun `on SaveLocallyClick receive with pending QR scan but no save to default should emit code to AuthenticatorRepository and navigate back`() = + runTest { + val viewModel = createViewModel() + every { + authenticatorRepository.emitTotpCodeResult(VALID_TOTP_RESULT) + } just runs + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + viewModel.trySendAction(QrCodeScanAction.SaveLocallyClick(false)) + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + verify { + authenticatorRepository.emitTotpCodeResult(VALID_TOTP_RESULT) + } + } + + @Test + @Suppress("MaxLineLength") + fun `on SaveLocallyClick receive with pending QR scan but and save to default should emit result to AuthenticatorRepository and update SettingsRepository`() = + runTest { + val viewModel = createViewModel() + every { + settingsRepository.defaultSaveOption = DefaultSaveOption.LOCAL + } just runs + every { + authenticatorRepository.emitTotpCodeResult( + TotpCodeResult.TotpCodeScan(VALID_TOTP_CODE), + ) + } just runs + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + viewModel.trySendAction(QrCodeScanAction.SaveLocallyClick(true)) + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + verify { + authenticatorRepository.emitTotpCodeResult( + TotpCodeResult.TotpCodeScan(VALID_TOTP_CODE), + ) + } + verify { settingsRepository.defaultSaveOption = DefaultSaveOption.LOCAL } + } + + @Test + @Suppress("MaxLineLength") + fun `on SaveToBitwardenErrorDismiss recieve should clear dialog state`() { + val viewModel = createViewModel() + every { + authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) + } returns false + // Show error dialog: + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(false)) + val expectedState = + DEFAULT_STATE.copy(dialog = QrCodeScanState.DialogState.SaveToBitwardenError) + assertEquals(expectedState, viewModel.stateFlow.value) + verify { authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) } + + // Clear dialog: + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenErrorDismiss) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + fun `on QrCodeScanReceive when authenticator sync is not enabled should just save locally`() { + val viewModel = createViewModel() + every { + authenticatorRepository.sharedCodesStateFlow.value + } returns SharedVerificationCodesState.SyncNotEnabled + every { + authenticatorRepository.emitTotpCodeResult(VALID_TOTP_RESULT) + } just runs + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + verify { authenticatorRepository.emitTotpCodeResult(VALID_TOTP_RESULT) } + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `on QrCodeScanReceive when default save option is local should save locally and navigate back`() = + runTest { + val viewModel = createViewModel() + every { settingsRepository.defaultSaveOption } returns DefaultSaveOption.LOCAL + every { + authenticatorRepository.sharedCodesStateFlow.value + } returns SharedVerificationCodesState.Success(emptyList()) + every { + authenticatorRepository.emitTotpCodeResult(VALID_TOTP_RESULT) + } just runs + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + verify { authenticatorRepository.emitTotpCodeResult(VALID_TOTP_RESULT) } + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `on QrCodeScanReceive when default save option is bitwarden should start navigate to Bitwarden flow`() = + runTest { + val viewModel = createViewModel() + every { settingsRepository.defaultSaveOption } returns DefaultSaveOption.BITWARDEN_APP + every { + authenticatorRepository.sharedCodesStateFlow.value + } returns SharedVerificationCodesState.Success(emptyList()) + every { + authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) + } returns true + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + verify { authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) } + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `on QrCodeScanReceive when code is invalid should emit result and navigate back`() = + runTest { + val viewModel = createViewModel() + every { + authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) + } just runs + val invalidUri: Uri = mockk { + every { getQueryParameter("secret") } returns "SECRET" + every { queryParameterNames } returns setOf("digits") + every { getQueryParameter("digits") } returns "100" + } + val invalidQrCode = "otpauth://totp/secret=SECRET" + every { Uri.parse(invalidQrCode) } returns invalidUri + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidQrCode)) + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + verify { authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) } + } + + private fun createViewModel() = QrCodeScanViewModel( + authenticatorBridgeManager = authenticatorBridgeManager, + authenticatorRepository = authenticatorRepository, + settingsRepository = settingsRepository, + ) +} + +private val DEFAULT_STATE = QrCodeScanState( + dialog = null, +) +private const val VALID_TOTP_CODE = "otpauth://totp/Label?secret=SECRET&issuer=Issuer" +private val VALID_TOTP_URI: Uri = mockk { + every { getQueryParameter("secret") } returns "SECRET" + every { queryParameterNames } returns emptySet() +} +private val VALID_TOTP_RESULT = TotpCodeResult.TotpCodeScan(VALID_TOTP_CODE) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt new file mode 100644 index 0000000000..a18d508afd --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt @@ -0,0 +1,156 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.search + +import androidx.lifecycle.SavedStateHandle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.manager.util.createMockVerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.authenticator.repository.util.itemsOrEmpty +import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager +import com.bitwarden.authenticator.data.platform.repository.model.DataState +import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest +import com.bitwarden.authenticator.ui.platform.components.model.IconData +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ItemSearchViewModelTest : BaseViewModelTest() { + + private val mutableAuthCodesStateFlow = + MutableStateFlow>>(DataState.Loading) + private val mutableSharedCodesFlow = MutableStateFlow( + SharedVerificationCodesState.Success(SHARED_ITEMS), + ) + private val mockAuthenticatorRepository = mockk { + every { getLocalVerificationCodesFlow() } returns mutableAuthCodesStateFlow + every { sharedCodesStateFlow } returns mutableSharedCodesFlow + } + private val mockClipboardManager = mockk() + + @BeforeEach + fun setup() { + mockkStatic(SharedVerificationCodesState::itemsOrEmpty) + } + + @AfterEach + fun teardown() { + unmockkStatic(SharedVerificationCodesState::itemsOrEmpty) + } + + @Test + fun `initial state is correct`() { + val viewModel = createViewModel() + assertEquals( + ItemSearchState.ViewState.Empty(message = null), + viewModel.stateFlow.value.viewState, + ) + } + + @Test + fun `state contains both shared items and local items when available`() { + val viewModel = createViewModel() + + mutableAuthCodesStateFlow.value = DataState.Loaded(LOCAL_ITEMS) + + viewModel.trySendAction( + ItemSearchAction.SearchTermChange("I"), + ) + + assertEquals( + ItemSearchState.ViewState.Content( + displayItems = SHARED_AND_LOCAL_DISPLAY_ITEMS, + ), + viewModel.stateFlow.value.viewState, + ) + } + + @Test + fun `state contains only local items when shared items are not available`() { + val viewModel = createViewModel() + every { mutableSharedCodesFlow.value.itemsOrEmpty } returns emptyList() + mutableAuthCodesStateFlow.value = DataState.Loaded(LOCAL_ITEMS) + + viewModel.trySendAction( + ItemSearchAction.SearchTermChange("I"), + ) + + assertEquals( + ItemSearchState.ViewState.Content( + displayItems = listOf(SHARED_AND_LOCAL_DISPLAY_ITEMS[1]), + ), + viewModel.stateFlow.value.viewState, + ) + } + + private fun createItemSearchState( + viewState: ItemSearchState.ViewState = ItemSearchState.ViewState.Empty(message = null), + ) = ItemSearchState( + searchTerm = "", + viewState = viewState, + ) + + private fun createViewModel( + initialState: ItemSearchState = createItemSearchState(), + ): ItemSearchViewModel { + return ItemSearchViewModel( + SavedStateHandle().apply { + set("state", initialState) + }, + mockClipboardManager, + mockAuthenticatorRepository, + ) + } +} + +private val LOCAL_ITEMS = listOf( + createMockVerificationCodeItem(number = 1), +) + +private val SHARED_ITEMS = listOf( + VerificationCodeItem( + "123456", + periodSeconds = 60, + timeLeftSeconds = 30, + issueTime = 1, + issuer = "Issuer", + label = "accountName", + id = "123", + source = AuthenticatorItem.Source.Shared( + userId = "1", + nameOfUser = "John Test", + email = "test@test.com", + environmentLabel = "1234", + ), + ), +) + +private val SHARED_AND_LOCAL_DISPLAY_ITEMS = listOf( + ItemSearchState.DisplayItem( + id = SHARED_ITEMS[0].id, + authCode = SHARED_ITEMS[0].code, + title = SHARED_ITEMS[0].issuer!!, + periodSeconds = SHARED_ITEMS[0].periodSeconds, + timeLeftSeconds = SHARED_ITEMS[0].timeLeftSeconds, + alertThresholdSeconds = 7, + startIcon = IconData.Local(iconRes = R.drawable.ic_login_item), + subtitle = SHARED_ITEMS[0].label, + ), + ItemSearchState.DisplayItem( + id = LOCAL_ITEMS[0].id, + authCode = LOCAL_ITEMS[0].code, + title = LOCAL_ITEMS[0].issuer!!, + periodSeconds = LOCAL_ITEMS[0].periodSeconds, + timeLeftSeconds = LOCAL_ITEMS[0].timeLeftSeconds, + alertThresholdSeconds = 7, + startIcon = IconData.Local(iconRes = R.drawable.ic_login_item), + subtitle = LOCAL_ITEMS[0].label, + ), +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/tutorial/TutorialScreenTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/tutorial/TutorialScreenTest.kt new file mode 100644 index 0000000000..53232f8705 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/tutorial/TutorialScreenTest.kt @@ -0,0 +1,132 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.tutorial + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.ui.platform.base.BaseComposeTest +import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialAction +import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialEvent +import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialScreen +import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialState +import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialViewModel +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertTrue + +class TutorialScreenTest : BaseComposeTest() { + private var onTutorialFinishedCalled = false + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + private val viewModel = mockk(relaxed = true) { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + } + + @Before + fun setUp() { + composeTestRule.setContent { + TutorialScreen( + viewModel = viewModel, + onTutorialFinished = { onTutorialFinishedCalled = true }, + ) + } + } + + @Test + fun `pages should display and update according to state`() { + composeTestRule + .onNodeWithText("Secure your accounts with Bitwarden Authenticator") + .assertExists() + .assertIsDisplayed() + + mutableEventFlow.tryEmit(TutorialEvent.UpdatePager(index = 1)) + composeTestRule + .onNodeWithText("Secure your accounts with Bitwarden Authenticator") + .assertDoesNotExist() + + composeTestRule + .onNodeWithText("Use your device camera to scan codes") + .assertExists() + .assertIsDisplayed() + + mutableStateFlow.update { + it.copy(pages = listOf(TutorialState.TutorialSlide.UniqueCodesSlide)) + } + composeTestRule + .onNodeWithText("Sign in using unique codes") + .assertExists() + .assertIsDisplayed() + } + + @Test + fun `Primary action button should say Continue when not at the end of the slides`() { + composeTestRule + .onNodeWithText("Continue") + .assertExists() + .assertIsDisplayed() + } + + @Test + fun `Primary action button should say Get started when at the end of the slides`() { + mutableStateFlow.update { + it.copy(pages = listOf(TutorialState.TutorialSlide.UniqueCodesSlide)) + } + composeTestRule + .onNodeWithText("Get Started") + .assertExists() + .assertIsDisplayed() + } + + @Test + fun `NavigateToAuthenticator event should call onTutorialFinished`() { + mutableEventFlow.tryEmit(TutorialEvent.NavigateToAuthenticator) + assertTrue(onTutorialFinishedCalled) + } + + @Test + fun `continue button click should send ContinueClick action`() { + composeTestRule + .onNodeWithText("Continue") + .performClick() + verify { + viewModel.trySendAction(TutorialAction.ContinueClick(mutableStateFlow.value.index)) + } + } + + @Test + fun `get started button click should send ContinueClick action`() { + mutableStateFlow.update { + it.copy(pages = listOf(TutorialState.TutorialSlide.UniqueCodesSlide)) + } + composeTestRule + .onNodeWithText("Get Started") + .performClick() + verify { + viewModel.trySendAction(TutorialAction.ContinueClick(mutableStateFlow.value.index)) + } + } + + @Test + fun `skip button click should send SkipClick action`() { + composeTestRule + .onNodeWithText("Skip") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(TutorialAction.SkipClick) } + } +} + +private val DEFAULT_STATE = TutorialState( + index = 0, + pages = listOf( + TutorialState.TutorialSlide.IntroSlide, + TutorialState.TutorialSlide.QrScannerSlide, + ), +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/tutorial/TutorialViewModelTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/tutorial/TutorialViewModelTest.kt new file mode 100644 index 0000000000..5aa922e605 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/tutorial/TutorialViewModelTest.kt @@ -0,0 +1,119 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.tutorial + +import app.cash.turbine.test +import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest +import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialAction +import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialEvent +import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialState +import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialViewModel +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class TutorialViewModelTest : BaseViewModelTest() { + private lateinit var viewModel: TutorialViewModel + + @BeforeEach + fun setUp() { + viewModel = TutorialViewModel() + } + + @Test + fun `initial state should be correct`() = runTest { + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + } + } + + @Test + fun `PagerSwipe should update state`() = runTest { + val newIndex = 2 + viewModel.trySendAction(TutorialAction.PagerSwipe(index = newIndex)) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy(index = newIndex), + awaitItem(), + ) + } + } + + @Test + fun `DotClick should update state and emit UpdatePager`() = runTest { + val newIndex = 2 + + viewModel.trySendAction(TutorialAction.DotClick(index = newIndex)) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy(index = newIndex), + awaitItem(), + ) + } + viewModel.eventFlow.test { + assertEquals( + TutorialEvent.UpdatePager(index = newIndex), + awaitItem(), + ) + } + } + + @Test + fun `ContinueClick should emit NavigateToAuthenticator when at the end of pages`() = runTest { + // Step 1: Verify state updates for index 0 -> 1 + viewModel.trySendAction(TutorialAction.ContinueClick(0)) + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy(index = 1), + awaitItem(), + ) + } + + // Step 2: Verify state updates for index 1 -> 2 + viewModel.trySendAction(TutorialAction.ContinueClick(1)) + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy(index = 2), + awaitItem(), + ) + } + // Step 3: Clean up any residual events before asserting event emission + viewModel.eventFlow.test { + cancelAndConsumeRemainingEvents() // Clear all old events + } + + // Step 4: Verify event emission when reaching the end of the pages + viewModel.trySendAction(TutorialAction.ContinueClick(2)) + viewModel.eventFlow.test { + assertEquals( + TutorialEvent.NavigateToAuthenticator, + awaitItem(), + ) + } + } + + @Test + fun `SkipClick should emit NavigateToAuthenticator`() = runTest { + viewModel.trySendAction(TutorialAction.SkipClick) + + viewModel.eventFlow.test { + assertEquals( + TutorialEvent.NavigateToAuthenticator, + awaitItem(), + ) + } + } +} + +private val DEFAULT_STATE = TutorialState( + index = 0, + pages = listOf( + TutorialState.TutorialSlide.IntroSlide, + TutorialState.TutorialSlide.QrScannerSlide, + TutorialState.TutorialSlide.UniqueCodesSlide, + ), +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseComposeTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseComposeTest.kt new file mode 100644 index 0000000000..583388d75b --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseComposeTest.kt @@ -0,0 +1,59 @@ +package com.bitwarden.authenticator.ui.platform.base + +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.createComposeRule +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme +import org.junit.Rule + +/** + * A base class that can be used for performing Compose-layer testing using Robolectric, Compose + * Testing, and JUnit 4. + */ +abstract class BaseComposeTest : BaseRobolectricTest() { + + @get:Rule + val composeTestRule = createComposeRule() + + /** + * instance of [OnBackPressedDispatcher] made available if testing using + * + * [setContentWithBackDispatcher] or [runTestWithTheme] + */ + var backDispatcher: OnBackPressedDispatcher? = null + private set + + /** + * Helper for testing a basic Composable function that only requires a Composable environment + * with the [BitwardenTheme]. + */ + protected fun runTestWithTheme( + theme: AppTheme, + test: @Composable () -> Unit, + ) { + composeTestRule.setContent { + AuthenticatorTheme( + theme = theme, + ) { + backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + test() + } + } + } + + /** + * Helper for testing a basic Composable function that provides access to a + * [OnBackPressedDispatcher]. + * + * Use if the [Composable] function being tested uses a [BackHandler] + */ + protected fun setContentWithBackDispatcher(test: @Composable () -> Unit) { + composeTestRule.setContent { + backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + test() + } + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseRobolectricTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseRobolectricTest.kt new file mode 100644 index 0000000000..745ee9cd8b --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseRobolectricTest.kt @@ -0,0 +1,21 @@ +package com.bitwarden.authenticator.ui.platform.base + +import dagger.hilt.android.testing.HiltTestApplication +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog + +/** + * A base class that can be used for performing tests that use Robolectric and JUnit 4. + */ +@Config( + application = HiltTestApplication::class, + sdk = [Config.NEWEST_SDK], +) +@RunWith(RobolectricTestRunner::class) +abstract class BaseRobolectricTest { + init { + ShadowLog.stream = System.out + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseViewModelTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseViewModelTest.kt new file mode 100644 index 0000000000..fe67f72f89 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseViewModelTest.kt @@ -0,0 +1,28 @@ +package com.bitwarden.authenticator.ui.platform.base + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.TurbineContext +import app.cash.turbine.turbineScope +import kotlinx.coroutines.CoroutineScope +import org.junit.jupiter.api.extension.RegisterExtension + +abstract class BaseViewModelTest { + @Suppress("unused", "JUnitMalformedDeclaration") + @RegisterExtension + protected open val mainDispatcherExtension = MainDispatcherExtension() + + protected suspend fun > T.stateEventFlow( + backgroundScope: CoroutineScope, + validate: suspend TurbineContext.( + stateFlow: ReceiveTurbine, + eventFlow: ReceiveTurbine, + ) -> Unit, + ) { + turbineScope { + this.validate( + this@stateEventFlow.stateFlow.testIn(backgroundScope), + this@stateEventFlow.eventFlow.testIn(backgroundScope), + ) + } + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/base/MainDispatcherExtension.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/base/MainDispatcherExtension.kt new file mode 100644 index 0000000000..2dfb015080 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/base/MainDispatcherExtension.kt @@ -0,0 +1,45 @@ +package com.bitwarden.authenticator.ui.platform.base + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterAllCallback +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeAllCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.api.extension.RegisterExtension + +/** + * JUnit 5 Extension for automatically setting a [testDispatcher] as the "main" dispatcher. + * + * Note that this may be used as a normal class property with [RegisterExtension] or may be applied + * directly to a test class using [ExtendWith]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherExtension( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : AfterAllCallback, + AfterEachCallback, + BeforeAllCallback, + BeforeEachCallback { + override fun afterAll(context: ExtensionContext?) { + Dispatchers.resetMain() + } + + override fun afterEach(context: ExtensionContext?) { + Dispatchers.resetMain() + } + + override fun beforeAll(context: ExtensionContext?) { + Dispatchers.setMain(testDispatcher) + } + + override fun beforeEach(context: ExtensionContext?) { + Dispatchers.setMain(testDispatcher) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt new file mode 100644 index 0000000000..417b99b170 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt @@ -0,0 +1,111 @@ +package com.bitwarden.authenticator.ui.platform.feature.debugmenu + +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.printToLog +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class DebugMenuScreenTest : BaseComposeTest() { + private var onNavigateBackCalled = false + private val mutableEventFlow = bufferedMutableSharedFlow() + private val mutableStateFlow = MutableStateFlow(DebugMenuState(featureFlags = emptyMap())) + private val viewModel = mockk(relaxed = true) { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + } + + @Before + fun setup() { + composeTestRule.setContent { + DebugMenuScreen( + onNavigateBack = { onNavigateBackCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `onNavigateBack should set onNavigateBackCalled to true`() { + mutableEventFlow.tryEmit(DebugMenuEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `onNavigateBack should send action to viewModel`() { + composeTestRule + .onRoot() + .printToLog("djf") + composeTestRule + .onNodeWithContentDescription("Back") + .performClick() + + verify { viewModel.trySendAction(DebugMenuAction.NavigateBack) } + } + + @Test + fun `feature flag content should not display if the state is empty`() { + composeTestRule + .onNodeWithText("Password manager sync", ignoreCase = true) + .assertDoesNotExist() + } + + @Test + fun `feature flag content should display if the state is not empty`() { + mutableStateFlow.tryEmit( + DebugMenuState( + featureFlags = mapOf( + FlagKey.PasswordManagerSync to true, + ), + ), + ) + + composeTestRule + .onNodeWithText("Password manager sync", ignoreCase = true) + .assertExists() + } + + @Test + fun `boolean feature flag content should send action when clicked`() { + mutableStateFlow.tryEmit( + DebugMenuState( + featureFlags = mapOf( + FlagKey.PasswordManagerSync to true, + ), + ), + ) + composeTestRule + .onNodeWithText("Password manager sync", ignoreCase = true) + .performClick() + + verify { + viewModel.trySendAction( + DebugMenuAction.UpdateFeatureFlag( + FlagKey.PasswordManagerSync, + false, + ), + ) + } + } + + @Test + fun `reset feature flag values should send action when clicked`() { + composeTestRule + .onNodeWithText("Reset Values", ignoreCase = true) + .performScrollTo() + .performClick() + + verify { viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues) } + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt new file mode 100644 index 0000000000..043c5f4607 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt @@ -0,0 +1,90 @@ +package com.bitwarden.authenticator.ui.platform.feature.debugmenu + +import app.cash.turbine.test +import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository +import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class DebugMenuViewModelTest : BaseViewModelTest() { + + private val mockFeatureFlagManager = mockk(relaxed = true) { + every { getFeatureFlagFlow(any()) } returns flowOf(true) + } + + private val mockDebugMenuRepository = mockk(relaxed = true) { + coEvery { resetFeatureFlagOverrides() } just runs + every { updateFeatureFlag(any(), any()) } just runs + } + + @Test + fun `initial state should be correct`() { + val viewModel = createViewModel() + assertEquals(viewModel.stateFlow.value, DEFAULT_STATE) + } + + @Test + fun `handleUpdateFeatureFlag should update the feature flag`() { + val viewModel = createViewModel() + assertEquals(viewModel.stateFlow.value, DEFAULT_STATE) + viewModel.trySendAction( + DebugMenuAction.Internal.UpdateFeatureFlagMap(UPDATED_MAP_VALUE), + ) + assertEquals(viewModel.stateFlow.value, DebugMenuState(UPDATED_MAP_VALUE)) + } + + @Test + fun `handleResetFeatureFlagValues should reset the feature flag values`() = runTest { + val viewModel = createViewModel() + viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues) + coVerify(exactly = 1) { mockDebugMenuRepository.resetFeatureFlagOverrides() } + } + + @Test + fun `handleNavigateBack should send NavigateBack event`() = runTest { + val viewModel = createViewModel() + viewModel.trySendAction(DebugMenuAction.NavigateBack) + viewModel.eventFlow.test { + assertEquals(DebugMenuEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `handleUpdateFeatureFlag should update the feature flag via the repository`() { + val viewModel = createViewModel() + viewModel.trySendAction( + DebugMenuAction.UpdateFeatureFlag(FlagKey.PasswordManagerSync, false), + ) + verify { mockDebugMenuRepository.updateFeatureFlag(FlagKey.PasswordManagerSync, false) } + } + + private fun createViewModel(): DebugMenuViewModel = DebugMenuViewModel( + featureFlagManager = mockFeatureFlagManager, + debugMenuRepository = mockDebugMenuRepository, + ) +} + +private val DEFAULT_MAP_VALUE: Map, Any> = mapOf( + FlagKey.BitwardenAuthenticationEnabled to true, + FlagKey.PasswordManagerSync to true, +) + +private val UPDATED_MAP_VALUE: Map, Any> = mapOf( + FlagKey.BitwardenAuthenticationEnabled to false, + FlagKey.PasswordManagerSync to false, +) + +private val DEFAULT_STATE = DebugMenuState( + featureFlags = DEFAULT_MAP_VALUE, +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugLaunchManagerTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugLaunchManagerTest.kt new file mode 100644 index 0000000000..6bf7185f85 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugLaunchManagerTest.kt @@ -0,0 +1,109 @@ +package com.bitwarden.authenticator.ui.platform.feature.debugmenu.manager + +import android.view.KeyEvent +import android.view.MotionEvent +import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DebugLaunchManagerTest { + + private val mockDebugMenuRepository = mockk(relaxed = true) { + every { isDebugMenuEnabled } returns true + } + + private val mockKeyEvent = mockk(relaxed = true) { + every { action } returns KeyEvent.ACTION_DOWN + every { keyCode } returns KeyEvent.KEYCODE_GRAVE + every { isShiftPressed } returns true + } + + private val mockMotionEvent = mockk(relaxed = true) { + every { action and MotionEvent.ACTION_MASK } returns MotionEvent.ACTION_POINTER_DOWN + every { pointerCount } returns 3 + } + + private var actionHasBeenCalled = false + private val action: () -> Unit = { actionHasBeenCalled = true } + + private val debugLaunchManager = + DebugLaunchManagerImpl(debugMenuRepository = mockDebugMenuRepository) + + @Test + fun `actionOnInputEvent should return true when KeyEvent is debug trigger`() { + assertFalse(actionHasBeenCalled) + val result = debugLaunchManager.actionOnInputEvent(event = mockKeyEvent, action = action) + assertTrue(result) + assertTrue(actionHasBeenCalled) + } + + @Suppress("MaxLineLength") + @Test + fun `actionOnInputEvent should return true when TouchEvent is debug trigger done 3 times in a row`() { + assertFalse(actionHasBeenCalled) + debugLaunchManager.actionOnInputEvent(event = mockMotionEvent, action = action) + debugLaunchManager.actionOnInputEvent(event = mockMotionEvent, action = action) + val result = debugLaunchManager.actionOnInputEvent(event = mockMotionEvent, action = action) + assertTrue(result) + assertTrue(actionHasBeenCalled) + } + + @Test + fun `actionOnInputEvent should return false when debug menu is not enabled`() { + every { mockDebugMenuRepository.isDebugMenuEnabled } returns false + assertFalse(actionHasBeenCalled) + val result = debugLaunchManager.actionOnInputEvent(event = mockKeyEvent, action = action) + assertFalse(result) + assertFalse(actionHasBeenCalled) + } + + @Test + fun `actionOnInputEvent should return false when key event is not debug trigger`() { + assertFalse(actionHasBeenCalled) + val result = debugLaunchManager + .actionOnInputEvent( + event = mockKeyEvent.apply { + every { action } returns KeyEvent.ACTION_UP + }, + action = action, + ) + assertFalse(result) + assertFalse(actionHasBeenCalled) + } + + @Test + fun `actionOnInputEvent should return false when touch event is not debug trigger`() { + assertFalse(actionHasBeenCalled) + debugLaunchManager.actionOnInputEvent(event = mockMotionEvent, action = action) + debugLaunchManager.actionOnInputEvent(event = mockMotionEvent, action = action) + val result = debugLaunchManager.actionOnInputEvent( + event = mockMotionEvent.apply { + every { pointerCount } returns 100 + }, + action = action, + ) + assertFalse(result) + assertFalse(actionHasBeenCalled) + } + + @Test + fun `if touch action input takes place too slow should return false`() { + val eventTimeMillis = 100L + assertFalse(actionHasBeenCalled) + debugLaunchManager.actionOnInputEvent(event = mockMotionEvent, action = action) + debugLaunchManager.actionOnInputEvent(event = mockMotionEvent.apply { + every { eventTime } returns eventTimeMillis + }, action = action) + val result = debugLaunchManager.actionOnInputEvent( + event = mockMotionEvent.apply { + every { eventTime } returns eventTimeMillis + 501 + }, + action = action, + ) + assertFalse(result) + assertFalse(actionHasBeenCalled) + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt new file mode 100644 index 0000000000..18f9b7ec11 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt @@ -0,0 +1,193 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings + +import android.content.Intent +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.core.net.toUri +import com.bitwarden.authenticator.BuildConfig +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.ui.platform.base.BaseComposeTest +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.base.util.concat +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class SettingsScreenTest : BaseComposeTest() { + + private var onNavigateToTutorialCalled = false + private var onNaviateToExportCalled = false + private var onNavigateToImportCalled = false + + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + + val viewModel: SettingsViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + every { trySendAction(any()) } just runs + } + + private val biometricsManager: BiometricsManager = mockk { + every { isBiometricsSupported } returns true + } + private val intentManager: IntentManager = mockk() + + @Before + fun setup() { + composeTestRule.setContent { + SettingsScreen( + viewModel = viewModel, + biometricsManager = biometricsManager, + intentManager = intentManager, + onNavigateToTutorial = { onNavigateToTutorialCalled = true }, + onNavigateToExport = { onNaviateToExportCalled = true }, + onNavigateToImport = { onNavigateToImportCalled = true }, + ) + } + } + + @Test + fun `Sync with Bitwarden row should be hidden when showSyncWithBitwarden is false`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + showSyncWithBitwarden = false, + ) + composeTestRule.onNodeWithText("Sync with Bitwarden app").assertDoesNotExist() + } + + @Test + fun `Sync with Bitwarden row click should send SyncWithBitwardenClick action`() { + composeTestRule + .onNodeWithText("Sync with Bitwarden app") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(SettingsAction.DataClick.SyncWithBitwardenClick) } + } + + @Test + fun `on NavigateToBitwardenApp receive should launch bitwarden account security deep link`() { + every { intentManager.startActivity(any()) } just runs + val intentSlot = slot() + val expectedIntent = Intent( + Intent.ACTION_VIEW, + "bitwarden://settings/account_security".toUri(), + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + mutableEventFlow.tryEmit(SettingsEvent.NavigateToBitwardenApp) + verify { intentManager.startActivity(capture(intentSlot)) } + assertEquals( + expectedIntent.data, + intentSlot.captured.data, + ) + assertEquals( + expectedIntent.flags, + intentSlot.captured.flags, + ) + } + + @Test + fun `on NavigateToBitwardenPlayStoreListing receive launch Bitwarden Play Store URI`() { + every { intentManager.launchUri(any()) } just runs + mutableEventFlow.tryEmit(SettingsEvent.NavigateToBitwardenPlayStoreListing) + verify { + intentManager.launchUri( + "https://play.google.com/store/apps/details?id=com.x8bit.bitwarden".toUri(), + ) + } + } + + @Test + fun `Default Save Option row should be hidden when showDefaultSaveOptionRow is false`() { + mutableStateFlow.value = DEFAULT_STATE + composeTestRule.onNodeWithText("Default save option").assertExists() + + mutableStateFlow.update { + it.copy( + showDefaultSaveOptionRow = false, + ) + } + composeTestRule.onNodeWithText("Default save option").assertDoesNotExist() + } + + @Test + @Suppress("MaxLineLength") + fun `Default Save Option dialog should send DefaultSaveOptionUpdated when confirm is clicked`() = + runTest { + val expectedSaveOption = DefaultSaveOption.BITWARDEN_APP + mutableStateFlow.value = DEFAULT_STATE + composeTestRule + .onNodeWithText("Default save option") + .performScrollTo() + .performClick() + + // Make sure the dialog is showing: + composeTestRule + .onAllNodesWithText("Default save option") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + // Select updated option: + composeTestRule + .onNodeWithText("Save to Bitwarden") + .assertIsDisplayed() + .performClick() + + // Click confirm: + composeTestRule + .onNodeWithText("Confirm") + .assertIsDisplayed() + .performClick() + + verify { + viewModel.trySendAction( + SettingsAction.DataClick.DefaultSaveOptionUpdated(expectedSaveOption), + ) + } + + // Make sure the dialog is not showing: + composeTestRule + .onNode(isDialog()) + .assertDoesNotExist() + } +} + +private val APP_LANGUAGE = AppLanguage.ENGLISH +private val APP_THEME = AppTheme.DEFAULT +private val DEFAULT_SAVE_OPTION = DefaultSaveOption.NONE +private val DEFAULT_STATE = SettingsState( + appearance = SettingsState.Appearance( + APP_LANGUAGE, + APP_THEME, + ), + isSubmitCrashLogsEnabled = true, + isUnlockWithBiometricsEnabled = true, + showSyncWithBitwarden = true, + showDefaultSaveOptionRow = true, + defaultSaveOption = DEFAULT_SAVE_OPTION, + dialog = null, + version = R.string.version.asText() + .concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()), + copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(), +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt new file mode 100644 index 0000000000..bb2be65b01 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt @@ -0,0 +1,253 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.bitwarden.authenticator.BuildConfig +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled +import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager +import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager +import com.bitwarden.authenticator.data.platform.manager.model.FlagKey +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.base.util.concat +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +class SettingsViewModelTest : BaseViewModelTest() { + + private val authenticatorBridgeManager: AuthenticatorBridgeManager = mockk { + every { accountSyncStateFlow } returns MutableStateFlow(AccountSyncState.Loading) + } + + private val mutableSharedCodesFlow = MutableStateFlow(MOCK_SHARED_CODES_STATE) + private val authenticatorRepository: AuthenticatorRepository = mockk { + every { sharedCodesStateFlow } returns mutableSharedCodesFlow + } + private val mutableDefaultSaveOptionFlow = bufferedMutableSharedFlow() + private val settingsRepository: SettingsRepository = mockk { + every { appLanguage } returns APP_LANGUAGE + every { appTheme } returns APP_THEME + every { defaultSaveOption } returns DEFAULT_SAVE_OPTION + every { defaultSaveOptionFlow } returns mutableDefaultSaveOptionFlow + every { isUnlockWithBiometricsEnabled } returns true + every { isCrashLoggingEnabled } returns true + } + private val clipboardManager: BitwardenClipboardManager = mockk() + private val featureFlagManager: FeatureFlagManager = mockk { + every { getFeatureFlag(FlagKey.PasswordManagerSync) } returns true + } + + @BeforeEach + fun setup() { + mockkStatic(SharedVerificationCodesState::isSyncWithBitwardenEnabled) + every { MOCK_SHARED_CODES_STATE.isSyncWithBitwardenEnabled } returns false + } + + @AfterEach + fun teardown() { + unmockkStatic(SharedVerificationCodesState::isSyncWithBitwardenEnabled) + } + + @Test + @Suppress("MaxLineLength") + fun `initialState should be correct when saved state is null and password manager feature flag is off`() { + every { + featureFlagManager.getFeatureFlag(FlagKey.PasswordManagerSync) + } returns false + val viewModel = createViewModel(savedState = null) + val expectedState = DEFAULT_STATE.copy( + showSyncWithBitwarden = false, + showDefaultSaveOptionRow = false, + ) + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `initialState should be correct when saved state is null and password manager feature flag is on but OS version is too low`() { + every { + authenticatorBridgeManager.accountSyncStateFlow + } returns MutableStateFlow(AccountSyncState.OsVersionNotSupported) + val viewModel = createViewModel(savedState = null) + val expectedState = DEFAULT_STATE.copy( + showSyncWithBitwarden = false, + showDefaultSaveOptionRow = false, + ) + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `initialState should be correct when saved state is null and password manager feature flag is on and OS version is supported`() { + every { + authenticatorBridgeManager.accountSyncStateFlow + } returns MutableStateFlow(AccountSyncState.Loading) + every { + featureFlagManager.getFeatureFlag(FlagKey.PasswordManagerSync) + } returns true + val viewModel = createViewModel(savedState = null) + val expectedState = DEFAULT_STATE.copy( + showSyncWithBitwarden = true, + ) + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `on SyncWithBitwardenClick receive with AccountSyncState AppNotInstalled should emit NavigateToBitwardenPlayStoreListing`() = + runTest { + every { + authenticatorBridgeManager.accountSyncStateFlow + } returns MutableStateFlow(AccountSyncState.AppNotInstalled) + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(SettingsAction.DataClick.SyncWithBitwardenClick) + assertEquals( + SettingsEvent.NavigateToBitwardenPlayStoreListing, + awaitItem(), + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `on SyncWithBitwardenClick receive with AccountSyncState not AppNotInstalled should emit NavigateToBitwardenApp`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(SettingsAction.DataClick.SyncWithBitwardenClick) + assertEquals( + SettingsEvent.NavigateToBitwardenApp, + awaitItem(), + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `Default save option row should only show when shared codes state shows syncing as enabled`() = + runTest { + val viewModel = createViewModel() + val enabledState: SharedVerificationCodesState = mockk { + every { isSyncWithBitwardenEnabled } returns true + } + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + mutableSharedCodesFlow.update { enabledState } + assertEquals( + DEFAULT_STATE.copy( + showDefaultSaveOptionRow = true, + ), + awaitItem(), + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `on DefaultSaveOptionUpdated should update SettingsRepository`() { + val expectedOption = DefaultSaveOption.BITWARDEN_APP + every { settingsRepository.defaultSaveOption = expectedOption } just runs + val viewModel = createViewModel() + viewModel.trySendAction(SettingsAction.DataClick.DefaultSaveOptionUpdated(expectedOption)) + verify { settingsRepository.defaultSaveOption = expectedOption } + } + + @Test + @Suppress("MaxLineLength") + fun `Default save option should update when repository emits`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + + mutableDefaultSaveOptionFlow.emit(DefaultSaveOption.LOCAL) + assertEquals( + DEFAULT_STATE.copy( + defaultSaveOption = DefaultSaveOption.LOCAL, + ), + awaitItem(), + ) + + mutableDefaultSaveOptionFlow.emit(DefaultSaveOption.BITWARDEN_APP) + assertEquals( + DEFAULT_STATE.copy( + defaultSaveOption = DefaultSaveOption.BITWARDEN_APP, + ), + awaitItem(), + ) + } + } + + private fun createViewModel( + savedState: SettingsState? = DEFAULT_STATE, + ) = SettingsViewModel( + savedStateHandle = SavedStateHandle().apply { this["state"] = savedState }, + clock = CLOCK, + authenticatorBridgeManager = authenticatorBridgeManager, + authenticatorRepository = authenticatorRepository, + settingsRepository = settingsRepository, + clipboardManager = clipboardManager, + featureFlagManager = featureFlagManager, + ) +} + +private val MOCK_SHARED_CODES_STATE: SharedVerificationCodesState = mockk() +private val APP_LANGUAGE = AppLanguage.ENGLISH +private val APP_THEME = AppTheme.DEFAULT +private val CLOCK = Clock.fixed( + Instant.parse("2024-10-12T12:00:00Z"), + ZoneOffset.UTC, +) +private val DEFAULT_SAVE_OPTION = DefaultSaveOption.NONE +private val DEFAULT_STATE = SettingsState( + appearance = SettingsState.Appearance( + APP_LANGUAGE, + APP_THEME, + ), + isSubmitCrashLogsEnabled = true, + isUnlockWithBiometricsEnabled = true, + showSyncWithBitwarden = true, + showDefaultSaveOptionRow = false, + defaultSaveOption = DEFAULT_SAVE_OPTION, + dialog = null, + version = R.string.version.asText() + .concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()), + copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(), +) diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/manager/permissions/FakePermissionManager.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/manager/permissions/FakePermissionManager.kt new file mode 100644 index 0000000000..6e25a367d0 --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/manager/permissions/FakePermissionManager.kt @@ -0,0 +1,68 @@ +package com.bitwarden.authenticator.ui.platform.manager.permissions + +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.compose.runtime.Composable +import io.mockk.every +import io.mockk.mockk + +/** + * A helper class used to test permissions + */ +class FakePermissionManager : PermissionsManager { + + /** + * The value returned when we check if we have the permission. + */ + var checkPermissionResult: Boolean = false + + /** + * The value returned when the user is asked for permission. + */ + var getPermissionsResult: Boolean = false + + /** + * The value returned when the user is asked for permission. + */ + var getMultiplePermissionsResult: Map = emptyMap() + + /** + * The value for whether a rationale should be shown to the user. + */ + var shouldShowRequestRationale: Boolean = false + + /** + * Indicates that the [getLauncher] function has been called. + */ + var hasGetLauncherBeenCalled: Boolean = false + + @Composable + override fun getLauncher( + onResult: (Boolean) -> Unit, + ): ManagedActivityResultLauncher { + hasGetLauncherBeenCalled = true + return mockk { + every { launch(any()) } answers { onResult.invoke(getPermissionsResult) } + } + } + + @Composable + override fun getPermissionsLauncher( + onResult: (Map) -> Unit, + ): ManagedActivityResultLauncher, Map> { + return mockk { + every { launch(any()) } answers { onResult.invoke(getMultiplePermissionsResult) } + } + } + + override fun checkPermission(permission: String): Boolean { + return checkPermissionResult + } + + override fun checkPermissions(permissions: Array): Boolean { + return checkPermissionResult + } + + override fun shouldShouldRequestPermissionRationale(permission: String): Boolean { + return shouldShowRequestRationale + } +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/util/DefaultSaveOptionExtensionsTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/util/DefaultSaveOptionExtensionsTest.kt new file mode 100644 index 0000000000..c7e741cb5f --- /dev/null +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/util/DefaultSaveOptionExtensionsTest.kt @@ -0,0 +1,25 @@ +package com.bitwarden.authenticator.ui.platform.util + +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class DefaultSaveOptionExtensionsTest { + + @Test + fun `displayLabel should map to correct labels`() { + DefaultSaveOption.entries.forEach { + val expected = when (it) { + DefaultSaveOption.BITWARDEN_APP -> R.string.save_to_bitwarden.asText() + DefaultSaveOption.LOCAL -> R.string.save_here.asText() + DefaultSaveOption.NONE -> R.string.none.asText() + } + assertEquals( + expected, + it.displayLabel, + ) + } + } +} diff --git a/crowdin-bwa.yml b/crowdin-bwa.yml new file mode 100644 index 0000000000..599ba87b84 --- /dev/null +++ b/crowdin-bwa.yml @@ -0,0 +1,9 @@ +project_id_env: _CROWDIN_PROJECT_ID +api_token_env: CROWDIN_API_TOKEN +preserve_hierarchy: true +base_path: "authenticator/src/main" +files: + - source: "/res/values/strings.xml" + translation: "/res/values-%android_code%/%original_file_name%" + update_option: update_as_unapproved + type: android diff --git a/keystores/debug-bwa.keystore b/keystores/debug-bwa.keystore new file mode 100644 index 0000000000..e548ecd313 Binary files /dev/null and b/keystores/debug-bwa.keystore differ diff --git a/settings.gradle.kts b/settings.gradle.kts index c659d0bf7d..1acc154d2c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -50,3 +50,4 @@ buildCache { rootProject.name = "Bitwarden" include(":app") include(":authenticatorbridge") +include(":authenticator")