Merge remote-tracking branch 'bwa-android/main' into bwa-monorepo

# Conflicts:
#	.checkmarx/config.yml
#	.github/CODEOWNERS
#	.github/ISSUE_TEMPLATE/bug.yml
#	.github/ISSUE_TEMPLATE/config.yml
#	.github/renovate.json
#	.github/workflows/build-authenticator.yml
#	.github/workflows/crowdin-pull-authenticator.yml
#	.github/workflows/crowdin-push-authenticator.yml
#	.github/workflows/scan-authenticator.yml
#	.github/workflows/test-authenticator.yml
#	.gitignore
#	Gemfile
#	Gemfile.lock
#	README.md
#	build.gradle.kts
#	fastlane/Fastfile
#	gradle.properties
#	gradle/libs.versions.toml
#	gradle/wrapper/gradle-wrapper.properties
#	gradlew.bat
#	settings.gradle.kts
This commit is contained in:
Álison Fernandes 2025-02-26 22:13:24 +00:00
commit dca7284230
No known key found for this signature in database
GPG Key ID: B8CE98903DFC87BC
472 changed files with 41782 additions and 23 deletions

2
.github/codecov.yml vendored Normal file
View File

@ -0,0 +1,2 @@
ignore:
- "src/test/**" # Tests

View File

@ -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 }} \

View File

@ -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 }}

View File

@ -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

View File

@ -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 }}

View File

@ -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

1
.husky/pre-commit Executable file
View File

@ -0,0 +1 @@
npx lint-staged

61
README-bwa.md Normal file
View File

@ -0,0 +1,61 @@
[![Github Workflow build on main](https://github.com/bitwarden/authenticator-android/actions/workflows/build-authenticator.yml/badge.svg?branch=main)](https://github.com/bitwarden/authenticator-android/actions/workflows/build-authenticator.yml?query=branch:main)
[![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby)
# Bitwarden Authenticator Android App
<a href="https://play.google.com/store/apps/details?id=com.bitwarden.authenticator" target="_blank"><img alt="Get it on Google Play" src="https://imgur.com/YQzmZi9.png" width="153" height="46"></a>
Bitwarden Authenticator allows you easily store and generate two-factor authentication codes on your device. The Bitwarden Authenticator Android application is written in Kotlin.
<img src="https://raw.githubusercontent.com/bitwarden/brand/master/screenshots/authenticator-android-codes.png" alt="" width="325" height="650" />
## 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.

1
authenticator/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -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<Test> {
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")
}
}

112
authenticator/proguard-rules.pro vendored Normal file
View File

@ -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.* <methods>; }
-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

View File

@ -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')"
]
}
}

View File

@ -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')"
]
}
}

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Allows unlocking your device and activating its screen so UI tests can succeed -->
<uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Allows changing locales -->
<uses-permission
android:name="android.permission.CHANGE_CONFIGURATION"
tools:ignore="ProtectedPermissions" />
<application tools:ignore="MissingApplicationIcon">
<!-- Disable Crashlytics for debug builds -->
<meta-data
android:name="firebase_crashlytics_collection_enabled"
android:value="false" />
</application>
</manifest>

View File

@ -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"
}

View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="${targetBitwardenAppId}.permission.AUTHENTICATOR_BRIDGE_SERVICE" />
<application
android:name=".AuthenticatorApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
tools:targetApi="31">
<activity
android:name="com.bitwarden.authenticator.MainActivity"
android:exported="true"
android:launchMode="@integer/launchModeAPILevel"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- This is required to support in-app language picker in Android 12 (API 32) and below -->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application>
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
<package android:name="${targetBitwardenAppId}" />
</queries>
</manifest>

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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, MainEvent, MainAction>(
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()
}

View File

@ -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?
}

View File

@ -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)
}

View File

@ -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,
)
}

View File

@ -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")
}
}

View File

@ -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()
}

View File

@ -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(),
)
}
}

View File

@ -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,
)
}

View File

@ -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<List<AuthenticatorItemEntity>>
/**
* Deletes an authenticator item from the data source with the given [itemId].
*/
suspend fun deleteItem(itemId: String)
}

View File

@ -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<List<AuthenticatorItemEntity>>()
override suspend fun saveItem(vararg authenticatorItem: AuthenticatorItemEntity) {
itemDao.insert(*authenticatorItem)
}
override fun getItems(): Flow<List<AuthenticatorItemEntity>> = merge(
forceItemsFlow,
itemDao.getAllItems(),
)
override suspend fun deleteItem(itemId: String) {
itemDao.deleteItem(itemId)
}
}

View File

@ -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 }
}

View File

@ -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 }
}

View File

@ -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<List<AuthenticatorItemEntity>>
/**
* 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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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) }
}
}

View File

@ -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"
}
}
}
}
}

View File

@ -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) }
}
}

View File

@ -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<TotpResponse>
/**
* Generate a random key for seeding biometrics.
*/
suspend fun generateBiometricsKey(): Result<String>
}

View File

@ -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<TotpResponse> = runCatching {
getClient()
.vault()
.generateTotp(
key = totp,
time = time,
)
}
override suspend fun generateBiometricsKey(): Result<String> =
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()
}

View File

@ -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)
}

View File

@ -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<ByteArray>
}

View File

@ -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<ByteArray> =
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")
}
}
}

View File

@ -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<AuthenticatorItem>,
): Flow<List<VerificationCodeItem>>
@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
}
}

View File

@ -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<AuthenticatorItem>,
): Flow<List<VerificationCodeItem>> {
if (itemList.isEmpty()) {
return flowOf(emptyList())
}
val flows = itemList.map { it.toFlowOfVerificationCodes() }
return combine(flows) { it.toList() }
}
private fun AuthenticatorItem.toFlowOfVerificationCodes(): Flow<VerificationCodeItem> {
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()
}

View File

@ -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,
)
}

View File

@ -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<ExportItem>,
) {
/**
* 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<String>?,
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?,
)
}
}

View File

@ -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()
}
}

View File

@ -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<TotpCodeResult>
/**
* 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<DataState<List<AuthenticatorItemEntity>>>
/**
* 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<DataState<AuthenticatorItemEntity?>>
/**
* State flow that represents the state of verification codes and accounts shared from the
* main Bitwarden app.
*/
val sharedCodesStateFlow: StateFlow<SharedVerificationCodesState>
/**
* 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<DataState<List<VerificationCodeItem>>>
/**
* 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<Unit>
}

View File

@ -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<List<AuthenticatorItemEntity>>>(DataState.Loading)
private val mutableTotpCodeResultFlow =
bufferedMutableSharedFlow<TotpCodeResult>()
private val firstTimeAccountSyncChannel: Channel<Unit> =
Channel(capacity = Channel.UNLIMITED)
override val totpCodeFlow: Flow<TotpCodeResult>
get() = mutableTotpCodeResultFlow.asSharedFlow()
private val authenticatorDataFlow: StateFlow<DataState<AuthenticatorData>> =
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<DataState<List<AuthenticatorItemEntity>>>
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<DataState<AuthenticatorItemEntity?>> =
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<SharedVerificationCodesState> 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<DataState<List<VerificationCodeItem>>> {
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<Unit>
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
}
}
}
}
}

View File

@ -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<AccountSyncState>
get() = MutableStateFlow(AccountSyncState.Loading)
override fun startAddTotpLoginItemFlow(totpUri: String): Boolean = false
}
}
@Provides
fun providesSymmetricKeyStorageProvider(
authDiskSource: AuthDiskSource,
): SymmetricKeyStorageProvider =
SymmetricKeyStorageProviderImpl(
authDiskSource = authDiskSource,
)
}

View File

@ -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,
)
}

View File

@ -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<AuthenticatorItemEntity>,
)

View File

@ -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()
}
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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<VerificationCodeItem>,
) : SharedVerificationCodesState()
/**
* The user needs to enable authenticator syncing from the bitwarden app.
*/
data object SyncNotEnabled : SharedVerificationCodesState()
}

View File

@ -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()
}

View File

@ -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"
}
}

View File

@ -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()
}

View File

@ -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<AuthenticatorItemEntity>.sortAlphabetically(): List<AuthenticatorItemEntity> {
return this.sortedWith(
comparator = { cipher1, cipher2 ->
SpecialCharWithPrecedenceComparator.compare(cipher1.issuer, cipher2.issuer)
},
)
}

View File

@ -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<SharedAccountData.Account>.toAuthenticatorItems(): List<AuthenticatorItem> =
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()
}
}

View File

@ -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<VerificationCodeItem>
get() = when (this) {
SharedVerificationCodesState.AppNotInstalled,
SharedVerificationCodesState.Error,
SharedVerificationCodesState.FeatureNotEnabled,
SharedVerificationCodesState.Loading,
SharedVerificationCodesState.OsVersionNotSupported,
SharedVerificationCodesState.SyncNotEnabled,
-> emptyList()
is SharedVerificationCodesState.Success -> this.items
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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

View File

@ -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,
)
}

View File

@ -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

View File

@ -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<String>?,
): Unit = sharedPreferences.edit {
putStringSet(key, value)
}
protected fun getStringSet(
key: String,
default: Set<String>?,
): Set<String>? = sharedPreferences.getStringSet(key, default)
@Suppress("UndocumentedPublicClass")
companion object {
const val BASE_KEY: String = "bwPreferencesStorage"
}
}

View File

@ -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"
}
}

View File

@ -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<ServerConfig?>
}

View File

@ -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<ServerConfig?>
get() = mutableServerConfigFlow.onSubscription { emit(serverConfig) }
private val mutableServerConfigFlow = bufferedMutableSharedFlow<ServerConfig?>(replay = 1)
}

View File

@ -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<FeatureFlagsConfiguration?>
}

View File

@ -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<FeatureFlagsConfiguration?>(replay = 1)
override val featureFlagsConfigurationFlow: Flow<FeatureFlagsConfiguration?>
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)
}
}

View File

@ -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 <T : Any> saveFeatureFlag(key: FlagKey<T>, value: T)
/**
* Get a feature flag value based on the associated [FlagKey] from disk.
*/
fun <T : Any> getFeatureFlag(key: FlagKey<T>): T?
}

View File

@ -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 <T : Any> saveFeatureFlag(key: FlagKey<T>, 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 <T : Any> getFeatureFlag(key: FlagKey<T>): 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
}
}
}

View File

@ -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<AppTheme>
/**
* The currently persisted default save option.
*/
var defaultSaveOption: DefaultSaveOption
/**
* Flow that emits changes to [defaultSaveOption]
*/
val defaultSaveOptionFlow: Flow<DefaultSaveOption>
/**
* 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<String>
/**
* Emits update that track [hasSeenWelcomeTutorial]
*/
val hasSeenWelcomeTutorialFlow: Flow<Boolean>
/**
* The current setting for if crash logging is enabled.
*/
var isCrashLoggingEnabled: Boolean?
/**
* The current setting for if crash logging is enabled.
*/
val isCrashLoggingEnabledFlow: Flow<Boolean?>
/**
* 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<Int>
/**
* 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<Boolean?>
/**
* Stores whether or not [isScreenCaptureAllowed].
*/
fun storeScreenCaptureAllowed(isScreenCaptureAllowed: Boolean?)
}

View File

@ -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<AppTheme>(replay = 1)
private val mutableScreenCaptureAllowedFlow =
bufferedMutableSharedFlow<Boolean?>()
private val mutableAlertThresholdSecondsFlow =
bufferedMutableSharedFlow<Int>()
private val mutableIsCrashLoggingEnabledFlow =
bufferedMutableSharedFlow<Boolean?>()
private val mutableDefaultSaveOptionFlow =
bufferedMutableSharedFlow<DefaultSaveOption>()
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<Boolean>()
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<AppTheme>
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<DefaultSaveOption>
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<String>
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<Boolean>
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<Boolean?>
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<Int> = 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<Boolean?> = mutableScreenCaptureAllowedFlow
.onSubscription { emit(getScreenCaptureAllowed()) }
override fun storeScreenCaptureAllowed(
isScreenCaptureAllowed: Boolean?,
) {
putBoolean(
key = SCREEN_CAPTURE_ALLOW_KEY,
value = isScreenCaptureAllowed,
)
mutableScreenCaptureAllowedFlow.tryEmit(isScreenCaptureAllowed)
}
}

View File

@ -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,
)
}

View File

@ -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<String, JsonPrimitive>,
)

View File

@ -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,
)

View File

@ -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<ConfigResponseJson>
}

View File

@ -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<T>(
private val backingCall: Call<T>,
private val successType: Type,
) : Call<Result<T>> {
override fun cancel(): Unit = backingCall.cancel()
override fun clone(): Call<Result<T>> = ResultCall(backingCall, successType)
override fun enqueue(callback: Callback<Result<T>>): Unit = backingCall.enqueue(
object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
callback.onResponse(this@ResultCall, Response.success(response.toResult()))
}
override fun onFailure(call: Call<T>, t: Throwable) {
callback.onResponse(this@ResultCall, Response.success(t.toFailure()))
}
},
)
@Suppress("TooGenericExceptionCaught")
override fun execute(): Response<Result<T>> =
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<T> = requireNotNull(execute().body())
private fun Throwable.toFailure(): Result<T> =
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<T>.toResult(): Result<T> =
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()
}
}
}

View File

@ -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<T>(
private val successType: Type,
) : CallAdapter<T, Call<Result<T>>> {
override fun responseType(): Type = successType
override fun adapt(call: Call<T>): Call<Result<T>> = ResultCall(call, successType)
}

View File

@ -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<out Annotation>,
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<Any>(successType = requestType)
} else {
null
}
}
}

View File

@ -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
}
}

View File

@ -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()

View File

@ -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
}
}

View File

@ -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(),
)
}

View File

@ -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<String, JsonPrimitive>?,
) {
/**
* 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?,
)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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<ZonedDateTime> {
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))
}
}

View File

@ -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<ConfigResponseJson>
}

View File

@ -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<ConfigResponseJson> = configApi.getConfig()
}

View File

@ -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"

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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)
}

Some files were not shown because too many files have changed in this diff Show More