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 @@
+[](https://github.com/bitwarden/authenticator-android/actions/workflows/build-authenticator.yml?query=branch:main)
+[](https://gitter.im/bitwarden/Lobby)
+
+# Bitwarden Authenticator Android App
+
+
+
+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