diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 63ea84c143..4dd0941a80 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,4 @@ -custom: ['https://www.buymeacoffee.com/beemdevelopment'] +buy_me_a_coffee: beemdevelopment +custom: + - "https://www.blockchain.com/btc/address/bc1q26kyxqjkc6tu477pzy0whagwhs4ypv93qls22n" + - "https://nanocrawler.cc/explorer/account/nano_1aegisc559b1x4p3839egnu579jkd4htpidy14eo9e31gzqmwuafypnj4q94" diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md deleted file mode 100644 index 7f5edcbe53..0000000000 --- a/.github/ISSUE_TEMPLATE/bug.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: "Bug report" -about: "Create a report to help us fix a bug" -labels: bug ---- - - - -###### Info - -* __Version__: -* __Source__: (Google Play/GitHub/F-Droid/?) -* __Vault encrypted__: Yes (with biometric unlock)/Yes/No -* __Device__: -* __Android version and ROM__: - -###### Steps to reproduce - -A detailed list of reproduction steps. - -###### What do you expect to happen? - -... - -###### What happens instead? - -... - -###### Log - -``` -If applicable, paste the debug log here. -``` diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000000..1d2c7a9def --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,89 @@ +name: Bug Report +description: Create a report to help us fix a bug +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Please read the [bug reports section of the contribution guidelines](https://github.com/beemdevelopment/Aegis/blob/master/CONTRIBUTING.md#bug-reports) before submitting an issue. + - type: input + id: version + attributes: + label: Version + description: Which version of Aegis are you using? + placeholder: "Example: v2.1" + validations: + required: true + - type: dropdown + id: source + attributes: + label: Source + description: Where did you get Aegis from? + options: + - Google Play + - F-Droid + - GitHub + - Other + validations: + required: true + - type: dropdown + id: encryption + attributes: + label: Vault encryption + description: Do you have encryption enabled for your Aegis vault? + options: + - "Yes (with biometric unlock)" + - "Yes" + - "No" + validations: + required: true + - type: input + id: device + attributes: + label: Device + description: Which device are you using Aegis on? + placeholder: "Example: Pixel 5" + validations: + required: true + - type: input + id: android_version + attributes: + label: Android version + description: Which Android version is running on your device? + placeholder: "Example: Android 13" + validations: + required: true + - type: input + id: rom + attributes: + label: ROM + description: Are you using a custom ROM? If so, which one and which version? If you're using the stock OS that came with your device, you can leave this field empty. + placeholder: "Example: GrapheneOS" + validations: + required: false + - type: textarea + id: reproduction_steps + attributes: + label: Steps to reproduce + description: A detailed list of reproduction steps. + validations: + required: true + - type: textarea + id: expectations + attributes: + label: What do you expect to happen? + validations: + required: true + - type: textarea + id: reality + attributes: + label: What happens instead? + validations: + required: true + - type: textarea + id: log + attributes: + label: Log + description: If applicable, paste the debug log that you captured using ADB here. + validations: + required: false diff --git a/.github/workflows/build-app-workflow.yaml b/.github/workflows/build-app-workflow.yaml index a68464493e..ab511ab741 100644 --- a/.github/workflows/build-app-workflow.yaml +++ b/.github/workflows/build-app-workflow.yaml @@ -5,8 +5,59 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Validate Gradle wrapper - uses: gradle/wrapper-validation-action@e2c57acffb2c9aa5a8dc6eda2bbae0b6e495bd4c + uses: gradle/wrapper-validation-action@699bb18358f12c5b78b37bb0111d3a0e2276e0e2 + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'gradle' - name: Build the app run: ./gradlew build + - uses: actions/upload-artifact@v4 + with: + name: apk + path: app/build/outputs/apk/debug/app-debug.apk + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'gradle' + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Tests + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d + with: + api-level: 31 + arch: x86_64 + profile: pixel_3a + heap-size: 512M + ram-size: 4096M + emulator-options: -memory 4096 -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + disk-size: 8G + script: | + mkdir -p artifacts/report + adb logcat -c + adb logcat -G 16M && adb logcat -g + ./gradlew connectedCheck || touch tests_failing + adb logcat -d > artifacts/logcat.txt + cp -r app/build/reports/androidTests/connected/* artifacts/report/ + if adb shell '[ -e /sdcard/Pictures/screenshots ]'; then adb pull /sdcard/Pictures/screenshots artifacts/; fi + test ! -f tests_failing + - uses: actions/upload-artifact@v4 + if: always() + with: + name: instrumented-test-report + path: | + artifacts/* + if-no-files-found: ignore diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000000..b575535bf9 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,42 @@ +name: codeql +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: '25 16 * * 2' +jobs: + analyze: + name: analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + if: github.event_name != 'schedule' || github.repository == 'beemdevelopment/Aegis' + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Exclude paths + # The importers are excluded from analysis, because some of the apps Aegis + # can import from don't have such great crypto, which will cause false + # positive security alerts. + run: | + find app/src/main/java/com/beemdevelopment/aegis/importers ! \( -name AegisImporter.java -o -name "DatabaseImporter*" \) -type f -exec rm -f {} + + sed -i '/Importer.class/d' app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'gradle' + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: java + - name: Build + run: ./gradlew assembleDebug + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml new file mode 100644 index 0000000000..e789836238 --- /dev/null +++ b/.github/workflows/crowdin.yml @@ -0,0 +1,25 @@ +name: crowdin +on: + push: + branches: + - master +# run sequentially (per branch) +concurrency: "crowdin-upload-${{ github.ref }}" +jobs: + upload-sources: + runs-on: ubuntu-latest + if: github.repository == 'beemdevelopment/Aegis' + steps: + - uses: actions/checkout@v4 + - name: Install crowdin-cli + run: | + wget https://github.com/crowdin/crowdin-cli/releases/download/4.6.1/crowdin-cli.zip + echo "7afd70de3a747ac631a5bad7866008163ae1d50c4606b5773f0b90a5481ffde2 crowdin-cli.zip" | sha256sum -c + unzip crowdin-cli.zip -d crowdin-cli + - name: Upload to Crowdin + env: + CROWDIN_PERSONAL_TOKEN: "${{ secrets.CROWDIN_TOKEN }}" + run: | + java -jar ./crowdin-cli/4.6.1/crowdin-cli.jar upload sources \ + --no-progress \ + --branch master diff --git a/.gitignore b/.gitignore index 94626464e4..f9bb113c2d 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ captures/ # Keystore files *.jks crowdin.properties +.crowdin/config.yml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 687c917b0e..0c7733c90a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,9 @@ Looking to contribute to Aegis? That's great! There are a couple of ways to help out. This document contains some general guidelines for each type of contribution. +Please review [the FAQ](FAQ.md) before reporting a bug, asking a question or +requesting a feature. + ## Translations We use [Crowdin](https://crowdin.com/project/aegis-authenticator) to crowdsource @@ -27,10 +30,9 @@ requests. ## Bug reports We use GitHub's issue tracker to track bugs. To make bug reports easier to -follow up on for us, they must follow [the -template](.github/ISSUE_TEMPLATE/bug.md). If a bug report does not follow the -template and does not contain enough information, it will be closed. Duplicate -bug reports receive the same treatment. +follow up on for us, please fill out the form as accurately as possible. If a +bug report does not contain enough information, it will be closed. Duplicate bug +reports receive the same treatment. Please consider trying to find the root cause yourself first and include your analysis of the issue in your report. Perhaps even send us a patch that fixes diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 0000000000..6d514574bd --- /dev/null +++ b/FAQ.md @@ -0,0 +1,109 @@ +# FAQ + +## General + +### How can I contribute? + +There are lots of ways! Please refer to our [contributing +guide](https://github.com/beemdevelopment/Aegis/blob/master/CONTRIBUTING.md). + +### Why is the latest version not on F-Droid yet? + +We don't release new versions of Aegis on F-Droid ourselves. Once we've released +a new version on GitHub, F-Droid will usually kick off their automatic build +process a day later and publish the app to their repository a couple of days +afterwards. It can sometimes take up to a week for a new version to appear on +F-Droid. + +### Can you port Aegis to iOS/Windows/MacOS/Browser Extension? + +We don't have plans to port Aegis to other platforms. + +### Can you add support for Autofill? + +On Android, only one app can be active in the Autofill slot at a time, and since +this is typically occupied by the password manager, we don't see much value in +adding support for this feature in Aegis. + +### What is the difference between exporting and backing up? + +Exporting is done manually and backups are done automatically. The format of the +vault file is exactly the same for both. + +## Security + +### I can no longer use biometrics to unlock the app. What should I do? + +If you could previously unlock Aegis with biometrics, but suddenly can't do so +anymore, this is probably caused by a change made to the security settings of +your device. The app will tell you when this happened in most cases. To resolve +this, unlock the app with your password, disable biometric unlock in the +settings of Aegis and re-enable it. + +### Why does Aegis keep prompting me for my password, even though I have enabled biometric authentication? + +You're probably encountering the password reminder. Try entering your password +to unlock the vault once. After that, Aegis will prompt for biometrics by +default again until it's time for another password reminder. + +Since forgetting your password will result in loss of access to the contents of +the vault, __we do NOT recommend disabling the password reminder__. + +### Aegis uses SHA1 for most/all of my tokens. Isn't that insecure? + +The hash algorithm is imposed by the service you're setting up 2FA for (e.g. +Google, Facebook, GitHub, etc). There is nothing we can do about that. If we +were to change this on Aegis' end, the tokens would stop working. Furthermore, +when using SHA1 in an HMAC calculation, the currently known issues in SHA1 are +not of concern. + +### Why doesn't Aegis support biometric unlock for my device, even though it works with other apps? + +The reason for this is pretty technical. In short, since you're not entering +your password when using biometric unlock, Aegis needs some other way to decrypt +the vault. For this purpose, we generate and use a key in the Android Keystore, +telling it to only allow us to use that key if the user authenticates using +their biometrics first. Some devices have buggy implementations of this feature, +resulting in the error displayed to you by Aegis in an error dialog. + +If biometrics works with other apps, but not with Aegis, that means those other +apps probably perform a weaker form of biometric authentication. + +## Backups + +### How can I back up my Aegis vault to the cloud automatically? + +Aegis can only automatically back up to the cloud if the app of your cloud +provider is installed on your device and fully participates in the Android +Storage Access Framework. Aegis doesn't have access to the internet and we don't +have plans to change this, so adding support for specific cloud providers in the +app is not possible. + +Cloud providers currently known to be supported: +- Nextcloud + +Another common setup is to configure Aegis to back up to a folder on local +storage of your device and then have a separate app (like +[Syncthing](https://syncthing.net/)) sync that folder anywhere you want. + +## Encrypted Backups + +### Why do I not get prompted to enter an encryption password when exporting? + +Aegis uses the same password you have configured to encrypt your vault as the +password which is used when exporting and importing your vault; so when prompted, +you will enter that when importing your vault. + +## Importing + +### When importing from Authenticator Plus, an error is shown claiming that Accounts.txt is missing + +Make sure you supply an Authenticator Plus export file obtained through +__Settings -> Backup & Restore -> Export as Text and HTML__. The ``.db`` format +is not supported. + +If it still doesn't work, please report the issue to us. As a temporary +workaround, you can try extracting the ZIP archive on a computer, recreating it +without a password and then importing that into Aegis. Another option is +extracting the ZIP archive on a computer and importing the resulting +Accounts.txt file into Aegis with the "Plain text" import option. diff --git a/README.md b/README.md index a7b0c784bf..195bd4edf4 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,20 @@ App icon -# Aegis Authenticator [![CI](https://github.com/beemdevelopment/Aegis/workflows/build/badge.svg)](https://github.com/beemdevelopment/Aegis/actions?query=workflow%3Abuild) [![Crowdin](https://badges.crowdin.net/aegis-authenticator/localized.svg)](https://crowdin.com/project/aegis-authenticator) [![Donate](https://img.shields.io/badge/donate-buy%20us%20a%20beer-%23FF813F)](https://www.buymeacoffee.com/beemdevelopment) [![Matrix](https://img.shields.io/badge/chat-Matrix-blue)](https://matrix.to/#/#aegis:matrix.org) +# Aegis Authenticator -__Aegis Authenticator__ is a free, secure and open source 2FA app for Android. +
+ +[![Build](https://github.com/beemdevelopment/Aegis/actions/workflows/build-app-workflow.yaml/badge.svg)](https://github.com/beemdevelopment/Aegis/actions/workflows/build-app-workflow.yaml?query=branch%3Amaster) [![Crowdin](https://badges.crowdin.net/aegis-authenticator/localized.svg)](https://crowdin.com/project/aegis-authenticator) [![Donate](https://img.shields.io/badge/donate-buy%20us%20a%20beer-%23FF813F)](https://www.buymeacoffee.com/beemdevelopment) [![Matrix](https://img.shields.io/matrix/aegis:matrix.org?color=blue)](https://matrix.to/#/#aegis:matrix.org) + +**Aegis Authenticator** is a free, secure and open source 2FA app for Android. It aims to provide a secure authenticator for your online services, while also including some features missing in existing authenticator apps, like proper encryption and backups. Aegis supports HOTP and TOTP, making it compatible with thousands of services. +For a list of frequently asked questions, please check out [the FAQ](FAQ.md). + The security design of the app and the vault format is described in detail in [this document](docs/vault.md). @@ -47,19 +53,20 @@ The security design of the app and the vault format is described in detail in [Screenshot 1](metadata/en-US/images/phoneScreenshots/screenshot1.png?raw=true) [Screenshot 2](/metadata/en-US/images/phoneScreenshots/screenshot2.png?raw=true) +src="metadata/en-US/images/phoneScreenshots/screenshot2.png?raw=true">](metadata/en-US/images/phoneScreenshots/screenshot2.png?raw=true) [Screenshot 3](/metadata/en-US/images/phoneScreenshots/screenshot3.png?raw=true) - +src="metadata/en-US/images/phoneScreenshots/screenshot3.png?raw=true">](metadata/en-US/images/phoneScreenshots/screenshot3.png?raw=true) [Screenshot 4](metadata/en-US/images/phoneScreenshots/screenshot4.png?raw=true) + [Screenshot 5](metadata/en-US/images/phoneScreenshots/screenshot5.png?raw=true) [Screenshot 6](metadata/en-US/images/phoneScreenshots/screenshot6.png?raw=true) - [Screenshot 7](metadata/en-US/images/phoneScreenshots/screenshot7.png?raw=true) +[Screenshot 8](metadata/en-US/images/phoneScreenshots/screenshot8.png?raw=true) ## Downloads @@ -71,7 +78,7 @@ src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" [Get it on F-Droid](https://f-droid.org/app/com.beemdevelopment.aegis) - + ### Verification APK releases on Google Play and GitHub are signed using the same key. They can @@ -109,21 +116,40 @@ Aegis supports icon packs to make it easier to assign icons to the entries in your vault. There are no official icon packs, but the community maintains a number of third-party icon packs you may want to check out. To learn how to create your own Aegis-compatible icon pack, see [the -documenation](docs/iconpacks.md). +documentation](docs/iconpacks.md). - [aegis-icons](https://github.com/aegis-icons/aegis-icons) Unofficial monochrome-styled 2FA icons. [aegis-icons preview](https://github.com/aegis-icons/aegis-icons) + src="metadata/en-US/images/iconPacks/aegis-icons.png">](https://github.com/aegis-icons/aegis-icons) + +- [delta-aegis-icons](https://github.com/Delta-Icons/aegis-icons) + + Delta version of the unofficial monochrome-styled 2FA icon pack aegis-icons. -- [aegis-simple-icons](https://github.com/alexbakker/aegis-simple-icons) + [delta-icons preview](https://github.com/Delta-Icons/aegis-icons) + +- [aegis-simple-icons](https://github.com/alexbakker/aegis-simple-icons) \* This project periodically generates an icon pack for Aegis based on [Simple - Icons](https://simpleicons.org/). The icons are automatically generated, so - not all of them are as high quality as the ones you'll find in - [aegis-icons](https://github.com/aegis-icons/aegis-icons). + Icons](https://simpleicons.org/). + + [aegis-simple-icons preview](https://github.com/alexbakker/aegis-simple-icons) + +- [aegis-simple-icons-outlined](https://github.com/michaelschattgen/aegis-simple-icons-outlined) \* + + This is a variant on the aegis-simple-icons pack where the icons contain no solid background and just the outlines are being used. + + [aegis-simple-icons-outlined preview](https://github.com/michaelschattgen/aegis-simple-icons-outlined) + +\* The icons are automatically generated, so +not all of them are as high quality as the ones you'll find in +[aegis-icons](https://github.com/aegis-icons/aegis-icons). ## Contributing @@ -138,3 +164,9 @@ Swing by our Matrix room to interact with other contributors: This project is licensed under the GNU General Public License v3.0. See the [LICENSE](LICENSE) file for details. + +A couple of libraries vendored in Aegis' repository are licensed under a +different license: + +- [TextDrawable](app/src/main/java/com/amulyakhare/textdrawable) +- [TrustedIntents](app/src/main/java/info/guardianproject/trustedintents) diff --git a/app/build.gradle b/app/build.gradle index b1b33ecdbd..36b035e91f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,5 +1,7 @@ apply plugin: 'com.android.application' apply plugin: 'com.google.protobuf' +apply plugin: 'dagger.hilt.android.plugin' +apply plugin: 'com.mikepenz.aboutlibraries.plugin' def getCmdOutput = { cmd -> def stdout = new ByteArrayOutputStream() @@ -18,28 +20,31 @@ def fileProviderAuthority = "${packageName}.fileprovider" def fileProviderAuthorityDebug = "${packageName}.debug.fileprovider" android { - compileSdkVersion 31 + compileSdk 35 + + namespace packageName defaultConfig { applicationId "${packageName}" - minSdkVersion 21 - targetSdkVersion 31 - versionCode 50 - versionName "2.0.2" + minSdkVersion 23 + targetSdkVersion 35 + versionCode 81 + versionName "3.4.2" multiDexEnabled true buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" buildConfigField "String", "GIT_BRANCH", "\"${getGitBranch()}\"" + buildConfigField "java.util.concurrent.atomic.AtomicBoolean", "TEST", "new java.util.concurrent.atomic.AtomicBoolean(false)" + + javaCompileOptions { + annotationProcessorOptions { + arguments = ["room.schemaLocation": "$projectDir/schemas"] + } + } testInstrumentationRunner "com.beemdevelopment.aegis.AegisTestRunner" testInstrumentationRunnerArguments clearPackageData: 'true' } - lintOptions { - abortOnError true - disable "MissingTranslation" - checkDependencies true - } - testOptions { execution 'ANDROIDX_TEST_ORCHESTRATOR' @@ -72,13 +77,6 @@ android { ] buildConfigField("String", "FILE_PROVIDER_AUTHORITY", "\"${fileProviderAuthorityDebug}\"") resValue "bool", "pref_secure_screen_default", "false" - postprocessing { - removeUnusedCode true - removeUnusedResources true - obfuscate false - optimizeCode false - proguardFiles getDefaultProguardFile('proguard-defaults.txt'), 'proguard-rules.pro' - } } release { manifestPlaceholders = [ @@ -88,26 +86,48 @@ android { ] buildConfigField("String", "FILE_PROVIDER_AUTHORITY", "\"${fileProviderAuthority}\"") resValue "bool", "pref_secure_screen_default", "true" - postprocessing { - removeUnusedCode true - removeUnusedResources true - obfuscate false - optimizeCode true - proguardFiles getDefaultProguardFile('proguard-defaults.txt'), 'proguard-rules.pro' - } + + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + // Required to make the APK reproducible + aaptOptions { + cruncherEnabled = false + } + defaultConfig { + vectorDrawables.generatedDensities = [] + } + + packagingOptions { + // R8 doesn't remove these resources, so exclude them manually. This reduces APK size by 4MB. + resources { + excludes += [ + '/org/bouncycastle/pqc/**/*.properties', + 'META-INF/versions/9/OSGI-INF/MANIFEST.MF' + ] } } compileOptions { - targetCompatibility 1.8 - sourceCompatibility 1.8 + targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility JavaVersion.VERSION_17 coreLibraryDesugaringEnabled true } + lint { + abortOnError true + checkDependencies true + } + buildFeatures { + buildConfig true + } } protobuf { protoc { - artifact = 'com.google.protobuf:protoc:3.8.0' + artifact = 'com.google.protobuf:protoc:3.25.1' } generateProtoTasks { all().each { task -> @@ -120,33 +140,48 @@ protobuf { } } +aboutLibraries { + // Tasks for aboutLibraries are not run automatically to keep the build reproducible + // To update manually: ./gradlew app:exportLibraryDefinitions -PaboutLibraries.exportPath=src/main/res/raw + prettyPrint = true + configPath = "app/config" + fetchRemoteFunding = false + registerAndroidTasks = false + exclusionPatterns = [~"javax.annotation.*"] + duplicationMode = com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE +} + dependencies { - def androidTestVersion = '1.4.0' - def cameraxVersion = '1.0.2' - def glideVersion = '4.12.0' - def guavaVersion = '30.1.1' + def cameraxVersion = '1.4.2' + def glideVersion = '4.16.0' + def guavaVersion = '33.4.8' + def hiltVersion = '2.56.2' def junitVersion = '4.13.2' - def libsuVersion = '3.1.2' + def libsuVersion = '6.0.0' + def roomVersion = '2.7.1' - annotationProcessor 'androidx.annotation:annotation:1.2.0' + annotationProcessor 'androidx.annotation:annotation:1.9.1' + annotationProcessor "androidx.room:room-compiler:$roomVersion" + annotationProcessor "com.google.dagger:hilt-compiler:$hiltVersion" annotationProcessor "com.github.bumptech.glide:compiler:${glideVersion}" implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation 'androidx.appcompat:appcompat:1.3.1' + implementation 'androidx.activity:activity:1.10.1' + implementation 'androidx.appcompat:appcompat:1.7.0' implementation "androidx.biometric:biometric:1.1.0" implementation "androidx.camera:camera-camera2:$cameraxVersion" implementation "androidx.camera:camera-lifecycle:$cameraxVersion" - implementation "androidx.camera:camera-view:1.0.0-alpha30" - implementation 'androidx.cardview:cardview:1.0.0' - implementation "androidx.core:core:1.7.0" - implementation 'androidx.constraintlayout:constraintlayout:2.1.1' - implementation 'androidx.documentfile:documentfile:1.0.1' - implementation "androidx.lifecycle:lifecycle-process:2.4.0" - implementation 'androidx.preference:preference:1.1.1' - implementation 'androidx.recyclerview:recyclerview:1.2.1' - implementation "androidx.viewpager2:viewpager2:1.0.0" - implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1' + implementation "androidx.camera:camera-view:$cameraxVersion" + implementation 'androidx.core:core:1.16.0' + implementation 'androidx.constraintlayout:constraintlayout:2.2.1' + implementation 'androidx.documentfile:documentfile:1.1.0' + implementation 'androidx.lifecycle:lifecycle-process:2.9.0' + implementation "androidx.preference:preference:1.2.1" + implementation 'androidx.recyclerview:recyclerview:1.4.0' + implementation "androidx.room:room-runtime:$roomVersion" + implementation 'androidx.viewpager2:viewpager2:1.1.0' implementation 'com.caverock:androidsvg-aar:1.4' + implementation "com.google.dagger:hilt-android:$hiltVersion" implementation 'com.github.avito-tech:krop:0.52' implementation "com.github.bumptech.glide:annotations:${glideVersion}" implementation "com.github.bumptech.glide:glide:${glideVersion}" @@ -156,34 +191,35 @@ dependencies { implementation "com.github.topjohnwu.libsu:core:${libsuVersion}" implementation "com.github.topjohnwu.libsu:io:${libsuVersion}" implementation "com.google.guava:guava:${guavaVersion}-android" - implementation 'com.google.android.material:material:1.4.0' - implementation 'com.google.protobuf:protobuf-javalite:3.17.3' - implementation 'com.google.zxing:core:3.4.1' - implementation "com.mikepenz:iconics-core:3.2.5" - implementation 'com.mikepenz:material-design-iconic-typeface:2.2.0.5@aar' - implementation 'com.nulab-inc:zxcvbn:1.5.2' - implementation 'de.hdodenhof:circleimageview:3.1.0' - implementation 'de.psdev.licensesdialog:licensesdialog:2.2.0' - // NOTE: this is kept at an old version on purpose (something in newer versions breaks the Authenticator Plus importer) - implementation 'net.lingala.zip4j:zip4j:2.6.0' - implementation 'info.guardianproject.trustedintents:trustedintents:0.2' - implementation 'org.bouncycastle:bcprov-jdk15on:1.69' - - androidTestImplementation "androidx.test:core:${androidTestVersion}" - androidTestImplementation "androidx.test:runner:${androidTestVersion}" - androidTestImplementation "androidx.test:rules:${androidTestVersion}" - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0' + implementation 'com.google.android.material:material:1.12.0' + implementation 'com.google.protobuf:protobuf-javalite:4.31.0' + implementation 'com.google.zxing:core:3.5.3' + implementation('com.mikepenz:aboutlibraries:11.2.3') { + exclude group: 'com.mikepenz', module: 'aboutlibraries-core' + } + implementation 'com.mikepenz:aboutlibraries-core-android:11.2.3' + implementation 'com.nulab-inc:zxcvbn:1.9.0' + implementation 'net.lingala.zip4j:zip4j:2.11.5' + implementation 'org.bouncycastle:bcprov-jdk18on:1.80' + implementation 'org.simpleflatmapper:sfm-csv:8.2.3' + + androidTestAnnotationProcessor "com.google.dagger:hilt-android-compiler:$hiltVersion" + androidTestImplementation "com.google.dagger:hilt-android-testing:$hiltVersion" + androidTestImplementation 'androidx.test:core:1.6.1' + androidTestImplementation 'androidx.test:runner:1.6.2' + androidTestImplementation 'androidx.test:rules:1.6.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.6.1' + androidTestImplementation 'androidx.test.espresso:espresso-intents:3.6.1' androidTestImplementation "junit:junit:${junitVersion}" - androidTestUtil 'androidx.test:orchestrator:1.4.0' + androidTestUtil 'androidx.test:orchestrator:1.5.1' - testImplementation "androidx.test:core:${androidTestVersion}" + testImplementation 'androidx.test:core:1.6.1' testImplementation "com.google.guava:guava:${guavaVersion}-jre" testImplementation "junit:junit:${junitVersion}" - testImplementation "org.json:json:20210307" - testImplementation 'org.robolectric:robolectric:4.6.1' + testImplementation 'org.json:json:20250517' + testImplementation 'org.robolectric:robolectric:4.14.1' - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' } diff --git a/app/config/libraries/krop.json b/app/config/libraries/krop.json new file mode 100644 index 0000000000..72731ef032 --- /dev/null +++ b/app/config/libraries/krop.json @@ -0,0 +1,6 @@ +{ + "uniqueId": "com.github.avito-tech:krop", + "licenses": [ + "MIT" + ] +} \ No newline at end of file diff --git a/app/config/libraries/libsu.json b/app/config/libraries/libsu.json new file mode 100644 index 0000000000..3ec91d89b9 --- /dev/null +++ b/app/config/libraries/libsu.json @@ -0,0 +1,6 @@ +{ + "uniqueId": "com.github.topjohnwu.libsu:.*::regex", + "licenses": [ + "Apache-2.0" + ] +} \ No newline at end of file diff --git a/app/config/libraries/textdrawable.json b/app/config/libraries/textdrawable.json new file mode 100644 index 0000000000..528a635468 --- /dev/null +++ b/app/config/libraries/textdrawable.json @@ -0,0 +1,15 @@ +{ + "uniqueId": "com.amulyakhare:com.amulyakhare.textdrawable", + "funding": [ + + ], + "developers": [ + + ], + "artifactVersion": "1.0.1", + "description": "This light-weight library provides images with letter/text like the Gmail app. It extends the Drawable class thus can be used with existing/custom/network ImageView classes. Also included is a fluent interface for creating drawables and a customizable ColorGenerator.", + "name": "textdrawable", + "licenses": [ + "MIT" + ] +} \ No newline at end of file diff --git a/app/config/libraries/trustedintents.json b/app/config/libraries/trustedintents.json new file mode 100644 index 0000000000..5ba89066c7 --- /dev/null +++ b/app/config/libraries/trustedintents.json @@ -0,0 +1,23 @@ +{ + "uniqueId": "info.guardianproject.trustedintents:trustedintents", + "funding": [ + + ], + "developers": [ + { + "name": "Guardian Project" + } + ], + "artifactVersion": "0.2", + "description": "TrustedIntents is a library for flexible trusted interactions between Android apps. It is modeled after Android's `signature` protection level for permissions. The key difference is that the framework allows the trusted signature to be set, rather than requiring to match the current app's signature.", + "scm": { + "connection": "scm:https://github.com/guardianproject/TrustedIntents.git", + "url": "scm:https://github.com/guardianproject/TrustedIntents", + "developerConnection": "scm:git@github.com:guardianproject/TrustedIntents.git" + }, + "name": "TrustedIntents", + "website": "https://guardianproject.info/code/trustedintents", + "licenses": [ + "3ca920d1875f7ad7ab04a2a331958577" + ] +} \ No newline at end of file diff --git a/app/config/licenses/3ca920d1875f7ad7ab04a2a331958577.json b/app/config/licenses/3ca920d1875f7ad7ab04a2a331958577.json new file mode 100644 index 0000000000..2f0d7c2ade --- /dev/null +++ b/app/config/licenses/3ca920d1875f7ad7ab04a2a331958577.json @@ -0,0 +1,5 @@ +{ + "hash": "3ca920d1875f7ad7ab04a2a331958577", + "url": "https://github.com/guardianproject/TrustedIntents/blob/master/LICENSE.txt", + "name": "LGPLv2.1" +} \ No newline at end of file diff --git a/app/lint.xml b/app/lint.xml index c7c85f78ae..f9f30d6e63 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -1,6 +1,15 @@ + + + + + + + + + diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 163e5b453a..f0789326ff 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,25 +1,10 @@ -# Add project specific ProGuard rules here. -# By default, the flags in this file are appended to flags specified -# in /home/alex/Android/Sdk/tools/proguard/proguard-android.txt -# You can edit the include path and order by changing the proguardFiles -# directive in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# Add any project specific keep options here: - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} +-keepattributes LineNumberTable,SourceFile +-renamesourcefileattribute SourceFile +-dontobfuscate -keepclasseswithmembers public class androidx.recyclerview.widget.RecyclerView { *; } - --keep class com.beemdevelopment.aegis.ui.fragments.* +-keep class com.beemdevelopment.aegis.ui.fragments.preferences.* -keep class com.beemdevelopment.aegis.importers.** { *; } - -keep class * extends com.google.protobuf.GeneratedMessageLite { *; } --keep class !org.bouncycastle.jce.provider.X509LDAPCertStoreSpi { *; } + +-dontwarn javax.naming.** diff --git a/app/schemas/com.beemdevelopment.aegis.database.AppDatabase/1.json b/app/schemas/com.beemdevelopment.aegis.database.AppDatabase/1.json new file mode 100644 index 0000000000..811e430c33 --- /dev/null +++ b/app/schemas/com.beemdevelopment.aegis.database.AppDatabase/1.json @@ -0,0 +1,52 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "392278bdb797d013cb2ada67a3b1cc60", + "entities": [ + { + "tableName": "audit_logs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `event_type` TEXT NOT NULL, `reference` TEXT, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "_eventType", + "columnName": "event_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "_reference", + "columnName": "reference", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "_timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "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, '392278bdb797d013cb2ada67a3b1cc60')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTest.java index ea9a077ae5..a7322c3c73 100644 --- a/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTest.java +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTest.java @@ -2,52 +2,121 @@ import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; import androidx.test.espresso.UiController; import androidx.test.espresso.ViewAction; +import androidx.test.espresso.matcher.BoundedMatcher; import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.GrantPermissionRule; import com.beemdevelopment.aegis.crypto.CryptoUtils; import com.beemdevelopment.aegis.crypto.SCryptParameters; import com.beemdevelopment.aegis.otp.OtpInfo; -import com.beemdevelopment.aegis.vault.Vault; +import com.beemdevelopment.aegis.ui.views.EntryHolder; import com.beemdevelopment.aegis.vault.VaultEntry; import com.beemdevelopment.aegis.vault.VaultFileCredentials; import com.beemdevelopment.aegis.vault.VaultManager; -import com.beemdevelopment.aegis.vault.VaultManagerException; +import com.beemdevelopment.aegis.vault.VaultRepository; +import com.beemdevelopment.aegis.vault.VaultRepositoryException; import com.beemdevelopment.aegis.vault.slots.PasswordSlot; import com.beemdevelopment.aegis.vault.slots.SlotException; +import com.beemdevelopment.aegis.vectors.VaultEntries; +import org.hamcrest.Description; import org.hamcrest.Matcher; +import org.junit.Before; +import org.junit.Rule; import java.lang.reflect.InvocationTargetException; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; +import javax.inject.Inject; + +import dagger.hilt.android.testing.HiltAndroidRule; public abstract class AegisTest { public static final String VAULT_PASSWORD = "test"; + public static final String VAULT_PASSWORD_CHANGED = "test2"; + public static final String VAULT_BACKUP_PASSWORD = "something"; + public static final String VAULT_BACKUP_PASSWORD_CHANGED = "something2"; + + @Rule + public HiltAndroidRule hiltRule = new HiltAndroidRule(this); + + @Rule + public final GrantPermissionRule permRule = getGrantPermissionRule(); + + @Inject + protected VaultManager _vaultManager; + + @Inject + protected Preferences _prefs; - protected AegisApplication getApp() { - return (AegisApplication) InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext(); + @Before + public void init() { + hiltRule.inject(); } - protected VaultManager getVault() { - return getApp().getVaultManager(); + private static GrantPermissionRule getGrantPermissionRule() { + List perms = new ArrayList<>(); + // NOTE: Disabled for now. See issue: #1047 + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + perms.add(Manifest.permission.POST_NOTIFICATIONS); + }*/ + return GrantPermissionRule.grant(perms.toArray(new String[0])); } - protected VaultManager initVault() { + protected AegisApplicationBase getApp() { + return (AegisApplicationBase) InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext(); + } + + protected VaultRepository initEncryptedVault() { VaultFileCredentials creds = generateCredentials(); - VaultManager vault = getApp().initVaultManager(new Vault(), creds); + return initVault(creds, VaultEntries.get()); + } + + protected VaultRepository initEmptyEncryptedVault() { + VaultFileCredentials creds = generateCredentials(); + return initVault(creds, null); + } + + protected VaultRepository initPlainVault() { + return initVault(null, VaultEntries.get()); + } + + protected VaultRepository initEmptyPlainVault() { + return initVault(null, null); + } + + private VaultRepository initVault(@Nullable VaultFileCredentials creds, @Nullable List entries) { + VaultRepository vault; try { - vault.save(false); - } catch (VaultManagerException e) { + vault = _vaultManager.initNew(creds); + } catch (VaultRepositoryException e) { throw new RuntimeException(e); } - getApp().getPreferences().setIntroDone(true); + if (entries != null) { + for (VaultEntry entry : entries) { + _vaultManager.getVault().addEntry(entry); + } + } + + try { + _vaultManager.save(); + } catch (VaultRepositoryException e) { + throw new RuntimeException(e); + } + + _prefs.setIntroDone(true); return vault; } @@ -78,7 +147,11 @@ protected VaultFileCredentials generateCredentials() { } protected static VaultEntry generateEntry(Class type, String name, String issuer) { - byte[] secret = CryptoUtils.generateRandomBytes(20); + return generateEntry(type, name, issuer, 20); + } + + protected static VaultEntry generateEntry(Class type, String name, String issuer, int secretLength) { + byte[] secret = CryptoUtils.generateRandomBytes(secretLength); OtpInfo info; try { @@ -110,4 +183,21 @@ public void perform(UiController uiController, View view) { } }; } + + @NonNull + protected static Matcher withOtpType(Class otpClass) { + return new BoundedMatcher(EntryHolder.class) { + @Override + public boolean matchesSafely(EntryHolder holder) { + return holder != null + && holder.getEntry() != null + && holder.getEntry().getInfo().getClass().equals(otpClass); + } + + @Override + public void describeTo(Description description) { + description.appendText(String.format("with otp type '%s'", otpClass.getSimpleName())); + } + }; + } } diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestApplication.java b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestApplication.java new file mode 100644 index 0000000000..62f5b7f21e --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestApplication.java @@ -0,0 +1,7 @@ +package com.beemdevelopment.aegis; + +import dagger.hilt.android.testing.CustomTestApplication; + +@CustomTestApplication(AegisApplicationBase.class) +public interface AegisTestApplication { +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestRunner.java b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestRunner.java index da76f50176..970f9ff042 100644 --- a/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestRunner.java +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestRunner.java @@ -1,15 +1,26 @@ package com.beemdevelopment.aegis; import android.app.Application; +import android.app.Instrumentation; import android.content.Context; -import android.preference.PreferenceManager; +import androidx.preference.PreferenceManager; import androidx.test.core.app.ApplicationProvider; import androidx.test.runner.AndroidJUnitRunner; import com.beemdevelopment.aegis.util.IOUtils; public class AegisTestRunner extends AndroidJUnitRunner { + static { + BuildConfig.TEST.set(true); + } + + @Override + public Application newApplication(ClassLoader cl, String name, Context context) + throws ClassNotFoundException, IllegalAccessException, InstantiationException { + return Instrumentation.newApplication(AegisTestApplication_Application.class, context); + } + @Override public void callApplicationOnCreate(Application app) { Context context = app.getApplicationContext(); diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/BackupExportTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/BackupExportTest.java new file mode 100644 index 0000000000..e015139500 --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/BackupExportTest.java @@ -0,0 +1,431 @@ +package com.beemdevelopment.aegis; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.pressBack; +import static androidx.test.espresso.action.ViewActions.typeText; +import static androidx.test.espresso.intent.Intents.intending; +import static androidx.test.espresso.intent.matcher.IntentMatchers.isInternal; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import android.app.Activity; +import android.app.Instrumentation; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.Nullable; +import androidx.test.espresso.contrib.RecyclerViewActions; +import androidx.test.espresso.intent.Intents; +import androidx.test.espresso.matcher.RootMatchers; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import com.beemdevelopment.aegis.crypto.CryptoUtils; +import com.beemdevelopment.aegis.crypto.MasterKey; +import com.beemdevelopment.aegis.encoding.Hex; +import com.beemdevelopment.aegis.importers.DatabaseImporter; +import com.beemdevelopment.aegis.importers.DatabaseImporterException; +import com.beemdevelopment.aegis.importers.GoogleAuthUriImporter; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.rules.ScreenshotTestRule; +import com.beemdevelopment.aegis.ui.PreferencesActivity; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultBackupManager; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultFile; +import com.beemdevelopment.aegis.vault.VaultFileCredentials; +import com.beemdevelopment.aegis.vault.VaultFileException; +import com.beemdevelopment.aegis.vault.VaultRepository; +import com.beemdevelopment.aegis.vault.VaultRepositoryException; +import com.beemdevelopment.aegis.vault.slots.PasswordSlot; +import com.beemdevelopment.aegis.vault.slots.SlotException; +import com.beemdevelopment.aegis.vault.slots.SlotIntegrityException; +import com.beemdevelopment.aegis.vault.slots.SlotList; +import com.beemdevelopment.aegis.vectors.VaultEntries; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; +import org.junit.runner.RunWith; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.List; +import java.util.Locale; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; + +import dagger.hilt.android.testing.HiltAndroidTest; + +@RunWith(AndroidJUnit4.class) +@HiltAndroidTest +@SmallTest +public class BackupExportTest extends AegisTest { + private final ActivityScenarioRule _activityRule = new ActivityScenarioRule<>(PreferencesActivity.class); + + @Rule + public final TestRule testRule = RuleChain.outerRule(_activityRule).around(new ScreenshotTestRule()); + + @Before + public void setUp() { + Intents.init(); + } + + @After + public void tearDown() { + Intents.release(); + } + + @Test + public void testPlainVaultExportPlainJson() { + initPlainVault(); + + openExportDialog(); + onView(withId(R.id.checkbox_export_encrypt)).perform(click()); + onView(withId(android.R.id.button1)).perform(click()); + onView(withId(R.id.checkbox_accept)).perform(click()); + File file = doExport(); + + readVault(file, null); + } + + @Test + public void testPlainVaultExportPlainTxt() { + initPlainVault(); + + openExportDialog(); + onView(withId(R.id.checkbox_export_encrypt)).perform(click()); + onView(withId(R.id.dropdown_export_format)).perform(click()); + onView(withText(R.string.export_format_google_auth_uri)).inRoot(RootMatchers.isPlatformPopup()).perform(click()); + onView(withId(android.R.id.button1)).perform(click()); + onView(withId(R.id.checkbox_accept)).perform(click()); + File file = doExport(); + + readTxtExport(file); + } + + @Test + public void testPlainVaultExportEncryptedJson() { + initPlainVault(); + + openExportDialog(); + File file = doExport(); + + onView(withId(R.id.text_password)).perform(typeText(VAULT_PASSWORD), closeSoftKeyboard()); + onView(withId(R.id.text_password_confirm)).perform(typeText(VAULT_PASSWORD), closeSoftKeyboard()); + onView(withId(android.R.id.button1)).perform(click()); + + readVault(file, VAULT_PASSWORD); + } + + @Test + public void testEncryptedVaultExportPlainJson() { + initEncryptedVault(); + + openExportDialog(); + onView(withId(R.id.checkbox_export_encrypt)).perform(click()); + onView(withId(android.R.id.button1)).perform(click()); + onView(withId(R.id.checkbox_accept)).perform(click()); + File file = doExport(); + + readVault(file, null); + } + + @Test + public void testEncryptedVaultExportPlainTxt() { + initEncryptedVault(); + + openExportDialog(); + onView(withId(R.id.checkbox_export_encrypt)).perform(click()); + onView(withId(R.id.dropdown_export_format)).perform(click()); + onView(withText(R.string.export_format_google_auth_uri)).inRoot(RootMatchers.isPlatformPopup()).perform(click()); + onView(withId(android.R.id.button1)).perform(click()); + onView(withId(R.id.checkbox_accept)).perform(click()); + File file = doExport(); + + readTxtExport(file); + } + + @Test + public void testEncryptedVaultExportEncryptedJson() { + initEncryptedVault(); + + openExportDialog(); + File file = doExport(); + + readVault(file, VAULT_PASSWORD); + } + + @Test + public void testPlainVaultExportHtml() { + initPlainVault(); + + openExportDialog(); + onView(withId(R.id.checkbox_export_encrypt)).perform(click()); + onView(withId(R.id.dropdown_export_format)).perform(click()); + onView(withText(R.string.export_format_html)).inRoot(RootMatchers.isPlatformPopup()).perform(click()); + onView(withId(android.R.id.button1)).perform(click()); + onView(withId(R.id.checkbox_accept)).perform(click()); + File file = doExport(); + + checkHtmlExport(file); + } + + @Test + public void testEncryptedVaultExportHtml() { + initEncryptedVault(); + + openExportDialog(); + onView(withId(R.id.checkbox_export_encrypt)).perform(click()); + onView(withId(R.id.dropdown_export_format)).perform(click()); + onView(withText(R.string.export_format_html)).inRoot(RootMatchers.isPlatformPopup()).perform(click()); + onView(withId(android.R.id.button1)).perform(click()); + onView(withId(R.id.checkbox_accept)).perform(click()); + File file = doExport(); + + checkHtmlExport(file); + } + + @Test + public void testSeparateExportPassword() { + initEncryptedVault(); + setSeparateBackupExportPassword(); + + openExportDialog(); + File file = doExport(); + + readVault(file, VAULT_BACKUP_PASSWORD); + } + + @Test + public void testChangeBackupPassword() throws SlotIntegrityException { + initEncryptedVault(); + setSeparateBackupExportPassword(); + + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_section_security_title)), click())); + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_backup_password_change_title)), click())); + onView(withId(R.id.text_password)).perform(typeText(VAULT_BACKUP_PASSWORD_CHANGED), closeSoftKeyboard()); + onView(withId(R.id.text_password_confirm)).perform(typeText(VAULT_BACKUP_PASSWORD_CHANGED), closeSoftKeyboard()); + onView(withId(android.R.id.button1)).perform(click()); + onView(isRoot()).perform(pressBack()); + + VaultFileCredentials creds = _vaultManager.getVault().getCredentials(); + assertEquals(creds.getSlots().findRegularPasswordSlots().size(), 1); + assertEquals(creds.getSlots().findBackupPasswordSlots().size(), 1); + + for (PasswordSlot slot : creds.getSlots().findBackupPasswordSlots()) { + verifyPasswordSlotChange(creds, slot, VAULT_BACKUP_PASSWORD, VAULT_BACKUP_PASSWORD_CHANGED); + } + + for (PasswordSlot slot : creds.getSlots().findRegularPasswordSlots()) { + decryptPasswordSlot(slot, VAULT_PASSWORD); + } + + openExportDialog(); + File file = doExport(); + readVault(file, VAULT_BACKUP_PASSWORD_CHANGED); + } + + @Test + public void testChangePasswordHavingBackupPassword() throws SlotIntegrityException { + initEncryptedVault(); + setSeparateBackupExportPassword(); + + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_section_security_title)), click())); + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_set_password_title)), click())); + onView(withId(R.id.text_password)).perform(typeText(VAULT_PASSWORD_CHANGED), closeSoftKeyboard()); + onView(withId(R.id.text_password_confirm)).perform(typeText(VAULT_PASSWORD_CHANGED), closeSoftKeyboard()); + onView(withId(android.R.id.button1)).perform(click()); + onView(isRoot()).perform(pressBack()); + + VaultFileCredentials creds = _vaultManager.getVault().getCredentials(); + assertEquals(creds.getSlots().findRegularPasswordSlots().size(), 1); + assertEquals(creds.getSlots().findBackupPasswordSlots().size(), 1); + + for (PasswordSlot slot : creds.getSlots().findRegularPasswordSlots()) { + verifyPasswordSlotChange(creds, slot, VAULT_PASSWORD, VAULT_PASSWORD_CHANGED); + } + + for (PasswordSlot slot : creds.getSlots().findBackupPasswordSlots()) { + decryptPasswordSlot(slot, VAULT_BACKUP_PASSWORD); + } + + openExportDialog(); + File file = doExport(); + readVault(file, VAULT_BACKUP_PASSWORD); + } + + private void setSeparateBackupExportPassword() { + VaultFileCredentials creds = _vaultManager.getVault().getCredentials(); + assertEquals(creds.getSlots().findRegularPasswordSlots().size(), 1); + assertEquals(creds.getSlots().findBackupPasswordSlots().size(), 0); + + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_section_security_title)), click())); + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_backup_password_title)), click())); + onView(withId(R.id.text_password)).perform(typeText(VAULT_BACKUP_PASSWORD), closeSoftKeyboard()); + onView(withId(R.id.text_password_confirm)).perform(typeText(VAULT_BACKUP_PASSWORD), closeSoftKeyboard()); + onView(withId(android.R.id.button1)).perform(click()); + onView(isRoot()).perform(pressBack()); + + creds = _vaultManager.getVault().getCredentials(); + assertEquals(creds.getSlots().findRegularPasswordSlots().size(), 1); + assertEquals(creds.getSlots().findBackupPasswordSlots().size(), 1); + for (PasswordSlot slot : creds.getSlots().findBackupPasswordSlots()) { + verifyPasswordSlotChange(creds, slot, VAULT_PASSWORD, VAULT_BACKUP_PASSWORD); + } + } + + private void verifyPasswordSlotChange(VaultFileCredentials creds, PasswordSlot slot, String oldPassword, String newPassword) { + assertThrows(SlotIntegrityException.class, () -> decryptPasswordSlot(slot, oldPassword)); + MasterKey masterKey; + try { + masterKey = decryptPasswordSlot(slot, newPassword); + } catch (SlotIntegrityException e) { + throw new RuntimeException("Unable to decrypt password slot", e); + } + + assertArrayEquals(creds.getKey().getBytes(), masterKey.getBytes()); + } + + private File doExport() { + File file = getExportFileUri(); + Intent resultData = new Intent(); + resultData.setData(Uri.fromFile(file)); + + Instrumentation.ActivityResult result = new Instrumentation.ActivityResult(Activity.RESULT_OK, resultData); + intending(not(isInternal())).respondWith(result); + + onView(withId(android.R.id.button1)).perform(click()); + return file; + } + + private void openExportDialog() { + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_section_import_export_title)), click())); + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_export_title)), click())); + } + + private MasterKey decryptPasswordSlot(PasswordSlot slot, String password) throws SlotIntegrityException { + SecretKey derivedKey = slot.deriveKey(password.toCharArray()); + try { + Cipher cipher = slot.createDecryptCipher(derivedKey); + return slot.getKey(cipher); + } catch (SlotException e) { + throw new RuntimeException("Unable to decrypt password slot", e); + } + } + + private File getExportFileUri() { + String dirName = Hex.encode(CryptoUtils.generateRandomBytes(8)); + File dir = new File(getInstrumentation().getTargetContext().getExternalCacheDir(), String.format("export-%s", dirName)); + if (!dir.mkdirs()) { + throw new RuntimeException(String.format("Unable to create export directory: %s", dir)); + } + + VaultBackupManager.FileInfo fileInfo = new VaultBackupManager.FileInfo(VaultRepository.FILENAME_PREFIX_EXPORT); + return new File(dir, fileInfo.toString()); + } + + private VaultRepository readVault(File file, @Nullable String password) { + VaultRepository repo; + try (InputStream inStream = new FileInputStream(file)) { + byte[] bytes = IOUtils.readAll(inStream); + VaultFile vaultFile = VaultFile.fromBytes(bytes); + + VaultFileCredentials creds = null; + if (password != null) { + SlotList slots = vaultFile.getHeader().getSlots(); + for (PasswordSlot slot : slots.findAll(PasswordSlot.class)) { + SecretKey derivedKey = slot.deriveKey(password.toCharArray()); + Cipher cipher = slot.createDecryptCipher(derivedKey); + MasterKey masterKey = slot.getKey(cipher); + creds = new VaultFileCredentials(masterKey, slots); + break; + } + } + + repo = VaultRepository.fromFile(getInstrumentation().getContext(), vaultFile, creds); + } catch (SlotException | SlotIntegrityException | VaultRepositoryException | VaultFileException | IOException e) { + throw new RuntimeException("Unable to read back vault file", e); + } + + checkReadEntries(repo.getEntries()); + return repo; + } + + private void readTxtExport(File file) { + GoogleAuthUriImporter importer = new GoogleAuthUriImporter(getInstrumentation().getContext()); + + Collection entries; + try (InputStream inStream = new FileInputStream(file)) { + DatabaseImporter.State state = importer.read(inStream); + DatabaseImporter.Result result = state.convert(); + entries = result.getEntries().getValues(); + } catch (DatabaseImporterException | IOException e) { + throw new RuntimeException("Unable to read txt export file", e); + } + + checkReadEntries(entries); + } + + private void checkHtmlExport(File file) { + try (InputStream inStream = new FileInputStream(file)) { + Reader inReader = new InputStreamReader(inStream, StandardCharsets.UTF_8); + XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); + XmlPullParser parser = factory.newPullParser(); + parser.setInput(inReader); + while (parser.getEventType() != XmlPullParser.START_TAG) { + parser.next(); + } + if (!parser.getName().toLowerCase(Locale.ROOT).equals("html")) { + throw new RuntimeException("not an html document!"); + } + while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { + parser.next(); + } + } catch (IOException | XmlPullParserException e) { + throw new RuntimeException("Unable to read html export file", e); + } + } + + private void checkReadEntries(Collection entries) { + List vectors = VaultEntries.get(); + assertEquals(vectors.size(), entries.size()); + + int i = 0; + for (VaultEntry entry : entries) { + VaultEntry vector = vectors.get(i); + String message = String.format("Entries are not equivalent: (%s) (%s)", vector.toJson().toString(), entry.toJson().toString()); + assertTrue(message, vector.equivalates(entry)); + try { + assertEquals(message, vector.getInfo().getOtp(), entry.getInfo().getOtp()); + } catch (OtpInfoException e) { + throw new RuntimeException("Unable to generate OTP", e); + } + i++; + } + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/IntentTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/DeepLinkTest.java similarity index 82% rename from app/src/androidTest/java/com/beemdevelopment/aegis/IntentTest.java rename to app/src/androidTest/java/com/beemdevelopment/aegis/DeepLinkTest.java index 89ebffd930..2afefaa1c7 100644 --- a/app/src/androidTest/java/com/beemdevelopment/aegis/IntentTest.java +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/DeepLinkTest.java @@ -1,5 +1,10 @@ package com.beemdevelopment.aegis; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static junit.framework.TestCase.assertTrue; + import android.content.Intent; import android.net.Uri; @@ -16,38 +21,36 @@ import org.junit.Test; import org.junit.runner.RunWith; -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static junit.framework.TestCase.assertTrue; +import dagger.hilt.android.testing.HiltAndroidTest; @RunWith(AndroidJUnit4.class) +@HiltAndroidTest @LargeTest -public class IntentTest extends AegisTest { +public class DeepLinkTest extends AegisTest { @Before public void before() { - initVault(); + initEmptyEncryptedVault(); } @Test - public void doDeepLinkIntent() { + public void testDeepLinkIntent() { VaultEntry entry = generateEntry(TotpInfo.class, "Bob", "Google"); GoogleAuthInfo info = new GoogleAuthInfo(entry.getInfo(), entry.getName(), entry.getIssuer()); launch(info.getUri()); onView(withId(R.id.action_save)).perform(click()); - VaultEntry createdEntry = (VaultEntry) getVault().getEntries().toArray()[0]; + VaultEntry createdEntry = (VaultEntry) _vaultManager.getVault().getEntries().toArray()[0]; assertTrue(createdEntry.equivalates(entry)); } @Test - public void doDeepLinkIntent_Empty() { + public void testDeepLinkIntent_Empty() { launch(null); } @Test - public void doDeepLinkIntent_Bad() { + public void testDeepLinkIntent_Bad() { launch(Uri.parse("otpauth://bad")); onView(withId(android.R.id.button1)).perform(click()); } diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/EmptySecretTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/EmptySecretTest.java new file mode 100644 index 0000000000..47c53234b4 --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/EmptySecretTest.java @@ -0,0 +1,54 @@ +package com.beemdevelopment.aegis; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.contrib.RecyclerViewActions; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.rules.ScreenshotTestRule; +import com.beemdevelopment.aegis.ui.MainActivity; +import com.beemdevelopment.aegis.vault.VaultEntry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; +import org.junit.runner.RunWith; + +import dagger.hilt.android.testing.HiltAndroidTest; + +@RunWith(AndroidJUnit4.class) +@HiltAndroidTest +@SmallTest +public class EmptySecretTest extends AegisTest { + private ActivityScenario _scenario; + + @Before + public void before() throws OtpInfoException { + initEmptyPlainVault(); + _vaultManager.getVault().addEntry(new VaultEntry(new TotpInfo(new byte[0]))); + + _scenario = ActivityScenario.launch(MainActivity.class); + } + + @After + public void after() { + _scenario.close(); + } + + @Test + public void testVaultEntryEmptySecret() { + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.error_all_caps)), click())); + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/IntroTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/IntroTest.java index ecc40163de..ad9d5a6e84 100644 --- a/app/src/androidTest/java/com/beemdevelopment/aegis/IntroTest.java +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/IntroTest.java @@ -1,41 +1,88 @@ package com.beemdevelopment.aegis; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.action.ViewActions.typeText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.intent.Intents.intending; +import static androidx.test.espresso.intent.matcher.IntentMatchers.isInternal; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static junit.framework.TestCase.assertFalse; +import static junit.framework.TestCase.assertNull; +import static junit.framework.TestCase.assertTrue; +import static org.hamcrest.Matchers.not; + +import android.app.Activity; +import android.app.Instrumentation; +import android.content.Intent; +import android.net.Uri; + +import androidx.test.espresso.IdlingRegistry; +import androidx.test.espresso.IdlingResource; import androidx.test.espresso.ViewInteraction; +import androidx.test.espresso.intent.Intents; import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; +import androidx.viewpager2.widget.ViewPager2; +import com.beemdevelopment.aegis.rules.ScreenshotTestRule; import com.beemdevelopment.aegis.ui.IntroActivity; -import com.beemdevelopment.aegis.vault.VaultManager; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultRepository; import com.beemdevelopment.aegis.vault.slots.BiometricSlot; import com.beemdevelopment.aegis.vault.slots.PasswordSlot; import com.beemdevelopment.aegis.vault.slots.SlotList; +import org.junit.After; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; import org.junit.runner.RunWith; -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; -import static androidx.test.espresso.action.ViewActions.replaceText; -import static androidx.test.espresso.action.ViewActions.typeText; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static junit.framework.TestCase.assertFalse; -import static junit.framework.TestCase.assertNull; -import static junit.framework.TestCase.assertTrue; -import static org.hamcrest.Matchers.not; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import dagger.hilt.android.testing.HiltAndroidTest; @RunWith(AndroidJUnit4.class) +@HiltAndroidTest @LargeTest public class IntroTest extends AegisTest { + private final ActivityScenarioRule _activityRule = new ActivityScenarioRule<>(IntroActivity.class); + + private ViewPager2IdlingResource _viewPager2IdlingResource; + @Rule - public final ActivityScenarioRule activityRule = new ActivityScenarioRule<>(IntroActivity.class); + public final TestRule testRule = RuleChain.outerRule(_activityRule).around(new ScreenshotTestRule()); + + @Before + public void setUp() { + Intents.init(); + + _activityRule.getScenario().onActivity(activity -> { + _viewPager2IdlingResource = new ViewPager2IdlingResource(activity.findViewById(R.id.pager), "viewPagerIdlingResource"); + IdlingRegistry.getInstance().register(_viewPager2IdlingResource); + }); + } + + @After + public void tearDown() { + Intents.release(); + IdlingRegistry.getInstance().unregister(_viewPager2IdlingResource); + } @Test - public void doIntro_None() { + public void testIntro_None() { + assertFalse(_prefs.isIntroDone()); ViewInteraction next = onView(withId(R.id.btnNext)); ViewInteraction prev = onView(withId(R.id.btnPrevious)); @@ -48,15 +95,16 @@ public void doIntro_None() { next.perform(click()); prev.check(matches(not(isDisplayed()))); next.perform(click()); - next.perform(click()); - VaultManager vault = getVault(); + VaultRepository vault = _vaultManager.getVault(); assertFalse(vault.isEncryptionEnabled()); - assertNull(getVault().getCredentials()); + assertNull(vault.getCredentials()); + assertTrue(_prefs.isIntroDone()); } @Test - public void doIntro_Password() { + public void testIntro_Password() { + assertFalse(_prefs.isIntroDone()); ViewInteraction next = onView(withId(R.id.btnNext)); ViewInteraction prev = onView(withId(R.id.btnPrevious)); @@ -79,10 +127,102 @@ public void doIntro_Password() { next.perform(click()); next.perform(click()); - VaultManager vault = getVault(); - SlotList slots = getVault().getCredentials().getSlots(); + VaultRepository vault = _vaultManager.getVault(); + SlotList slots = vault.getCredentials().getSlots(); + assertTrue(vault.isEncryptionEnabled()); + assertTrue(slots.has(PasswordSlot.class)); + assertFalse(slots.has(BiometricSlot.class)); + assertTrue(_prefs.isIntroDone()); + } + + @Test + public void testIntro_Import_Plain() { + assertFalse(_prefs.isIntroDone()); + Uri uri = getResourceUri("aegis_plain.json"); + Intent resultData = new Intent(); + resultData.setData(uri); + + Instrumentation.ActivityResult result = new Instrumentation.ActivityResult(Activity.RESULT_OK, resultData); + intending(not(isInternal())).respondWith(result); + + ViewInteraction next = onView(withId(R.id.btnNext)); + onView(withId(R.id.btnImport)).perform(click()); + next.perform(click()); + + VaultRepository vault = _vaultManager.getVault(); + assertFalse(vault.isEncryptionEnabled()); + assertNull(vault.getCredentials()); + assertTrue(_prefs.isIntroDone()); + } + + @Test + public void testIntro_Import_Encrypted() { + assertFalse(_prefs.isIntroDone()); + Uri uri = getResourceUri("aegis_encrypted.json"); + Intent resultData = new Intent(); + resultData.setData(uri); + + Instrumentation.ActivityResult result = new Instrumentation.ActivityResult(Activity.RESULT_OK, resultData); + intending(not(isInternal())).respondWith(result); + + ViewInteraction next = onView(withId(R.id.btnNext)); + onView(withId(R.id.btnImport)).perform(click()); + onView(withId(R.id.text_input)).perform(typeText(VAULT_PASSWORD), closeSoftKeyboard()); + onView(withId(android.R.id.button1)).perform(click()); + next.perform(click()); + + VaultRepository vault = _vaultManager.getVault(); + SlotList slots = vault.getCredentials().getSlots(); assertTrue(vault.isEncryptionEnabled()); assertTrue(slots.has(PasswordSlot.class)); assertFalse(slots.has(BiometricSlot.class)); + assertTrue(_prefs.isIntroDone()); + } + + private Uri getResourceUri(String resourceName) { + File targetFile = new File(getInstrumentation().getTargetContext().getExternalCacheDir(), resourceName); + try (InputStream inStream = getClass().getResourceAsStream(resourceName); + FileOutputStream outStream = new FileOutputStream(targetFile)) { + IOUtils.copy(inStream, outStream); + } catch (IOException e) { + throw new RuntimeException(e); + } + + return Uri.fromFile(targetFile); + } + + // Source: https://stackoverflow.com/a/32763454/12972657 + private static class ViewPager2IdlingResource implements IdlingResource { + private final String _resName; + private boolean _isIdle = true; + private IdlingResource.ResourceCallback _resourceCallback = null; + + public ViewPager2IdlingResource(ViewPager2 viewPager, String resName) { + viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageScrollStateChanged(int state) { + _isIdle = (state == ViewPager2.SCROLL_STATE_IDLE || state == ViewPager2.SCROLL_STATE_DRAGGING); + if (_isIdle && _resourceCallback != null) { + _resourceCallback.onTransitionToIdle(); + } + } + }); + _resName = resName; + } + + @Override + public String getName() { + return _resName; + } + + @Override + public boolean isIdleNow() { + return _isIdle; + } + + @Override + public void registerIdleTransitionCallback(IdlingResource.ResourceCallback resourceCallback) { + _resourceCallback = resourceCallback; + } } } diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java index 4db3a94cc8..1a98d947b3 100644 --- a/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java @@ -1,6 +1,28 @@ package com.beemdevelopment.aegis; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.openContextualActionModeOverflowMenu; +import static androidx.test.espresso.action.ViewActions.clearText; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.longClick; +import static androidx.test.espresso.action.ViewActions.pressBack; +import static androidx.test.espresso.action.ViewActions.scrollTo; +import static androidx.test.espresso.action.ViewActions.typeText; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; +import static androidx.test.espresso.matcher.ViewMatchers.withClassName; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static junit.framework.TestCase.assertFalse; +import static junit.framework.TestCase.assertNull; +import static junit.framework.TestCase.assertTrue; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; + import androidx.annotation.IdRes; +import androidx.recyclerview.widget.RecyclerView; import androidx.test.espresso.ViewInteraction; import androidx.test.espresso.contrib.RecyclerViewActions; import androidx.test.espresso.matcher.RootMatchers; @@ -9,66 +31,67 @@ import androidx.test.filters.LargeTest; import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.Hex; import com.beemdevelopment.aegis.otp.HotpInfo; +import com.beemdevelopment.aegis.otp.MotpInfo; import com.beemdevelopment.aegis.otp.SteamInfo; import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.otp.YandexInfo; +import com.beemdevelopment.aegis.rules.ScreenshotTestRule; import com.beemdevelopment.aegis.ui.MainActivity; +import com.beemdevelopment.aegis.ui.views.EntryAdapter; import com.beemdevelopment.aegis.vault.VaultEntry; -import com.beemdevelopment.aegis.vault.VaultManager; +import com.beemdevelopment.aegis.vault.VaultRepository; import com.beemdevelopment.aegis.vault.slots.PasswordSlot; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; import org.junit.runner.RunWith; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.Espresso.openContextualActionModeOverflowMenu; -import static androidx.test.espresso.action.ViewActions.clearText; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; -import static androidx.test.espresso.action.ViewActions.longClick; -import static androidx.test.espresso.action.ViewActions.pressBack; -import static androidx.test.espresso.action.ViewActions.typeText; -import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; -import static androidx.test.espresso.matcher.ViewMatchers.isRoot; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withText; -import static junit.framework.TestCase.assertFalse; -import static junit.framework.TestCase.assertNull; -import static junit.framework.TestCase.assertTrue; +import dagger.hilt.android.testing.HiltAndroidTest; @RunWith(AndroidJUnit4.class) +@HiltAndroidTest @LargeTest public class OverallTest extends AegisTest { private static final String _groupName = "Test"; + private final ActivityScenarioRule _activityRule = new ActivityScenarioRule<>(MainActivity.class); + @Rule - public final ActivityScenarioRule activityRule = new ActivityScenarioRule<>(MainActivity.class); + public final TestRule testRule = RuleChain.outerRule(_activityRule).around(new ScreenshotTestRule()); @Test - public void doOverallTest() { + public void testOverall() { ViewInteraction next = onView(withId(R.id.btnNext)); next.perform(click()); onView(withId(R.id.rb_password)).perform(click()); next.perform(click()); - onView(withId(R.id.text_password)).perform(typeText(VAULT_PASSWORD), closeSoftKeyboard()); + onView(withId(R.id.text_password)).perform(click()).perform(typeText(VAULT_PASSWORD), closeSoftKeyboard()); onView(withId(R.id.text_password_confirm)).perform(typeText(VAULT_PASSWORD), closeSoftKeyboard()); next.perform(click()); onView(withId(R.id.btnNext)).perform(click()); - VaultManager vault = getVault(); + VaultRepository vault = _vaultManager.getVault(); assertTrue(vault.isEncryptionEnabled()); assertTrue(vault.getCredentials().getSlots().has(PasswordSlot.class)); + assertTrue(_prefs.isIntroDone()); List entries = Arrays.asList( generateEntry(TotpInfo.class, "Frank", "Google"), generateEntry(HotpInfo.class, "John", "GitHub"), generateEntry(TotpInfo.class, "Alice", "Office 365"), - generateEntry(SteamInfo.class, "Gaben", "Steam") + generateEntry(SteamInfo.class, "Gaben", "Steam"), + generateEntry(YandexInfo.class, "Ivan", "Yandex", 16), + generateEntry(MotpInfo.class, "Jimmy McGill", "PfSense", 16) ); for (VaultEntry entry : entries) { addEntry(entry); @@ -81,19 +104,26 @@ public void doOverallTest() { } for (int i = 0; i < 10; i++) { - onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(1, clickChildViewWithId(R.id.buttonRefresh))); + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnHolderItem(withOtpType(HotpInfo.class), clickChildViewWithId(R.id.buttonRefresh))); } - onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(0, longClick())); + AtomicBoolean isErrorCardShown = new AtomicBoolean(false); + _activityRule.getScenario().onActivity(activity -> { + isErrorCardShown.set(((EntryAdapter)((RecyclerView) activity.findViewById(R.id.rvKeyProfiles)).getAdapter()).isErrorCardShown()); + }); + + int entryPosOffset = isErrorCardShown.get() ? 1 : 0; + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(entryPosOffset + 0, longClick())); onView(withId(R.id.action_copy)).perform(click()); - onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(1, longClick())); + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(entryPosOffset + 1, longClick())); onView(withId(R.id.action_edit)).perform(click()); onView(withId(R.id.text_name)).perform(clearText(), typeText("Bob"), closeSoftKeyboard()); - onView(withId(R.id.dropdown_group)).perform(click()); - onView(withText(R.string.new_group)).inRoot(RootMatchers.isPlatformPopup()).perform(click()); + onView(withId(R.id.text_group)).perform(click()); + onView(withId(R.id.addGroup)).inRoot(RootMatchers.isDialog()).perform(click()); onView(withId(R.id.text_input)).perform(typeText(_groupName), closeSoftKeyboard()); onView(withId(android.R.id.button1)).perform(click()); + onView(withText(R.string.save)).perform(click()); onView(isRoot()).perform(pressBack()); onView(withId(android.R.id.button1)).perform(click()); @@ -106,21 +136,22 @@ public void doOverallTest() { changeGroupFilter(_groupName); changeGroupFilter(null); - onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(1, longClick())); - onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(2, click())); - onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(3, click())); + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(entryPosOffset + 2, longClick())); + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(entryPosOffset + 3, click())); + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(entryPosOffset + 4, click())); onView(withId(R.id.action_share_qr)).perform(click()); onView(withId(R.id.btnNext)).perform(click()).perform(click()).perform(click()); - onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(2, longClick())); - onView(withId(R.id.action_delete)).perform(click()); + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(entryPosOffset + 0, longClick())); + onView(allOf(isDescendantOfA(withClassName(containsString("ActionBarContextView"))), withClassName(containsString("OverflowMenuButton")))).perform(click()); + onView(withText(R.string.action_delete)).perform(click()); onView(withId(android.R.id.button1)).perform(click()); openContextualActionModeOverflowMenu(); onView(withText(R.string.lock)).perform(click()); onView(withId(R.id.text_password)).perform(typeText(VAULT_PASSWORD), closeSoftKeyboard()); onView(withId(R.id.button_decrypt)).perform(click()); - vault = getVault(); + vault = _vaultManager.getVault(); openContextualActionModeOverflowMenu(); onView(withText(R.string.action_settings)).perform(click()); @@ -146,20 +177,18 @@ private void changeSort(@IdRes int resId) { } private void changeGroupFilter(String text) { - onView(withId(R.id.chip_group)).perform(click()); if (text == null) { - onView(withId(R.id.btnClear)).perform(click()); + onView(allOf(withText(R.string.no_group), isDescendantOfA(withId(R.id.groupChipGroup)))).perform(click()); } else { - onView(withText(text)).perform(click()); - onView(isRoot()).perform(pressBack()); + onView(allOf(withText(text), isDescendantOfA(withId(R.id.groupChipGroup)))).perform(click()); } } private void addEntry(VaultEntry entry) { onView(withId(R.id.fab)).perform(click()); - onView(withId(R.id.fab_enter)).perform(click()); + onView(withId(R.id.fab_menu_item_enter)).perform(click()); - onView(withId(R.id.accordian_header)).perform(click()); + onView(withId(R.id.accordian_header)).perform(scrollTo(), click()); onView(withId(R.id.text_name)).perform(typeText(entry.getName()), closeSoftKeyboard()); onView(withId(R.id.text_issuer)).perform(typeText(entry.getIssuer()), closeSoftKeyboard()); @@ -169,19 +198,39 @@ private void addEntry(VaultEntry entry) { otpType = "HOTP"; } else if (entry.getInfo() instanceof SteamInfo) { otpType = "Steam"; + } else if (entry.getInfo() instanceof YandexInfo) { + otpType = "Yandex"; + } else if (entry.getInfo() instanceof MotpInfo) { + otpType = "MOTP"; } else if (entry.getInfo() instanceof TotpInfo) { otpType = "TOTP"; } else { throw new RuntimeException(String.format("Unexpected entry type: %s", entry.getInfo().getClass().getSimpleName())); } - onView(withId(R.id.dropdown_type)).perform(click()); + onView(withId(R.id.dropdown_type)).perform(scrollTo(), click()); onView(withText(otpType)).inRoot(RootMatchers.isPlatformPopup()).perform(click()); } - String secret = Base32.encode(entry.getInfo().getSecret()); + String secret; + if (Objects.equals(entry.getInfo().getTypeId(), MotpInfo.ID)) { + secret = Hex.encode(entry.getInfo().getSecret()); + } else { + secret = Base32.encode(entry.getInfo().getSecret()); + } + onView(withId(R.id.text_secret)).perform(typeText(secret), closeSoftKeyboard()); + if (entry.getInfo() instanceof YandexInfo) { + String pin = "123456"; + ((YandexInfo) entry.getInfo()).setPin(pin); + onView(withId(R.id.text_pin)).perform(typeText(pin), closeSoftKeyboard()); + } else if (entry.getInfo() instanceof MotpInfo) { + String pin = "1234"; + ((MotpInfo) entry.getInfo()).setPin(pin); + onView(withId(R.id.text_pin)).perform(typeText(pin), closeSoftKeyboard()); + } + onView(withId(R.id.action_save)).perform(click()); } } diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/PanicTriggerTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/PanicTriggerTest.java new file mode 100644 index 0000000000..39feca578e --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/PanicTriggerTest.java @@ -0,0 +1,58 @@ +package com.beemdevelopment.aegis; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import android.content.Intent; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; +import androidx.test.rule.ActivityTestRule; + +import com.beemdevelopment.aegis.ui.PanicResponderActivity; +import com.beemdevelopment.aegis.vault.VaultRepository; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dagger.hilt.android.testing.HiltAndroidTest; + +@RunWith(AndroidJUnit4.class) +@HiltAndroidTest +@SmallTest +public class PanicTriggerTest extends AegisTest { + @Before + public void before() { + initEncryptedVault(); + } + + @Test + public void testPanicTriggerDisabled() { + assertFalse(_prefs.isPanicTriggerEnabled()); + assertTrue(_vaultManager.isVaultLoaded()); + launchPanic(); + assertTrue(_vaultManager.isVaultLoaded()); + _vaultManager.getVault(); + assertTrue(VaultRepository.fileExists(getApp())); + } + + @Test + public void testPanicTriggerEnabled() { + _prefs.setIsPanicTriggerEnabled(true); + assertTrue(_prefs.isPanicTriggerEnabled()); + assertTrue(_vaultManager.isVaultLoaded()); + launchPanic(); + assertFalse(_vaultManager.isVaultLoaded()); + assertThrows(IllegalStateException.class, () -> _vaultManager.getVault()); + assertFalse(VaultRepository.fileExists(getApp())); + } + + private void launchPanic() { + Intent intent = new Intent(PanicResponderActivity.PANIC_TRIGGER_ACTION); + // we need to use the deprecated ActivityTestRule class because of https://github.com/android/android-test/issues/143 + ActivityTestRule rule = new ActivityTestRule<>(PanicResponderActivity.class); + rule.launchActivity(intent); + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/rules/ScreenshotTestRule.java b/app/src/androidTest/java/com/beemdevelopment/aegis/rules/ScreenshotTestRule.java new file mode 100644 index 0000000000..613394ef03 --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/rules/ScreenshotTestRule.java @@ -0,0 +1,36 @@ +package com.beemdevelopment.aegis.rules; + +import android.graphics.Bitmap; + +import androidx.test.runner.screenshot.BasicScreenCaptureProcessor; +import androidx.test.runner.screenshot.ScreenCapture; +import androidx.test.runner.screenshot.ScreenCaptureProcessor; +import androidx.test.runner.screenshot.Screenshot; + +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; + +import java.io.IOException; +import java.util.HashSet; + +public class ScreenshotTestRule extends TestWatcher { + @Override + protected void failed(Throwable e, Description description) { + super.failed(e, description); + + String filename = description.getTestClass().getSimpleName() + "-" + description.getMethodName(); + + ScreenCapture capture = Screenshot.capture(); + capture.setName(filename); + capture.setFormat(Bitmap.CompressFormat.PNG); + + HashSet processors = new HashSet<>(); + processors.add(new BasicScreenCaptureProcessor()); + + try { + capture.process(processors); + } catch (IOException e2) { + e.printStackTrace(); + } + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/vault/VaultManagerTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/vault/VaultRepositoryTest.java similarity index 51% rename from app/src/androidTest/java/com/beemdevelopment/aegis/vault/VaultManagerTest.java rename to app/src/androidTest/java/com/beemdevelopment/aegis/vault/VaultRepositoryTest.java index 163f108f57..ee32febde6 100644 --- a/app/src/androidTest/java/com/beemdevelopment/aegis/vault/VaultManagerTest.java +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/vault/VaultRepositoryTest.java @@ -1,5 +1,11 @@ package com.beemdevelopment.aegis.vault; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; @@ -10,30 +16,28 @@ import org.junit.Test; import org.junit.runner.RunWith; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; +import dagger.hilt.android.testing.HiltAndroidTest; @RunWith(AndroidJUnit4.class) +@HiltAndroidTest @SmallTest -public class VaultManagerTest extends AegisTest { +public class VaultRepositoryTest extends AegisTest { @Before public void before() { - initVault(); + initEncryptedVault(); } @Test - public void testToggleEncryption() throws VaultManagerException { - getVault().disableEncryption(); - assertFalse(getVault().isEncryptionEnabled()); - assertNull(getVault().getCredentials()); + public void testToggleEncryption() throws VaultRepositoryException { + VaultRepository vault = _vaultManager.getVault(); + _vaultManager.disableEncryption(); + assertFalse(vault.isEncryptionEnabled()); + assertNull(vault.getCredentials()); VaultFileCredentials creds = generateCredentials(); - getVault().enableEncryption(creds); - assertTrue(getVault().isEncryptionEnabled()); - assertNotNull(getVault().getCredentials()); - assertEquals(getVault().getCredentials().getSlots().findAll(PasswordSlot.class).size(), 1); + _vaultManager.enableEncryption(creds); + assertTrue(vault.isEncryptionEnabled()); + assertNotNull(vault.getCredentials()); + assertEquals(vault.getCredentials().getSlots().findAll(PasswordSlot.class).size(), 1); } } diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/vectors/VaultEntries.java b/app/src/androidTest/java/com/beemdevelopment/aegis/vectors/VaultEntries.java new file mode 120000 index 0000000000..f8cf5bbd6e --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/vectors/VaultEntries.java @@ -0,0 +1 @@ +../../../../../../test/java/com/beemdevelopment/aegis/vectors/VaultEntries.java \ No newline at end of file diff --git a/app/src/androidTest/resources/com/beemdevelopment/aegis/aegis_encrypted.json b/app/src/androidTest/resources/com/beemdevelopment/aegis/aegis_encrypted.json new file mode 120000 index 0000000000..02a115f795 --- /dev/null +++ b/app/src/androidTest/resources/com/beemdevelopment/aegis/aegis_encrypted.json @@ -0,0 +1 @@ +../../../../../test/resources/com/beemdevelopment/aegis/importers/aegis_encrypted.json \ No newline at end of file diff --git a/app/src/androidTest/resources/com/beemdevelopment/aegis/aegis_plain.json b/app/src/androidTest/resources/com/beemdevelopment/aegis/aegis_plain.json new file mode 120000 index 0000000000..b710d05fc4 --- /dev/null +++ b/app/src/androidTest/resources/com/beemdevelopment/aegis/aegis_plain.json @@ -0,0 +1 @@ +../../../../../test/resources/com/beemdevelopment/aegis/importers/aegis_plain.json \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8132f216f4..7e83d41c95 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,10 +1,15 @@ + xmlns:tools="http://schemas.android.com/tools"> + + + + + tools:targetApi="tiramisu"> - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -116,6 +169,8 @@ + + diff --git a/app/src/main/assets/changelog.html b/app/src/main/assets/changelog.html index c4e0307944..7a46eab386 100644 --- a/app/src/main/assets/changelog.html +++ b/app/src/main/assets/changelog.html @@ -31,6 +31,301 @@
+

Version 3.4.2

+

+ This version fixes the quick settings tile staying inactive permanently after upgrading to Android 16. +

+

New

+
    +
  • Redesigned FAB menu
  • +
  • Ability to import otpauth uri from clipboard
  • +
+

Fixes

+
    +
  • Fix quick settings tile state
  • +
  • Disable autofill services in 'Edit Entry' screen to avoid accidental overwriting master password
  • +
  • Inverted positions of buttons in 'Select Group' dialog
  • +
  • Remove redundant padding in tiles view
  • +
+

Version 3.4.1

+

New

+
    +
  • Support for importing from Proton Authenticator
  • +
+

Fixes

+
    +
  • The autofill service would show a prompt to save the PIN as a password
  • +
+

Version 3.4

+

New

+
    +
  • Haptic feedback when an entry is about to expire
  • +
  • Brightness increase is now toggleable in the entry transfer view
  • +
  • Filter on multiple groups simultaneously
  • +
  • Color contrast on hidden codes has been improved
  • +
  • Prompt before the user is about to save an entry with a duplicate name/issuer combination
  • +
  • New languages: Estonian, Korean, Malayalam, Norwegian (Bokmål) and Serbian
  • +
+

Fixes

+
    +
  • A crash could occur if an entry with period 7 exists and code expiry indication is enabled
  • +
  • The Portuguese (Brazilian) locale was used even if Portuguese was configured
  • +
  • FreeOTP import would fail if the algorithm or digits field was not specified for an entry
  • +
  • The divider between entries would be missing in certain filter configurations
  • +
  • The snackbar in try entry importing view could obstruct the name of an entry
  • +
+

Miscellaneous

+
    +
  • Android 6 or newer is now required the run the app
  • +
+

Version 3.3.4

+

Fixes

+
    +
  • Icons are now resized to 512x512 to reduce the size of the vault file and to reduce the chance of encountering out of memory conditions
  • +
+

Version 3.3.3

+

Fixes

+
    +
  • Some users ran into out of memory conditions due to large icons in their vault file. We've introduced a temporary measure that should help in most cases, but we'll follow up with a more comprehensive fix soon.
  • +
  • Window insets were not always applied correctly, causing parts of the UI to appear off-screen
  • +
  • The 2FAS importer did not tolerate spaces for secrets and was not always able to extract the issuer
  • +
+

Version 3.3.2

+

New

+
    +
  • Find entries by searching in multiple fields simultaneously
  • +
+

Fixes

+
    +
  • Entries would not actually be added to the Aegis vault in some cases when importing from Google Authenticator export QR codes
  • +
  • The lock button was sometimes shown for unencrypted vaults
  • +
  • The sort category menu item did not always reflect the current sorting
  • +
  • The next code was not always easy to read because its color had low contrast with the background
  • +
  • Entry selection was not cancelled when changing the group filter
  • +
+

Version 3.3.1

+

Fixes

+
    +
  • Codes were not shown in case the tiles view mode was combined with hidden account names
  • +
+

Version 3.3

+

New

+
    +
  • Significant improvements to group filtering +
      +
    • Groups can now be filtered on straight from the main view instead of through a dialog
    • +
    • Ability to assign multiple entries to a group in one go
    • +
    • Support for reordering groups
    • +
    +
  • +
  • Codes now change color when they're about to expire
  • +
  • Option to show the next code ahead of time
  • +
  • Support for backing up to a single file (This enables support for more cloud providers, such as Google Drive)
  • +
  • Various minor improvements to make QR code exports easier to scan
  • +
  • Support for importing from Ente Auth
  • +
  • Support for importing FreeOTP 2 backups
  • +
  • Updated translations
  • +
+

Fixes

+
    +
  • QR codes exported for Google Authenticator could not be scanned on iOS
  • +
  • The code would be copied after a single tap in case "Tap to reveal" and "Copy tokens to the clipboard" were enabled simultaneously
  • +
  • Various other minor UI, stability and performance improvements
  • +
+

Version 3.2

+

New

+
    +
  • The ability to add a single entry to multiple groups
  • +
  • Option to keep an infinite number of backups
  • +
  • Option to customize which fields to search for in entries
  • +
  • Allow hiding entry names in the tiled view mode
  • +
+

Fixes

+
    +
  • With "Tap to reveal" enabled, the size of the shown dots would not be consistent with the size of the code digits, on some devices
  • +
  • After importing a backup, the UI would in some cases incorrectly claim that biometric unlock is enabled
  • +
  • The export dialog was not fully visible on some devices
  • +
  • Various other minor UI, stability and performance improvements
  • +
+

Version 3.1.1

+

Fixes

+

+ A recent Android Pixel update introduced a bug causing Aegis to sometimes show a black screen after unlocking the vault. + We have reported this issue to the Google Issue Tracker (link) and + are awaiting a response from Google. In the meantime, we have implemented a workaround that eliminates this bug. +

+
    +
  • Group filter now gets applied properly upon unlocking the vault
  • +
  • Advanced entry settings now gets shown correctly
  • +
  • Keyboard when searching for entries now gets hidden when the user starts scrolling through the list
  • +
+

Version 3.1

+

New

+
    +
  • A new audit log has been added to check all important events that occurred in your vault
  • +
  • Added the ability to rename groups
  • +
+

Fixes

+
    +
  • Group selection will now be remembered again upon launch
  • +
  • Various UI improvements
  • +
  • Stability fixes
  • +
+

Version 3.0.1

+

New

+
    +
  • Support for importing from the new Battle.net app
  • +
+

Fixes

+
    +
  • Visual glitches when AMOLED theme was used on old Android versions
  • +
  • Minor UI improvements
  • +
+

Version 3.0

+

New

+
    +
  • Material 3 (and Material You)
  • +
  • Automatic assignment of icons to entries
  • +
  • Ability to select all entries in one go
  • +
  • Support for importing 2FAS schema v4 backups
  • +
  • Sort entries based on the last time they were used
  • +
  • Some clarifications related to importing and backup permission errors
  • +
  • Preparations for the ability to assign a single entry to multiple groups
  • +
  • Performance improvements when scrolling through an entry list with lots of icons
  • +
  • A new look for the third-party licenses list
  • +
+

Fixes

+
    +
  • Directly importing from Authy using root would fail
  • +
  • Minor glitches related to animation duration scale settings
  • +
  • Various stability improvements
  • +
+

Version 2.2.2

+

New

+
    +
  • An optional name field for icon packs to bypass filename character restrictions
  • +
+

Fixes

+
    +
  • The Authenticator Pro importer only supported the legacy backup format
  • +
  • A crash could occur in the tile service
  • +
+

Version 2.2.1

+

New

+
    +
  • Ability to automatically skip potential duplicates when importing entries
  • +
+

Fixes

+
    +
  • Biometrics button on the unlock screen was unresponsive
  • +
+

Version 2.2

+

New

+
    +
  • Authenticator Pro encrypted import support
  • +
  • Ability to change account name position
  • +
  • A new dialog explaining how our password reminder works
  • +
  • Ability to change copy behavior
  • +
  • Ability to only show account names when necessary
  • +
  • New view mode: Tiles/Grid
  • +
  • Added translation: Dutch (Frysian)
  • +
  • Updated translations
  • +
+

Fixes

+
    +
  • Deleting an entry while a search filter is active now shows the correct state
  • +
  • Aegis now fully respects system animation settings
  • +
+

Version 2.1.3

+

New

+
    +
  • Option to disable the backup reminder
  • +
  • Improved group selection dropdown during vault export
  • +
  • New translation: Hebrew
  • +
  • Updated translations
  • +
+

Fixes

+
    +
  • A crash could occur because a Toast was incorrectly created
  • +
+

Version 2.1.2

+

Fixes

+
    +
  • A crash could occur when changing an entry in such a way that it is filtered out from the entry list
  • +
+

Version 2.1.1

+

New

+
    +
  • An option to export the vault as an HTML file
  • +
  • Support for importing from Battle.net Authenticator (root required)
  • +
  • An option to hide entry icons
  • +
  • An option to only include certain groups in an export
  • +
  • Copying a token now takes a second tap if tap to reveal is enabled
  • +
  • The ability to copy the URI when transferring entries through QR codes
  • +
  • Updated translations
  • +
+

Fixes

+
    +
  • The lock notification would remain after locking the vault in certain cases. For now, we've disabled the notification entirely.
  • +
  • Making changes to an entry while having one or more favorited entries in the vault could result in buggy ordering
  • +
  • Tapping to the reveal a token could increase the height of the entry in certain view modes on recent Android versions
  • +
  • The backup reminder was unclear about when the last successful backup took place
  • +
  • Users could accidentally select MD5 as the hash algorithm for non-mOTP entry types, causing crashes at seemingly random intervals. Any users who have gotten themselves into this situation will see these bad entries get reset to SHA1.
  • +
  • Importing from certain apps would cause a crash if an empty password was entered
  • +
  • The andOTP importer could hang indefinitely if the user accidentally selected a non-andOTP file.
  • +
  • Various other stability improvements
  • +
+

Version 2.1

+

New

+
    +
  • Support for mOTP
  • +
  • Support for Yandex OTP (Experimental)
  • +
  • An Adaptive Icon for Material You
  • +
  • Ability to favorite certain entries and pin them to the top of the entry list
  • +
  • Ability to filter by entries that are not in a group
  • +
  • Ability to set a separate password that is used for encrypting backups and exports
  • +
  • Support for predictive back gesture
  • +
  • Improved overview of backup status in preferences
  • +
  • Additional options for code digit grouping
  • +
  • Support for importing from Duo
  • +
  • Support for importing from Bitwarden
  • +
  • Support for importing multiple QR code images in one go
  • +
  • Support for scanning Google Authenticator export QR codes from image files
  • +
  • Display some extra information in the dialog displayed when deleting an entry
  • +
  • An option to export through Google Authenticator export QR code images
  • +
  • An option to import an existing vault file from the first page in the intro
  • +
  • An option to minimize the app after copying a token
  • +
  • A count of the total number of entries is displayed at the bottom of the entry list
  • +
  • A backup reminder is shown if changes were made to the vault, but no backup or export has been created yet since then
  • +
  • A warning is shown after a plaintext export has been made
  • +
  • An option to focus search immediately after the app starts
  • +
  • Allow customization of the frequency of the password reminder
  • +
  • Allow sharing text to Aegis in the format of a Google Authenticator URI to add as a new entry
  • +
  • Always allow D2D (device-to-device) Android backups regardless of backup settings
  • +
  • Mark clipboard data as sensitive when copying tokens so that Android will mask them in the UI
  • +
  • Updated translations for almost all languages
  • +
  • New languages: Asturian, Catalan, Galician
  • +
+

Fixes

+
    +
  • Various reliability improvements for the QR code scanner
  • +
  • The floating action button was glitchy when making small entry list scroll movements
  • +
  • The vault unlocked notification was never shown and was still using the old app icon
  • +
  • The automatically generated entry icon was broken if the entry name/issuer is a multi-codepoint character (certain emoji's, for example)
  • +
  • The PIN keyboard was not disabled after enabling encryption
  • +
  • The password prompt message was unclear when importing from a file
  • +
  • The entry list was not sorted correctly if a change to an entry caused its location to change
  • +
  • Quickly double-tapping on the copy button would cause a crash
  • +
  • Importing an entry with an empty secret would cause a crash loop
  • +
  • On certain devices, it was not possible to import icon packs because the .ZIP files would be grayed out
  • +
  • An unclear error message was shown when trying to import from Steam and Google Authenticator
  • +
  • Various other minor UI and stability improvements
  • +
+

Version 2.0.3

+

New

+
    +
  • Support for importing 2FAS Authenticator's new backup format
  • +

Version 2.0.2

New

    diff --git a/app/src/main/java/com/amulyakhare/textdrawable/LICENSE b/app/src/main/java/com/amulyakhare/textdrawable/LICENSE new file mode 100644 index 0000000000..f86466167b --- /dev/null +++ b/app/src/main/java/com/amulyakhare/textdrawable/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2014 Amulya Khare + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/app/src/main/java/com/amulyakhare/textdrawable/TextDrawable.java b/app/src/main/java/com/amulyakhare/textdrawable/TextDrawable.java new file mode 100644 index 0000000000..f41057153b --- /dev/null +++ b/app/src/main/java/com/amulyakhare/textdrawable/TextDrawable.java @@ -0,0 +1,316 @@ +package com.amulyakhare.textdrawable; + +import android.graphics.*; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.OvalShape; +import android.graphics.drawable.shapes.RectShape; +import android.graphics.drawable.shapes.RoundRectShape; + +/** + * @author amulya + * @datetime 14 Oct 2014, 3:53 PM + */ +public class TextDrawable extends ShapeDrawable { + + private final Paint textPaint; + private final Paint borderPaint; + private static final float SHADE_FACTOR = 0.9f; + private final String text; + private final int color; + private final RectShape shape; + private final int height; + private final int width; + private final int fontSize; + private final float radius; + private final int borderThickness; + + private TextDrawable(Builder builder) { + super(builder.shape); + + // shape properties + shape = builder.shape; + height = builder.height; + width = builder.width; + radius = builder.radius; + + // text and color + text = builder.toUpperCase ? builder.text.toUpperCase() : builder.text; + color = builder.color; + + // text paint settings + fontSize = builder.fontSize; + textPaint = new Paint(); + textPaint.setColor(builder.textColor); + textPaint.setAntiAlias(true); + textPaint.setFakeBoldText(builder.isBold); + textPaint.setStyle(Paint.Style.FILL); + textPaint.setTypeface(builder.font); + textPaint.setTextAlign(Paint.Align.CENTER); + textPaint.setStrokeWidth(builder.borderThickness); + + // border paint settings + borderThickness = builder.borderThickness; + borderPaint = new Paint(); + borderPaint.setColor(getDarkerShade(color)); + borderPaint.setStyle(Paint.Style.STROKE); + borderPaint.setStrokeWidth(borderThickness); + + // drawable paint color + Paint paint = getPaint(); + paint.setColor(color); + + } + + private int getDarkerShade(int color) { + return Color.rgb((int)(SHADE_FACTOR * Color.red(color)), + (int)(SHADE_FACTOR * Color.green(color)), + (int)(SHADE_FACTOR * Color.blue(color))); + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + Rect r = getBounds(); + + + // draw border + if (borderThickness > 0) { + drawBorder(canvas); + } + + int count = canvas.save(); + canvas.translate(r.left, r.top); + + // draw text + int width = this.width < 0 ? r.width() : this.width; + int height = this.height < 0 ? r.height() : this.height; + int fontSize = this.fontSize < 0 ? (Math.min(width, height) / 2) : this.fontSize; + textPaint.setTextSize(fontSize); + canvas.drawText(text, width / 2, height / 2 - ((textPaint.descent() + textPaint.ascent()) / 2), textPaint); + + canvas.restoreToCount(count); + + } + + private void drawBorder(Canvas canvas) { + RectF rect = new RectF(getBounds()); + rect.inset(borderThickness/2, borderThickness/2); + + if (shape instanceof OvalShape) { + canvas.drawOval(rect, borderPaint); + } + else if (shape instanceof RoundRectShape) { + canvas.drawRoundRect(rect, radius, radius, borderPaint); + } + else { + canvas.drawRect(rect, borderPaint); + } + } + + @Override + public void setAlpha(int alpha) { + textPaint.setAlpha(alpha); + } + + @Override + public void setColorFilter(ColorFilter cf) { + textPaint.setColorFilter(cf); + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + @Override + public int getIntrinsicWidth() { + return width; + } + + @Override + public int getIntrinsicHeight() { + return height; + } + + public static IShapeBuilder builder() { + return new Builder(); + } + + public static class Builder implements IConfigBuilder, IShapeBuilder, IBuilder { + + private String text; + + private int color; + + private int borderThickness; + + private int width; + + private int height; + + private Typeface font; + + private RectShape shape; + + public int textColor; + + private int fontSize; + + private boolean isBold; + + private boolean toUpperCase; + + public float radius; + + private Builder() { + text = ""; + color = Color.GRAY; + textColor = Color.WHITE; + borderThickness = 0; + width = -1; + height = -1; + shape = new RectShape(); + font = Typeface.create("sans-serif-light", Typeface.NORMAL); + fontSize = -1; + isBold = false; + toUpperCase = false; + } + + public IConfigBuilder width(int width) { + this.width = width; + return this; + } + + public IConfigBuilder height(int height) { + this.height = height; + return this; + } + + public IConfigBuilder textColor(int color) { + this.textColor = color; + return this; + } + + public IConfigBuilder withBorder(int thickness) { + this.borderThickness = thickness; + return this; + } + + public IConfigBuilder useFont(Typeface font) { + this.font = font; + return this; + } + + public IConfigBuilder fontSize(int size) { + this.fontSize = size; + return this; + } + + public IConfigBuilder bold() { + this.isBold = true; + return this; + } + + public IConfigBuilder toUpperCase() { + this.toUpperCase = true; + return this; + } + + @Override + public IConfigBuilder beginConfig() { + return this; + } + + @Override + public IShapeBuilder endConfig() { + return this; + } + + @Override + public IBuilder rect() { + this.shape = new RectShape(); + return this; + } + + @Override + public IBuilder round() { + this.shape = new OvalShape(); + return this; + } + + @Override + public IBuilder roundRect(int radius) { + this.radius = radius; + float[] radii = {radius, radius, radius, radius, radius, radius, radius, radius}; + this.shape = new RoundRectShape(radii, null, null); + return this; + } + + @Override + public TextDrawable buildRect(String text, int color) { + rect(); + return build(text, color); + } + + @Override + public TextDrawable buildRoundRect(String text, int color, int radius) { + roundRect(radius); + return build(text, color); + } + + @Override + public TextDrawable buildRound(String text, int color) { + round(); + return build(text, color); + } + + @Override + public TextDrawable build(String text, int color) { + this.color = color; + this.text = text; + return new TextDrawable(this); + } + } + + public interface IConfigBuilder { + public IConfigBuilder width(int width); + + public IConfigBuilder height(int height); + + public IConfigBuilder textColor(int color); + + public IConfigBuilder withBorder(int thickness); + + public IConfigBuilder useFont(Typeface font); + + public IConfigBuilder fontSize(int size); + + public IConfigBuilder bold(); + + public IConfigBuilder toUpperCase(); + + public IShapeBuilder endConfig(); + } + + public static interface IBuilder { + + public TextDrawable build(String text, int color); + } + + public static interface IShapeBuilder { + + public IConfigBuilder beginConfig(); + + public IBuilder rect(); + + public IBuilder round(); + + public IBuilder roundRect(int radius); + + public TextDrawable buildRect(String text, int color); + + public TextDrawable buildRoundRect(String text, int color, int radius); + + public TextDrawable buildRound(String text, int color); + } +} diff --git a/app/src/main/java/com/amulyakhare/textdrawable/util/ColorGenerator.java b/app/src/main/java/com/amulyakhare/textdrawable/util/ColorGenerator.java new file mode 100644 index 0000000000..7efe7d5d66 --- /dev/null +++ b/app/src/main/java/com/amulyakhare/textdrawable/util/ColorGenerator.java @@ -0,0 +1,69 @@ +package com.amulyakhare.textdrawable.util; + +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +/** + * @author amulya + * @datetime 14 Oct 2014, 5:20 PM + */ +public class ColorGenerator { + + public static ColorGenerator DEFAULT; + + public static ColorGenerator MATERIAL; + + static { + DEFAULT = create(Arrays.asList( + 0xfff16364, + 0xfff58559, + 0xfff9a43e, + 0xffe4c62e, + 0xff67bf74, + 0xff59a2be, + 0xff2093cd, + 0xffad62a7, + 0xff805781 + )); + MATERIAL = create(Arrays.asList( + 0xffe57373, + 0xfff06292, + 0xffba68c8, + 0xff9575cd, + 0xff7986cb, + 0xff64b5f6, + 0xff4fc3f7, + 0xff4dd0e1, + 0xff4db6ac, + 0xff81c784, + 0xffaed581, + 0xffff8a65, + 0xffd4e157, + 0xffffd54f, + 0xffffb74d, + 0xffa1887f, + 0xff90a4ae + )); + } + + private final List mColors; + private final Random mRandom; + + public static ColorGenerator create(List colorList) { + return new ColorGenerator(colorList); + } + + private ColorGenerator(List colorList) { + mColors = colorList; + mRandom = new Random(System.currentTimeMillis()); + } + + public int getRandomColor() { + return mColors.get(mRandom.nextInt(mColors.size())); + } + + public int getColor(Object key) { + return mColors.get(Math.abs(key.hashCode()) % mColors.size()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/AccountNamePosition.java b/app/src/main/java/com/beemdevelopment/aegis/AccountNamePosition.java new file mode 100644 index 0000000000..bbc4a072da --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/AccountNamePosition.java @@ -0,0 +1,17 @@ +package com.beemdevelopment.aegis; + +public enum AccountNamePosition { + HIDDEN, + END, + BELOW; + + private static AccountNamePosition[] _values; + + static { + _values = values(); + } + + public static AccountNamePosition fromInteger(int x) { + return _values[x]; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/AegisApplication.java b/app/src/main/java/com/beemdevelopment/aegis/AegisApplication.java index 9411d4e808..b1c3e22325 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/AegisApplication.java +++ b/app/src/main/java/com/beemdevelopment/aegis/AegisApplication.java @@ -1,239 +1,8 @@ package com.beemdevelopment.aegis; -import android.app.Application; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; -import android.graphics.drawable.Icon; -import android.os.Build; +import dagger.hilt.android.HiltAndroidApp; -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; -import androidx.lifecycle.Lifecycle; -import androidx.lifecycle.LifecycleEventObserver; -import androidx.lifecycle.LifecycleOwner; -import androidx.lifecycle.ProcessLifecycleOwner; +@HiltAndroidApp +public class AegisApplication extends AegisApplicationBase { -import com.beemdevelopment.aegis.icons.IconPackManager; -import com.beemdevelopment.aegis.services.NotificationService; -import com.beemdevelopment.aegis.ui.MainActivity; -import com.beemdevelopment.aegis.util.IOUtils; -import com.beemdevelopment.aegis.vault.Vault; -import com.beemdevelopment.aegis.vault.VaultFile; -import com.beemdevelopment.aegis.vault.VaultFileCredentials; -import com.beemdevelopment.aegis.vault.VaultManager; -import com.beemdevelopment.aegis.vault.VaultManagerException; -import com.mikepenz.iconics.Iconics; -import com.mikepenz.material_design_iconic_typeface_library.MaterialDesignIconic; -import com.topjohnwu.superuser.Shell; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -public class AegisApplication extends Application { - private VaultFile _vaultFile; - private VaultManager _manager; - private Preferences _prefs; - private List _lockListeners; - private boolean _blockAutoLock; - private IconPackManager _iconPackManager; - - private static final String CODE_LOCK_STATUS_ID = "lock_status_channel"; - private static final String CODE_LOCK_VAULT_ACTION = "lock_vault"; - - static { - // to access other app's internal storage directory, run libsu commands inside the global mount namespace - Shell.setDefaultBuilder(Shell.Builder.create().setFlags(Shell.FLAG_MOUNT_MASTER)); - } - - @Override - public void onCreate() { - super.onCreate(); - _prefs = new Preferences(this); - _lockListeners = new ArrayList<>(); - _iconPackManager = new IconPackManager(this); - - Iconics.init(this); - Iconics.registerFont(new MaterialDesignIconic()); - - // listen for SCREEN_OFF events - ScreenOffReceiver receiver = new ScreenOffReceiver(); - IntentFilter intentFilter = new IntentFilter(); - intentFilter.addAction(Intent.ACTION_SCREEN_OFF); - intentFilter.addAction(CODE_LOCK_VAULT_ACTION); - registerReceiver(receiver, intentFilter); - - // lock the app if the user moves the application to the background - ProcessLifecycleOwner.get().getLifecycle().addObserver(new AppLifecycleObserver()); - - // clear the cache directory on startup, to make sure no temporary vault export files remain - IOUtils.clearDirectory(getCacheDir(), false); - - if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - initAppShortcuts(); - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - initNotificationChannels(); - } - } - - public boolean isVaultLocked() { - return _manager == null; - } - - /** - * Loads the vault file from disk at the default location, stores an internal - * reference to it for future use and returns it. This must only be called before - * initVaultManager() or after lock(). - */ - public VaultFile loadVaultFile() throws VaultManagerException { - if (!isVaultLocked()) { - throw new AssertionError("loadVaultFile() may only be called before initVaultManager() or after lock()"); - } - - if (_vaultFile == null) { - _vaultFile = VaultManager.readVaultFile(this); - } - - return _vaultFile; - } - - /** - * Initializes the vault manager by decrypting the given vaultFile with the given - * creds. This removes the internal reference to the raw vault file. - */ - public VaultManager initVaultManager(VaultFile vaultFile, VaultFileCredentials creds) throws VaultManagerException { - _vaultFile = null; - _manager = VaultManager.init(this, vaultFile, creds); - return _manager; - } - - /** - * Initializes the vault manager with the given vault and creds. This removes the - * internal reference to the raw vault file. - */ - public VaultManager initVaultManager(Vault vault, VaultFileCredentials creds) { - _vaultFile = null; - _manager = new VaultManager(this, vault, creds); - return _manager; - } - - public VaultManager getVaultManager() { - return _manager; - } - - public IconPackManager getIconPackManager() { - return _iconPackManager; - } - - public Preferences getPreferences() { - return _prefs; - } - - public boolean isAutoLockEnabled(int autoLockType) { - return _prefs.isAutoLockTypeEnabled(autoLockType) && !isVaultLocked() && _manager.isEncryptionEnabled(); - } - - public void registerLockListener(LockListener listener) { - _lockListeners.add(listener); - } - - public void unregisterLockListener(LockListener listener) { - _lockListeners.remove(listener); - } - - /** - * Sets whether to block automatic lock on minimization. This should only be called - * by activities before invoking an intent that shows a DocumentsUI, because that - * action leads AppLifecycleObserver to believe that the app has been minimized. - */ - public void setBlockAutoLock(boolean block) { - _blockAutoLock = block; - } - - /** - * Locks the vault and the app. - * @param userInitiated whether or not the user initiated the lock in MainActivity. - */ - public void lock(boolean userInitiated) { - _manager.destroy(); - _manager = null; - - for (LockListener listener : _lockListeners) { - listener.onLocked(userInitiated); - } - - stopService(new Intent(AegisApplication.this, NotificationService.class)); - } - - @RequiresApi(api = Build.VERSION_CODES.N_MR1) - private void initAppShortcuts() { - ShortcutManager shortcutManager = getSystemService(ShortcutManager.class); - if (shortcutManager == null) { - return; - } - - Intent intent = new Intent(this, MainActivity.class); - intent.putExtra("action", "scan"); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - intent.setAction(Intent.ACTION_MAIN); - - ShortcutInfo shortcut = new ShortcutInfo.Builder(this, "shortcut_new") - .setShortLabel(getString(R.string.new_entry)) - .setLongLabel(getString(R.string.add_new_entry)) - .setIcon(Icon.createWithResource(this, R.drawable.ic_qr_code)) - .setIntent(intent) - .build(); - - shortcutManager.setDynamicShortcuts(Collections.singletonList(shortcut)); - } - - private void initNotificationChannels() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - CharSequence name = getString(R.string.channel_name_lock_status); - String description = getString(R.string.channel_description_lock_status); - int importance = NotificationManager.IMPORTANCE_LOW; - - NotificationChannel channel = new NotificationChannel(CODE_LOCK_STATUS_ID, name, importance); - channel.setDescription(description); - - NotificationManager notificationManager = getSystemService(NotificationManager.class); - notificationManager.createNotificationChannel(channel); - } - } - - private class AppLifecycleObserver implements LifecycleEventObserver { - @Override - public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) { - if (event == Lifecycle.Event.ON_STOP - && isAutoLockEnabled(Preferences.AUTO_LOCK_ON_MINIMIZE) - && !_blockAutoLock) { - lock(false); - } - } - } - - private class ScreenOffReceiver extends BroadcastReceiver { - @Override - public void onReceive(Context context, Intent intent) { - if (isAutoLockEnabled(Preferences.AUTO_LOCK_ON_DEVICE_LOCK)) { - lock(false); - } - } - } - - public interface LockListener { - /** - * When called, the app/vault has been locked and the listener should perform its cleanup operations. - * @param userInitiated whether or not the user initiated the lock in MainActivity. - */ - void onLocked(boolean userInitiated); - } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/AegisApplicationBase.java b/app/src/main/java/com/beemdevelopment/aegis/AegisApplicationBase.java new file mode 100644 index 0000000000..2d973fa721 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/AegisApplicationBase.java @@ -0,0 +1,121 @@ +package com.beemdevelopment.aegis; + +import android.app.Application; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.graphics.drawable.Icon; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleEventObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.ProcessLifecycleOwner; + +import com.beemdevelopment.aegis.receivers.VaultLockReceiver; +import com.beemdevelopment.aegis.ui.MainActivity; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultManager; +import com.topjohnwu.superuser.Shell; + +import java.util.Collections; + +import dagger.hilt.InstallIn; +import dagger.hilt.android.EarlyEntryPoint; +import dagger.hilt.android.EarlyEntryPoints; +import dagger.hilt.components.SingletonComponent; + +public abstract class AegisApplicationBase extends Application { + private static final String CODE_LOCK_STATUS_ID = "lock_status_channel"; + + private VaultManager _vaultManager; + + static { + // Enable verbose libsu logging in debug builds + Shell.enableVerboseLogging = BuildConfig.DEBUG; + } + + @Override + public void onCreate() { + super.onCreate(); + _vaultManager = EarlyEntryPoints.get(this, EntryPoint.class).getVaultManager(); + + VaultLockReceiver lockReceiver = new VaultLockReceiver(); + IntentFilter intentFilter = new IntentFilter(Intent.ACTION_SCREEN_OFF); + ContextCompat.registerReceiver(this, lockReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED); + + // lock the app if the user moves the application to the background + ProcessLifecycleOwner.get().getLifecycle().addObserver(new AppLifecycleObserver()); + + // clear the cache directory on startup, to make sure no temporary vault export files remain + IOUtils.clearDirectory(getCacheDir(), false); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + initAppShortcuts(); + } + + // NOTE: Disabled for now. See issue: #1047 + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + initNotificationChannels(); + }*/ + } + + @RequiresApi(api = Build.VERSION_CODES.N_MR1) + private void initAppShortcuts() { + ShortcutManager shortcutManager = getSystemService(ShortcutManager.class); + if (shortcutManager == null) { + return; + } + + Intent intent = new Intent(this, MainActivity.class); + intent.putExtra("action", "scan"); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.setAction(Intent.ACTION_MAIN); + + ShortcutInfo shortcut = new ShortcutInfo.Builder(this, "shortcut_new") + .setShortLabel(getString(R.string.new_entry)) + .setLongLabel(getString(R.string.add_new_entry)) + .setIcon(Icon.createWithResource(this, R.drawable.ic_qr_code)) + .setIntent(intent) + .build(); + + shortcutManager.setDynamicShortcuts(Collections.singletonList(shortcut)); + } + + private void initNotificationChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CharSequence name = getString(R.string.channel_name_lock_status); + String description = getString(R.string.channel_description_lock_status); + int importance = NotificationManager.IMPORTANCE_LOW; + + NotificationChannel channel = new NotificationChannel(CODE_LOCK_STATUS_ID, name, importance); + channel.setDescription(description); + + NotificationManager notificationManager = getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } + + private class AppLifecycleObserver implements LifecycleEventObserver { + @Override + public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) { + if (event == Lifecycle.Event.ON_STOP + && _vaultManager.isAutoLockEnabled(Preferences.AUTO_LOCK_ON_MINIMIZE) + && !_vaultManager.isAutoLockBlocked()) { + _vaultManager.lock(false); + } + } + } + + @EarlyEntryPoint + @InstallIn(SingletonComponent.class) + interface EntryPoint { + VaultManager getVaultManager(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/AegisBackupAgent.java b/app/src/main/java/com/beemdevelopment/aegis/AegisBackupAgent.java index deb8cc68c6..3a08e6e542 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/AegisBackupAgent.java +++ b/app/src/main/java/com/beemdevelopment/aegis/AegisBackupAgent.java @@ -8,24 +8,35 @@ import android.os.ParcelFileDescriptor; import android.util.Log; +import com.beemdevelopment.aegis.database.AppDatabase; +import com.beemdevelopment.aegis.database.AuditLogRepository; import com.beemdevelopment.aegis.util.IOUtils; -import com.beemdevelopment.aegis.vault.VaultManager; +import com.beemdevelopment.aegis.vault.VaultFile; +import com.beemdevelopment.aegis.vault.VaultRepository; +import com.beemdevelopment.aegis.vault.VaultRepositoryException; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; public class AegisBackupAgent extends BackupAgent { - private static final String TAG = BackupAgent.class.getSimpleName(); + private static final String TAG = AegisBackupAgent.class.getSimpleName(); private Preferences _prefs; + private AuditLogRepository _auditLogRepository; + @Override public void onCreate() { super.onCreate(); + + // Cannot use injection with Dagger Hilt here, because the app is launched in a restricted mode on restore _prefs = new Preferences(this); + AppDatabase appDatabase = AegisModule.provideAppDatabase(this); + _auditLogRepository = AegisModule.provideAuditLogRepository(appDatabase); } @Override @@ -34,34 +45,50 @@ public synchronized void onFullBackup(FullBackupDataOutput data) throws IOExcept Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ? data.getTransportFlags() : -1, Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? data.getQuota() : -1)); - if (!_prefs.isAndroidBackupsEnabled()) { + boolean isD2D = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + && (data.getTransportFlags() & FLAG_DEVICE_TO_DEVICE_TRANSFER) == FLAG_DEVICE_TO_DEVICE_TRANSFER; + + if (isD2D) { + Log.i(TAG, "onFullBackup(): allowing D2D transfer"); + } else if (!_prefs.isAndroidBackupsEnabled()) { Log.i(TAG, "onFullBackup() skipped: Android backups disabled in preferences"); return; } - // first copy the vault to the files/backup directory + // We perform a catch of any Exception here to make sure we also + // report any runtime exceptions, in addition to the expected IOExceptions. + try { + fullBackup(data); + _auditLogRepository.addAndroidBackupCreatedEvent(); + _prefs.setAndroidBackupResult(new Preferences.BackupResult(null)); + } catch (Exception e) { + Log.e(TAG, String.format("onFullBackup() failed: %s", e)); + _prefs.setAndroidBackupResult(new Preferences.BackupResult(e)); + throw e; + } + + Log.i(TAG, "onFullBackup() finished"); + } + + private void fullBackup(FullBackupDataOutput data) throws IOException { + // First copy the vault to the files/backup directory createBackupDir(); File vaultBackupFile = getVaultBackupFile(); - try (FileInputStream inStream = VaultManager.getAtomicFile(this).openRead(); - FileOutputStream outStream = new FileOutputStream(vaultBackupFile)) { - IOUtils.copy(inStream, outStream); - } catch (IOException e) { - Log.e(TAG, String.format("onFullBackup() failed: %s", e)); + try (OutputStream outputStream = new FileOutputStream(vaultBackupFile)) { + VaultFile vaultFile = VaultRepository.readVaultFile(this); + byte[] bytes = vaultFile.exportable().toBytes(); + outputStream.write(bytes); + } catch (VaultRepositoryException | IOException e) { deleteBackupDir(); - throw e; + throw new IOException(e); } - // then call the original implementation so that fullBackupContent specified in AndroidManifest is read + // Then call the original implementation so that fullBackupContent specified in AndroidManifest is read try { super.onFullBackup(data); - } catch (IOException e) { - Log.e(TAG, String.format("onFullBackup() failed: %s", e)); - throw e; } finally { deleteBackupDir(); } - - Log.i(TAG, "onFullBackup() finished"); } @Override @@ -72,7 +99,7 @@ public synchronized void onRestoreFile(ParcelFileDescriptor data, long size, Fil File vaultBackupFile = getVaultBackupFile(); if (destination.getCanonicalFile().equals(vaultBackupFile.getCanonicalFile())) { try (InputStream inStream = new FileInputStream(vaultBackupFile)) { - VaultManager.writeToFile(this, inStream); + VaultRepository.writeToFile(this, inStream); } catch (IOException e) { Log.e(TAG, String.format("onRestoreFile() failed: dest=%s, error=%s", destination, e)); throw e; @@ -102,17 +129,19 @@ public void onRestore(BackupDataInput data, int appVersionCode, ParcelFileDescri private void createBackupDir() throws IOException { File dir = getVaultBackupFile().getParentFile(); - if (!dir.exists() && !dir.mkdir()) { - throw new IOException(String.format("Unable to create backup directory: %s", dir.toString())); + if (dir == null || (!dir.exists() && !dir.mkdir())) { + throw new IOException(String.format("Unable to create backup directory: %s", dir)); } } private void deleteBackupDir() { File dir = getVaultBackupFile().getParentFile(); - IOUtils.clearDirectory(dir, true); + if (dir != null) { + IOUtils.clearDirectory(dir, true); + } } private File getVaultBackupFile() { - return new File(new File(getFilesDir(), "backup"), VaultManager.FILENAME); + return new File(new File(getFilesDir(), "backup"), VaultRepository.FILENAME); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/AegisModule.java b/app/src/main/java/com/beemdevelopment/aegis/AegisModule.java new file mode 100644 index 0000000000..bdaae0d486 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/AegisModule.java @@ -0,0 +1,55 @@ +package com.beemdevelopment.aegis; + +import android.content.Context; + +import androidx.room.Room; + +import com.beemdevelopment.aegis.database.AppDatabase; +import com.beemdevelopment.aegis.database.AuditLogDao; +import com.beemdevelopment.aegis.database.AuditLogRepository; +import com.beemdevelopment.aegis.icons.IconPackManager; +import com.beemdevelopment.aegis.vault.VaultManager; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; +import dagger.hilt.InstallIn; +import dagger.hilt.android.qualifiers.ApplicationContext; +import dagger.hilt.components.SingletonComponent; + +@Module +@InstallIn(SingletonComponent.class) +public class AegisModule { + @Provides + @Singleton + public static IconPackManager provideIconPackManager(@ApplicationContext Context context) { + return new IconPackManager(context); + } + + @Provides + @Singleton + public static AuditLogRepository provideAuditLogRepository(AppDatabase appDatabase) { + AuditLogDao auditLogDao = appDatabase.auditLogDao(); + return new AuditLogRepository(auditLogDao); + } + + @Provides + @Singleton + public static VaultManager provideVaultManager(@ApplicationContext Context context, AuditLogRepository auditLogRepository) { + return new VaultManager(context, auditLogRepository); + } + + @Provides + public static Preferences providePreferences(@ApplicationContext Context context) { + return new Preferences(context); + } + + @Provides + @Singleton + public static AppDatabase provideAppDatabase(@ApplicationContext Context context) { + return Room.databaseBuilder(context.getApplicationContext(), + AppDatabase.class, "aegis-db") + .build(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/BackupsVersioningStrategy.java b/app/src/main/java/com/beemdevelopment/aegis/BackupsVersioningStrategy.java new file mode 100644 index 0000000000..0e06954f54 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/BackupsVersioningStrategy.java @@ -0,0 +1,7 @@ +package com.beemdevelopment.aegis; + +public enum BackupsVersioningStrategy { + UNDEFINED, + MULTIPLE_BACKUPS, + SINGLE_BACKUP +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/CopyBehavior.java b/app/src/main/java/com/beemdevelopment/aegis/CopyBehavior.java new file mode 100644 index 0000000000..4b9b844e50 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/CopyBehavior.java @@ -0,0 +1,17 @@ +package com.beemdevelopment.aegis; + +public enum CopyBehavior { + NEVER, + SINGLETAP, + DOUBLETAP; + + private static CopyBehavior[] _values; + + static { + _values = values(); + } + + public static CopyBehavior fromInteger(int x) { + return _values[x]; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/EventType.java b/app/src/main/java/com/beemdevelopment/aegis/EventType.java new file mode 100644 index 0000000000..4588ee013a --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/EventType.java @@ -0,0 +1,42 @@ +package com.beemdevelopment.aegis; + +public enum EventType { + + VAULT_UNLOCKED, + VAULT_BACKUP_CREATED, + VAULT_ANDROID_BACKUP_CREATED, + VAULT_EXPORTED, + ENTRY_SHARED, + VAULT_UNLOCK_FAILED_PASSWORD, + VAULT_UNLOCK_FAILED_BIOMETRICS; + private static EventType[] _values; + + static { + _values = values(); + } + + public static EventType fromInteger(int x) { + return _values[x]; + } + + public static int getEventTitleRes(EventType eventType) { + switch (eventType) { + case VAULT_UNLOCKED: + return R.string.event_title_vault_unlocked; + case VAULT_BACKUP_CREATED: + return R.string.event_title_backup_created; + case VAULT_ANDROID_BACKUP_CREATED: + return R.string.event_title_android_backup_created; + case VAULT_EXPORTED: + return R.string.event_title_vault_exported; + case ENTRY_SHARED: + return R.string.event_title_entry_shared; + case VAULT_UNLOCK_FAILED_PASSWORD: + return R.string.event_title_vault_unlock_failed_password; + case VAULT_UNLOCK_FAILED_BIOMETRICS: + return R.string.event_title_vault_unlock_failed_biometrics; + default: + return R.string.event_unknown; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/GroupPlaceholderType.java b/app/src/main/java/com/beemdevelopment/aegis/GroupPlaceholderType.java new file mode 100644 index 0000000000..6169e808d2 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/GroupPlaceholderType.java @@ -0,0 +1,20 @@ +package com.beemdevelopment.aegis; + +public enum GroupPlaceholderType { + ALL, + NEW_GROUP, + NO_GROUP; + + public int getStringRes() { + switch (this) { + case ALL: + return R.string.all; + case NEW_GROUP: + return R.string.new_group; + case NO_GROUP: + return R.string.no_group; + default: + throw new IllegalArgumentException("Unexpected placeholder type: " + this); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/PassReminderFreq.java b/app/src/main/java/com/beemdevelopment/aegis/PassReminderFreq.java new file mode 100644 index 0000000000..56c9df3a6d --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/PassReminderFreq.java @@ -0,0 +1,56 @@ +package com.beemdevelopment.aegis; + +import androidx.annotation.StringRes; + +import java.util.concurrent.TimeUnit; + +public enum PassReminderFreq { + NEVER, + WEEKLY, + BIWEEKLY, + MONTHLY, + QUARTERLY; + + public long getDurationMillis() { + long weeks; + switch (this) { + case WEEKLY: + weeks = 1; + break; + case BIWEEKLY: + weeks = 2; + break; + case MONTHLY: + weeks = 4; + break; + case QUARTERLY: + weeks = 13; + break; + default: + weeks = 0; + break; + } + + return TimeUnit.MILLISECONDS.convert(weeks * 7L, TimeUnit.DAYS); + } + + @StringRes + public int getStringRes() { + switch (this) { + case WEEKLY: + return R.string.password_reminder_freq_weekly; + case BIWEEKLY: + return R.string.password_reminder_freq_biweekly; + case MONTHLY: + return R.string.password_reminder_freq_monthly; + case QUARTERLY: + return R.string.password_reminder_freq_quarterly; + default: + return R.string.password_reminder_freq_never; + } + } + + public static PassReminderFreq fromInteger(int i) { + return PassReminderFreq.values()[i]; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/Preferences.java b/app/src/main/java/com/beemdevelopment/aegis/Preferences.java index e5182c5f46..7ccda05fb4 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/Preferences.java +++ b/app/src/main/java/com/beemdevelopment/aegis/Preferences.java @@ -5,23 +5,30 @@ import android.content.res.Resources; import android.net.Uri; import android.os.Build; -import android.preference.PreferenceManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.provider.DocumentsContractCompat; +import androidx.preference.PreferenceManager; + +import com.beemdevelopment.aegis.util.JsonUtils; +import com.beemdevelopment.aegis.util.TimeUtils; +import com.beemdevelopment.aegis.vault.VaultBackupPermissionException; import org.json.JSONArray; import org.json.JSONException; +import org.json.JSONObject; import java.util.ArrayList; import java.util.Collections; - -import org.json.JSONObject; - import java.util.Date; -import java.util.List; import java.util.HashMap; +import java.util.HashSet; +import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.UUID; -import java.util.concurrent.TimeUnit; public class Preferences { public static final int AUTO_LOCK_OFF = 1 << 0; @@ -29,12 +36,26 @@ public class Preferences { public static final int AUTO_LOCK_ON_MINIMIZE = 1 << 2; public static final int AUTO_LOCK_ON_DEVICE_LOCK = 1 << 3; + public static final int SEARCH_IN_ISSUER = 1 << 0; + public static final int SEARCH_IN_NAME = 1 << 1; + public static final int SEARCH_IN_NOTE = 1 << 2; + public static final int SEARCH_IN_GROUPS = 1 << 3; + + public static final int BACKUPS_VERSIONS_INFINITE = -1; + public static final int[] AUTO_LOCK_SETTINGS = { AUTO_LOCK_ON_BACK_BUTTON, AUTO_LOCK_ON_MINIMIZE, AUTO_LOCK_ON_DEVICE_LOCK }; + public static final int[] SEARCH_BEHAVIOR_SETTINGS = { + SEARCH_IN_ISSUER, + SEARCH_IN_NAME, + SEARCH_IN_NOTE, + SEARCH_IN_GROUPS + }; + private SharedPreferences _prefs; public Preferences(Context context) { @@ -43,16 +64,40 @@ public Preferences(Context context) { if (getPasswordReminderTimestamp().getTime() == 0) { resetPasswordReminderTimestamp(); } + + migratePreferences(); + } + + public void migratePreferences() { + // Change copy on tap to copy behavior to new preference and delete the old key + String prefCopyOnTapKey = "pref_copy_on_tap"; + if (_prefs.contains(prefCopyOnTapKey)) { + + boolean isCopyOnTapEnabled = _prefs.getBoolean(prefCopyOnTapKey, false); + if (isCopyOnTapEnabled) { + setCopyBehavior(CopyBehavior.SINGLETAP); + } + + _prefs.edit().remove(prefCopyOnTapKey).apply(); + } } public boolean isTapToRevealEnabled() { return _prefs.getBoolean("pref_tap_to_reveal", false); } + public boolean isGroupMultiselectEnabled() { + return _prefs.getBoolean("pref_groups_multiselect", false); + } + public boolean isEntryHighlightEnabled() { return _prefs.getBoolean("pref_highlight_entry", false); } + public boolean isHapticFeedbackEnabled() { + return _prefs.getBoolean("pref_haptic_feedback", true); + } + public boolean isPauseFocusedEnabled() { boolean dependenciesEnabled = isTapToRevealEnabled() || isEntryHighlightEnabled(); if (!dependenciesEnabled) return false; @@ -63,39 +108,77 @@ public boolean isPanicTriggerEnabled() { return _prefs.getBoolean("pref_panic_trigger", false); } + public void setIsPanicTriggerEnabled(boolean enabled) { + _prefs.edit().putBoolean("pref_panic_trigger", enabled).apply(); + } + public boolean isSecureScreenEnabled() { // screen security should be enabled by default, but not for debug builds return _prefs.getBoolean("pref_secure_screen", !BuildConfig.DEBUG); } - public boolean isPasswordReminderEnabled() { - return _prefs.getBoolean("pref_password_reminder", true); + public PassReminderFreq getPasswordReminderFrequency() { + final String key = "pref_password_reminder_freq"; + if (_prefs.contains(key) || _prefs.getBoolean("pref_password_reminder", true)) { + int i = _prefs.getInt(key, PassReminderFreq.BIWEEKLY.ordinal()); + return PassReminderFreq.fromInteger(i); + } + + return PassReminderFreq.NEVER; + } + + public void setPasswordReminderFrequency(PassReminderFreq freq) { + _prefs.edit().putInt("pref_password_reminder_freq", freq.ordinal()).apply(); } public boolean isPasswordReminderNeeded() { - long diff = new Date().getTime() - getPasswordReminderTimestamp().getTime(); - long days = TimeUnit.DAYS.convert(diff, TimeUnit.MILLISECONDS); - return isPasswordReminderEnabled() && days >= 30; + return isPasswordReminderNeeded(new Date().getTime()); + } + + boolean isPasswordReminderNeeded(long currTime) { + PassReminderFreq freq = getPasswordReminderFrequency(); + if (freq == PassReminderFreq.NEVER) { + return false; + } + + long duration = currTime - getPasswordReminderTimestamp().getTime(); + return duration >= freq.getDurationMillis(); } public Date getPasswordReminderTimestamp() { return new Date(_prefs.getLong("pref_password_reminder_counter", 0)); } + void setPasswordReminderTimestamp(long timestamp) { + _prefs.edit().putLong("pref_password_reminder_counter", timestamp).apply(); + } + public void resetPasswordReminderTimestamp() { - _prefs.edit().putLong("pref_password_reminder_counter", new Date().getTime()).apply(); + setPasswordReminderTimestamp(new Date().getTime()); } - public boolean isAccountNameVisible() { - return _prefs.getBoolean("pref_account_name", true); + public boolean onlyShowNecessaryAccountNames() { return _prefs.getBoolean("pref_shared_issuer_account_name", false); } + + public boolean isIconVisible() { + return _prefs.getBoolean("pref_show_icons", true); } - public int getCodeGroupSize() { - if (_prefs.getBoolean("pref_code_group_size", false)) { - return 2; - } else { - return 3; - } + public boolean getShowNextCode() { + return _prefs.getBoolean("pref_show_next_code", false); + } + + public boolean getShowExpirationState() { + return _prefs.getBoolean("pref_expiration_state", true); + } + + public CodeGrouping getCodeGroupSize() { + String value = _prefs.getString("pref_code_group_size_string", "GROUPING_THREES"); + + return CodeGrouping.valueOf(value); + } + + public void setCodeGroupSize(CodeGrouping codeGroupSize) { + _prefs.edit().putString("pref_code_group_size_string", codeGroupSize.name()).apply(); } public boolean isIntroDone() { @@ -111,6 +194,20 @@ private int getAutoLockMask() { return _prefs.getInt("pref_auto_lock_mask", def); } + public int getSearchBehaviorMask() { + final int def = SEARCH_IN_ISSUER | SEARCH_IN_NAME; + + return _prefs.getInt("pref_search_behavior_mask", def); + } + + public boolean isSearchBehaviorTypeEnabled(int searchBehaviorType) { + return (getSearchBehaviorMask() & searchBehaviorType) == searchBehaviorType; + } + + public void setSearchBehaviorMask(int searchBehavior) { + _prefs.edit().putInt("pref_search_behavior_mask", searchBehavior).apply(); + } + public boolean isAutoLockEnabled() { return getAutoLockMask() != AUTO_LOCK_OFF; } @@ -151,6 +248,10 @@ public void setCurrentTheme(Theme theme) { _prefs.edit().putInt("pref_current_theme", theme.ordinal()).apply(); } + public boolean isDynamicColorsEnabled() { + return _prefs.getBoolean("pref_dynamic_colors", false); + } + public ViewMode getCurrentViewMode() { return ViewMode.fromInteger(_prefs.getInt("pref_current_view_mode", 0)); } @@ -159,6 +260,14 @@ public void setCurrentViewMode(ViewMode viewMode) { _prefs.edit().putInt("pref_current_view_mode", viewMode.ordinal()).apply(); } + public AccountNamePosition getAccountNamePosition() { + return AccountNamePosition.fromInteger(_prefs.getInt("pref_account_name_position", AccountNamePosition.END.ordinal())); + } + + public void setAccountNamePosition(AccountNamePosition accountNamePosition) { + _prefs.edit().putInt("pref_account_name_position", accountNamePosition.ordinal()).apply(); + } + public Integer getUsageCount(UUID uuid) { Integer usageCount = getUsageCounts().get(uuid); @@ -172,22 +281,61 @@ public void resetUsageCount(UUID uuid) { setUsageCount(usageCounts); } + public long getLastUsedTimestamp(UUID uuid) { + Map timestamps = getLastUsedTimestamps(); + if (timestamps != null && timestamps.size() > 0){ + Long timestamp = timestamps.get(uuid); + return timestamp != null ? timestamp : 0; + } + + return 0; + } + public void clearUsageCount() { _prefs.edit().remove("pref_usage_count").apply(); } + public Map getLastUsedTimestamps() { + Map lastUsedTimestamps = new HashMap<>(); + String lastUsedTimestamp = _prefs.getString("pref_last_used_timestamps", ""); + try { + JSONArray arr = new JSONArray(lastUsedTimestamp); + for (int i = 0; i < arr.length(); i++) { + JSONObject json = arr.getJSONObject(i); + lastUsedTimestamps.put(UUID.fromString(json.getString("uuid")), json.getLong("timestamp")); + } + } catch (JSONException ignored) { + } + + return lastUsedTimestamps; + } + + public void setLastUsedTimestamps(Map lastUsedTimestamps) { + JSONArray lastUsedTimestampJson = new JSONArray(); + for (Map.Entry entry : lastUsedTimestamps.entrySet()) { + JSONObject entryJson = new JSONObject(); + try { + entryJson.put("uuid", entry.getKey()); + entryJson.put("timestamp", entry.getValue()); + lastUsedTimestampJson.put(entryJson); + } catch (JSONException e) { + e.printStackTrace(); + } + } + + _prefs.edit().putString("pref_last_used_timestamps", lastUsedTimestampJson.toString()).apply(); + } + public Map getUsageCounts() { Map usageCounts = new HashMap<>(); String usageCount = _prefs.getString("pref_usage_count", ""); try { JSONArray arr = new JSONArray(usageCount); - for(int i = 0; i < arr.length(); i++) { + for (int i = 0; i < arr.length(); i++) { JSONObject json = arr.getJSONObject(i); usageCounts.put(UUID.fromString(json.getString("uuid")), json.getInt("count")); } - - } catch (JSONException e) { - e.printStackTrace(); + } catch (JSONException ignored) { } return usageCounts; @@ -213,8 +361,16 @@ public int getTimeout() { return _prefs.getInt("pref_timeout", -1); } + public String getLanguage() { + return _prefs.getString("pref_lang", "system"); + } + + public void setLanguage(String lang) { + _prefs.edit().putString("pref_lang", lang).apply(); + } + public Locale getLocale() { - String lang = _prefs.getString("pref_lang", "system"); + String lang = getLanguage(); if (lang.equals("system")) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { @@ -238,6 +394,7 @@ public boolean isAndroidBackupsEnabled() { public void setIsAndroidBackupsEnabled(boolean enabled) { _prefs.edit().putBoolean("pref_android_backups", enabled).apply(); + setAndroidBackupResult(null); } public boolean isBackupsEnabled() { @@ -246,6 +403,15 @@ public boolean isBackupsEnabled() { public void setIsBackupsEnabled(boolean enabled) { _prefs.edit().putBoolean("pref_backups", enabled).apply(); + setBuiltInBackupResult(null); + } + + public boolean isBackupReminderEnabled() { + return _prefs.getBoolean("pref_backup_reminder", true); + } + + public void setIsBackupReminderEnabled(boolean enabled) { + _prefs.edit().putBoolean("pref_backup_reminder", enabled).apply(); } public Uri getBackupsLocation() { @@ -265,6 +431,36 @@ public void setFocusSearch(boolean enabled) { _prefs.edit().putBoolean("pref_focus_search", enabled).apply(); } + public void setLatestExportTimeNow() { + _prefs.edit().putLong("pref_export_latest", new Date().getTime()).apply(); + setIsBackupReminderNeeded(false); + } + + public Date getLatestBackupOrExportTime() { + List dates = new ArrayList<>(); + + long l = _prefs.getLong("pref_export_latest", 0); + if (l > 0) { + dates.add(new Date(l)); + } + + BackupResult builtinRes = getBuiltInBackupResult(); + if (builtinRes != null) { + dates.add(builtinRes.getTime()); + } + + BackupResult androidRes = getAndroidBackupResult(); + if (androidRes != null) { + dates.add(androidRes.getTime()); + } + + if (dates.size() == 0) { + return null; + } + + return Collections.max(dates, Date::compareTo); + } + public void setBackupsLocation(Uri location) { _prefs.edit().putString("pref_backups_location", location == null ? null : location.toString()).apply(); } @@ -277,12 +473,91 @@ public void setBackupsVersionCount(int versions) { _prefs.edit().putInt("pref_backups_versions", versions).apply(); } - public void setBackupsError(Exception e) { - _prefs.edit().putString("pref_backups_error", e == null ? null : e.toString()).apply(); + public void setAndroidBackupResult(@Nullable BackupResult res) { + setBackupResult(false, res); + } + + public void setBuiltInBackupResult(@Nullable BackupResult res) { + setBackupResult(true, res); + } + + @Nullable + public BackupResult getAndroidBackupResult() { + return getBackupResult(false); + } + + @Nullable + public BackupResult getBuiltInBackupResult() { + return getBackupResult(true); + } + + @Nullable + public Preferences.BackupResult getErroredBackupResult() { + Preferences.BackupResult res = getBuiltInBackupResult(); + if (res != null && !res.isSuccessful()) { + return res; + } + res = getAndroidBackupResult(); + if (res != null && !res.isSuccessful()) { + return res; + } + return null; + } + + private void setBackupResult(boolean isBuiltInBackup, @Nullable BackupResult res) { + String json = null; + if (res != null) { + res.setIsBuiltIn(isBuiltInBackup); + json = res.toJson(); + } + _prefs.edit().putString(getBackupResultKey(isBuiltInBackup), json).apply(); + } + + @Nullable + private BackupResult getBackupResult(boolean isBuiltInBackup) { + String json = _prefs.getString(getBackupResultKey(isBuiltInBackup), null); + if (json == null) { + return null; + } + + try { + BackupResult res = BackupResult.fromJson(json); + res.setIsBuiltIn(isBuiltInBackup); + return res; + } catch (JSONException e) { + return null; + } + } + + private static String getBackupResultKey(boolean isBuiltInBackup) { + return isBuiltInBackup ? "pref_backups_result_builtin": "pref_backups_result_android"; + } + + public void setIsBackupReminderNeeded(boolean needed) { + if (isBackupsReminderNeeded() != needed) { + _prefs.edit().putBoolean("pref_backups_reminder_needed", needed).apply(); + } + } + + public boolean isBackupsReminderNeeded() { + return _prefs.getBoolean("pref_backups_reminder_needed", false); + } + + public void setIsPlaintextBackupWarningNeeded(boolean needed) { + _prefs.edit().putBoolean("pref_plaintext_backup_warning_needed", needed).apply(); + } + + public boolean isPlaintextBackupWarningNeeded() { + return !isPlaintextBackupWarningDisabled() + && _prefs.getBoolean("pref_plaintext_backup_warning_needed", false); + } + + public void setIsPlaintextBackupWarningDisabled(boolean disabled) { + _prefs.edit().putBoolean("pref_plaintext_backup_warning_disabled", disabled).apply(); } - public String getBackupsError() { - return _prefs.getString("pref_backups_error", null); + public boolean isPlaintextBackupWarningDisabled() { + return _prefs.getBoolean("pref_plaintext_backup_warning_disabled", false); } public boolean isPinKeyboardEnabled() { @@ -297,30 +572,136 @@ public void setIsTimeSyncWarningEnabled(boolean enabled) { _prefs.edit().putBoolean("pref_warn_time_sync", enabled).apply(); } - public boolean isCopyOnTapEnabled() { - return _prefs.getBoolean("pref_copy_on_tap", false); + public CopyBehavior getCopyBehavior() { + return CopyBehavior.fromInteger(_prefs.getInt("pref_current_copy_behavior", 0)); + } + + public void setCopyBehavior(CopyBehavior copyBehavior) { + _prefs.edit().putInt("pref_current_copy_behavior", copyBehavior.ordinal()).apply(); } - public void setGroupFilter(List groupFilter) { + public boolean isMinimizeOnCopyEnabled() { + return _prefs.getBoolean("pref_minimize_on_copy", false); + } + + public void setGroupFilter(Set groupFilter) { JSONArray json = new JSONArray(groupFilter); - _prefs.edit().putString("pref_group_filter", json.toString()).apply(); + _prefs.edit().putString("pref_group_filter_uuids", json.toString()).apply(); } - public List getGroupFilter() { - String raw = _prefs.getString("pref_group_filter", null); + public Set getGroupFilter() { + String raw = _prefs.getString("pref_group_filter_uuids", null); if (raw == null || raw.isEmpty()) { - return Collections.emptyList(); + return Collections.emptySet(); } try { JSONArray json = new JSONArray(raw); - List filter = new ArrayList<>(); + Set filter = new HashSet<>(); for (int i = 0; i < json.length(); i++) { - filter.add(json.getString(i)); + filter.add(json.isNull(i) ? null : UUID.fromString(json.getString(i))); } return filter; } catch (JSONException e) { - return Collections.emptyList(); + return Collections.emptySet(); + } + } + + @NonNull + public BackupsVersioningStrategy getBackupVersioningStrategy() { + Uri uri = getBackupsLocation(); + if (uri == null) { + return BackupsVersioningStrategy.UNDEFINED; + } + if (DocumentsContractCompat.isTreeUri(uri)) { + return BackupsVersioningStrategy.MULTIPLE_BACKUPS; + } else { + return BackupsVersioningStrategy.SINGLE_BACKUP; + } + } + + public static class BackupResult { + private final Date _time; + private boolean _isBuiltIn; + private final String _error; + private final boolean _isPermissionError; + + public BackupResult(@Nullable Exception e) { + this(new Date(), e == null ? null : e.toString(), e instanceof VaultBackupPermissionException); + } + + private BackupResult(Date time, @Nullable String error, boolean isPermissionError) { + _time = time; + _error = error; + _isPermissionError = isPermissionError; + } + + @Nullable + public String getError() { + return _error; + } + + public boolean isSuccessful() { + return _error == null; + } + + public Date getTime() { + return _time; + } + + public String getElapsedSince(Context context) { + return TimeUtils.getElapsedSince(context, _time); + } + + public boolean isBuiltIn() { + return _isBuiltIn; + } + + private void setIsBuiltIn(boolean isBuiltIn) { + _isBuiltIn = isBuiltIn; + } + + public boolean isPermissionError() { + return _isPermissionError; + } + + public String toJson() { + JSONObject obj = new JSONObject(); + + try { + obj.put("time", _time.getTime()); + obj.put("error", _error == null ? JSONObject.NULL : _error); + obj.put("isPermissionError", _isPermissionError); + } catch (JSONException e) { + throw new RuntimeException(e); + } + + return obj.toString(); + } + + public static BackupResult fromJson(String json) throws JSONException { + JSONObject obj = new JSONObject(json); + long time = obj.getLong("time"); + String error = JsonUtils.optString(obj, "error"); + boolean isPermissionError = obj.optBoolean("isPermissionError"); + return new BackupResult(new Date(time), error, isPermissionError); + } + } + + public enum CodeGrouping { + HALVES(-1), + NO_GROUPING(-2), + GROUPING_TWOS(2), + GROUPING_THREES(3), + GROUPING_FOURS(4); + + private final int _value; + CodeGrouping(int value) { + _value = value; + } + + public int getValue() { + return _value; } } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/SortCategory.java b/app/src/main/java/com/beemdevelopment/aegis/SortCategory.java index 5b3a9a906c..a7b3f6647a 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/SortCategory.java +++ b/app/src/main/java/com/beemdevelopment/aegis/SortCategory.java @@ -1,5 +1,6 @@ package com.beemdevelopment.aegis; +import com.beemdevelopment.aegis.helpers.comparators.LastUsedComparator; import com.beemdevelopment.aegis.helpers.comparators.UsageCountComparator; import com.beemdevelopment.aegis.vault.VaultEntry; import com.beemdevelopment.aegis.helpers.comparators.AccountNameComparator; @@ -14,7 +15,8 @@ public enum SortCategory { ACCOUNT_REVERSED, ISSUER, ISSUER_REVERSED, - USAGE_COUNT; + USAGE_COUNT, + LAST_USED; private static SortCategory[] _values; @@ -31,20 +33,22 @@ public Comparator getComparator() { switch (this) { case ACCOUNT: - comparator = new AccountNameComparator(); + comparator = new AccountNameComparator().thenComparing(new IssuerNameComparator()); break; case ACCOUNT_REVERSED: - comparator = Collections.reverseOrder(new AccountNameComparator()); + comparator = Collections.reverseOrder(new AccountNameComparator().thenComparing(new IssuerNameComparator())); break; case ISSUER: - comparator = new IssuerNameComparator(); + comparator = new IssuerNameComparator().thenComparing(new AccountNameComparator()); break; case ISSUER_REVERSED: - comparator = Collections.reverseOrder(new IssuerNameComparator()); + comparator = Collections.reverseOrder(new IssuerNameComparator().thenComparing(new AccountNameComparator())); break; case USAGE_COUNT: comparator = Collections.reverseOrder(new UsageCountComparator()); break; + case LAST_USED: + comparator = Collections.reverseOrder(new LastUsedComparator()); } return comparator; @@ -64,6 +68,8 @@ public int getMenuItem() { return R.id.menu_sort_alphabetically_reverse; case USAGE_COUNT: return R.id.menu_sort_usage_count; + case LAST_USED: + return R.id.menu_sort_last_used; default: return R.id.menu_sort_custom; } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ThemeMap.java b/app/src/main/java/com/beemdevelopment/aegis/ThemeMap.java index 65b77625e4..26859bc640 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ThemeMap.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ThemeMap.java @@ -10,20 +10,8 @@ private ThemeMap() { } public static final Map DEFAULT = ImmutableMap.of( - Theme.LIGHT, R.style.Theme_Aegis_Light_Default, - Theme.DARK, R.style.Theme_Aegis_Dark_Default, - Theme.AMOLED, R.style.Theme_Aegis_TrueDark_Default - ); - - public static final Map NO_ACTION_BAR = ImmutableMap.of( - Theme.LIGHT, R.style.Theme_Aegis_Light_NoActionBar, - Theme.DARK, R.style.Theme_Aegis_Dark_NoActionBar, - Theme.AMOLED, R.style.Theme_Aegis_TrueDark_NoActionBar - ); - - public static final Map FULLSCREEN = ImmutableMap.of( - Theme.LIGHT, R.style.Theme_Aegis_Light_Fullscreen, - Theme.DARK, R.style.Theme_Aegis_Dark_Fullscreen, - Theme.AMOLED, R.style.Theme_Aegis_TrueDark_Fullscreen + Theme.LIGHT, R.style.Theme_Aegis_Light, + Theme.DARK, R.style.Theme_Aegis_Dark, + Theme.AMOLED, R.style.Theme_Aegis_Amoled ); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/VibrationPatterns.java b/app/src/main/java/com/beemdevelopment/aegis/VibrationPatterns.java new file mode 100644 index 0000000000..6e8de25f8b --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/VibrationPatterns.java @@ -0,0 +1,12 @@ +package com.beemdevelopment.aegis; + +import java.util.Arrays; + +public class VibrationPatterns { + public static final long[] EXPIRING = {475, 20, 5, 20, 965, 20, 5, 20, 965, 20, 5, 20, 420}; + public static final long[] REFRESH_CODE = {0, 100}; + + public static long getLengthInMillis(long[] pattern) { + return Arrays.stream(pattern).sum(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/ViewMode.java b/app/src/main/java/com/beemdevelopment/aegis/ViewMode.java index e1e4e6941e..a962aa5c96 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ViewMode.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ViewMode.java @@ -5,7 +5,8 @@ public enum ViewMode { NORMAL, COMPACT, - SMALL; + SMALL, + TILES; private static ViewMode[] _values; @@ -26,19 +27,39 @@ public int getLayoutId() { return R.layout.card_entry_compact; case SMALL: return R.layout.card_entry_small; + case TILES: + return R.layout.card_entry_tile; default: return R.layout.card_entry; } } /** - * Retrieves the height (in dp) that the divider between entries should have in this view mode. + * Retrieves the offset (in dp) that should exist between entries in this view mode. */ - public float getDividerHeight() { + public float getItemOffset() { if (this == ViewMode.COMPACT) { - return 0; + return 1; + } else if (this == ViewMode.TILES) { + return 4; } - return 20; + return 8; + } + + public int getSpanCount() { + if (this == ViewMode.TILES) { + return 2; + } + + return 1; + } + + public String getFormattedAccountName(String accountName) { + if (this == ViewMode.TILES) { + return accountName; + } + + return String.format("(%s)", accountName); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/CryptoUtils.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/CryptoUtils.java index 20621e562c..00b2bd6129 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/crypto/CryptoUtils.java +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/CryptoUtils.java @@ -1,8 +1,6 @@ package com.beemdevelopment.aegis.crypto; -import android.os.Build; - -import org.bouncycastle.crypto.generators.SCrypt; +import com.beemdevelopment.aegis.crypto.bc.SCrypt; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -23,7 +21,6 @@ import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; public class CryptoUtils { @@ -66,13 +63,7 @@ private static Cipher createCipher(SecretKey key, int opmode, byte[] nonce) // generate the nonce if none is given // we are not allowed to do this ourselves as "setRandomizedEncryptionRequired" is set to true if (nonce != null) { - AlgorithmParameterSpec spec; - // apparently kitkat doesn't support GCMParameterSpec - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { - spec = new IvParameterSpec(nonce); - } else { - spec = new GCMParameterSpec(CRYPTO_AEAD_TAG_SIZE * 8, nonce); - } + AlgorithmParameterSpec spec = new GCMParameterSpec(CRYPTO_AEAD_TAG_SIZE * 8, nonce); cipher.init(opmode, key, spec); } else { cipher.init(opmode, key); diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/KeyStoreHandle.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/KeyStoreHandle.java index 5cb1c89b23..6ed1c05c8b 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/crypto/KeyStoreHandle.java +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/KeyStoreHandle.java @@ -1,11 +1,8 @@ package com.beemdevelopment.aegis.crypto; -import android.os.Build; import android.security.keystore.KeyGenParameterSpec; import android.security.keystore.KeyProperties; -import androidx.annotation.RequiresApi; - import java.io.IOException; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; @@ -45,10 +42,6 @@ public boolean containsKey(String id) throws KeyStoreHandleException { } public SecretKey generateKey(String id) throws KeyStoreHandleException { - if (!isSupported()) { - throw new KeyStoreHandleException("Symmetric KeyStore keys are not supported in this version of Android"); - } - try { KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, STORE_NAME); generator.init(new KeyGenParameterSpec.Builder(id, @@ -87,14 +80,13 @@ public SecretKey getKey(String id) throws KeyStoreHandleException { throw new KeyStoreHandleException(e); } - if (isSupported() && isKeyPermanentlyInvalidated(key)) { + if (isKeyPermanentlyInvalidated(key)) { return null; } return key; } - @RequiresApi(api = Build.VERSION_CODES.M) private static boolean isKeyPermanentlyInvalidated(SecretKey key) { // try to initialize a dummy cipher and see if an InvalidKeyException is thrown try { @@ -127,8 +119,4 @@ public void clear() throws KeyStoreHandleException { throw new KeyStoreHandleException(e); } } - - public static boolean isSupported() { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M; - } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/bc/SCrypt.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/bc/SCrypt.java new file mode 100644 index 0000000000..068362a186 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/bc/SCrypt.java @@ -0,0 +1,255 @@ +/* +Copyright (c) 2000-2021 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + */ + +package com.beemdevelopment.aegis.crypto.bc; + +import org.bouncycastle.crypto.PBEParametersGenerator; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.util.Arrays; +import org.bouncycastle.util.Integers; +import org.bouncycastle.util.Pack; + +/** + * Implementation of the scrypt a password-based key derivation function. + *

    + * Scrypt was created by Colin Percival and is specified in RFC 7914 - The scrypt Password-Based Key Derivation Function + */ +public class SCrypt +{ + private SCrypt() + { + // not used. + } + + /** + * Generate a key using the scrypt key derivation function. + * + * @param P the bytes of the pass phrase. + * @param S the salt to use for this invocation. + * @param N CPU/Memory cost parameter. Must be larger than 1, a power of 2 and less than + * 2^(128 * r / 8). + * @param r the block size, must be >= 1. + * @param p Parallelization parameter. Must be a positive integer less than or equal to + * Integer.MAX_VALUE / (128 * r * 8). + * @param dkLen the length of the key to generate. + * @return the generated key. + */ + public static byte[] generate(byte[] P, byte[] S, int N, int r, int p, int dkLen) + { + if (P == null) + { + throw new IllegalArgumentException("Passphrase P must be provided."); + } + if (S == null) + { + throw new IllegalArgumentException("Salt S must be provided."); + } + if (N <= 1 || !isPowerOf2(N)) + { + throw new IllegalArgumentException("Cost parameter N must be > 1 and a power of 2"); + } + // Only value of r that cost (as an int) could be exceeded for is 1 + if (r == 1 && N >= 65536) + { + throw new IllegalArgumentException("Cost parameter N must be > 1 and < 65536."); + } + if (r < 1) + { + throw new IllegalArgumentException("Block size r must be >= 1."); + } + int maxParallel = Integer.MAX_VALUE / (128 * r * 8); + if (p < 1 || p > maxParallel) + { + throw new IllegalArgumentException("Parallelisation parameter p must be >= 1 and <= " + maxParallel + + " (based on block size r of " + r + ")"); + } + if (dkLen < 1) + { + throw new IllegalArgumentException("Generated key length dkLen must be >= 1."); + } + return MFcrypt(P, S, N, r, p, dkLen); + } + + private static byte[] MFcrypt(byte[] P, byte[] S, int N, int r, int p, int dkLen) + { + int MFLenBytes = r * 128; + byte[] bytes = SingleIterationPBKDF2(P, S, p * MFLenBytes); + + int[] B = null; + + try + { + int BLen = bytes.length >>> 2; + B = new int[BLen]; + + Pack.littleEndianToInt(bytes, 0, B); + + /* + * Chunk memory allocations; We choose 'd' so that there will be 2**d chunks, each not + * larger than 32KiB, except that the minimum chunk size is 2 * r * 32. + */ + int d = 0, total = N * r; + while ((N - d) > 2 && total > (1 << 10)) + { + ++d; + total >>>= 1; + } + + int MFLenWords = MFLenBytes >>> 2; + for (int BOff = 0; BOff < BLen; BOff += MFLenWords) + { + // TODO These can be done in parallel threads + SMix(B, BOff, N, d, r); + } + + Pack.intToLittleEndian(B, bytes, 0); + + return SingleIterationPBKDF2(P, bytes, dkLen); + } + finally + { + Clear(bytes); + Clear(B); + } + } + + private static byte[] SingleIterationPBKDF2(byte[] P, byte[] S, int dkLen) + { + PBEParametersGenerator pGen = new PKCS5S2ParametersGenerator(new SHA256Digest()); + pGen.init(P, S, 1); + KeyParameter key = (KeyParameter)pGen.generateDerivedMacParameters(dkLen * 8); + return key.getKey(); + } + + private static void SMix(int[] B, int BOff, int N, int d, int r) + { + int powN = Integers.numberOfTrailingZeros(N); + int blocksPerChunk = N >>> d; + int chunkCount = 1 << d, chunkMask = blocksPerChunk - 1, chunkPow = powN - d; + + int BCount = r * 32; + + int[] blockX1 = new int[16]; + int[] blockX2 = new int[16]; + int[] blockY = new int[BCount]; + + int[] X = new int[BCount]; + int[][] VV = new int[chunkCount][]; + + try + { + System.arraycopy(B, BOff, X, 0, BCount); + + for (int c = 0; c < chunkCount; ++c) + { + int[] V = new int[blocksPerChunk * BCount]; + VV[c] = V; + + int off = 0; + for (int i = 0; i < blocksPerChunk; i += 2) + { + System.arraycopy(X, 0, V, off, BCount); + off += BCount; + BlockMix(X, blockX1, blockX2, blockY, r); + System.arraycopy(blockY, 0, V, off, BCount); + off += BCount; + BlockMix(blockY, blockX1, blockX2, X, r); + } + } + + int mask = N - 1; + for (int i = 0; i < N; ++i) + { + int j = X[BCount - 16] & mask; + int[] V = VV[j >>> chunkPow]; + int VOff = (j & chunkMask) * BCount; + System.arraycopy(V, VOff, blockY, 0, BCount); + Xor(blockY, X, 0, blockY); + BlockMix(blockY, blockX1, blockX2, X, r); + } + + System.arraycopy(X, 0, B, BOff, BCount); + } + finally + { + ClearAll(VV); + ClearAll(new int[][]{X, blockX1, blockX2, blockY}); + } + } + + private static void BlockMix(int[] B, int[] X1, int[] X2, int[] Y, int r) + { + System.arraycopy(B, B.length - 16, X1, 0, 16); + + int BOff = 0, YOff = 0, halfLen = B.length >>> 1; + + for (int i = 2 * r; i > 0; --i) + { + Xor(X1, B, BOff, X2); + + Salsa20Engine.salsaCore(8, X2, X1); + System.arraycopy(X1, 0, Y, YOff, 16); + + YOff = halfLen + BOff - YOff; + BOff += 16; + } + } + + private static void Xor(int[] a, int[] b, int bOff, int[] output) + { + for (int i = output.length - 1; i >= 0; --i) + { + output[i] = a[i] ^ b[bOff + i]; + } + } + + private static void Clear(byte[] array) + { + if (array != null) + { + Arrays.fill(array, (byte)0); + } + } + + private static void Clear(int[] array) + { + if (array != null) + { + Arrays.fill(array, 0); + } + } + + private static void ClearAll(int[][] arrays) + { + for (int i = 0; i < arrays.length; ++i) + { + Clear(arrays[i]); + } + } + + // note: we know X is non-zero + private static boolean isPowerOf2(int x) + { + return ((x & (x - 1)) == 0); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/bc/Salsa20Engine.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/bc/Salsa20Engine.java new file mode 100644 index 0000000000..8709cab4dc --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/bc/Salsa20Engine.java @@ -0,0 +1,118 @@ +/* +Copyright (c) 2000-2021 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + */ + +package com.beemdevelopment.aegis.crypto.bc; + +/** + * Implementation of Daniel J. Bernstein's Salsa20 stream cipher, Snuffle 2005 + */ +public class Salsa20Engine { + private Salsa20Engine() + { + + } + + public static void salsaCore(int rounds, int[] input, int[] x) + { + if (input.length != 16) + { + throw new IllegalArgumentException(); + } + if (x.length != 16) + { + throw new IllegalArgumentException(); + } + if (rounds % 2 != 0) + { + throw new IllegalArgumentException("Number of rounds must be even"); + } + + int x00 = input[ 0]; + int x01 = input[ 1]; + int x02 = input[ 2]; + int x03 = input[ 3]; + int x04 = input[ 4]; + int x05 = input[ 5]; + int x06 = input[ 6]; + int x07 = input[ 7]; + int x08 = input[ 8]; + int x09 = input[ 9]; + int x10 = input[10]; + int x11 = input[11]; + int x12 = input[12]; + int x13 = input[13]; + int x14 = input[14]; + int x15 = input[15]; + + for (int i = rounds; i > 0; i -= 2) + { + x04 ^= Integer.rotateLeft(x00 + x12, 7); + x08 ^= Integer.rotateLeft(x04 + x00, 9); + x12 ^= Integer.rotateLeft(x08 + x04, 13); + x00 ^= Integer.rotateLeft(x12 + x08, 18); + x09 ^= Integer.rotateLeft(x05 + x01, 7); + x13 ^= Integer.rotateLeft(x09 + x05, 9); + x01 ^= Integer.rotateLeft(x13 + x09, 13); + x05 ^= Integer.rotateLeft(x01 + x13, 18); + x14 ^= Integer.rotateLeft(x10 + x06, 7); + x02 ^= Integer.rotateLeft(x14 + x10, 9); + x06 ^= Integer.rotateLeft(x02 + x14, 13); + x10 ^= Integer.rotateLeft(x06 + x02, 18); + x03 ^= Integer.rotateLeft(x15 + x11, 7); + x07 ^= Integer.rotateLeft(x03 + x15, 9); + x11 ^= Integer.rotateLeft(x07 + x03, 13); + x15 ^= Integer.rotateLeft(x11 + x07, 18); + + x01 ^= Integer.rotateLeft(x00 + x03, 7); + x02 ^= Integer.rotateLeft(x01 + x00, 9); + x03 ^= Integer.rotateLeft(x02 + x01, 13); + x00 ^= Integer.rotateLeft(x03 + x02, 18); + x06 ^= Integer.rotateLeft(x05 + x04, 7); + x07 ^= Integer.rotateLeft(x06 + x05, 9); + x04 ^= Integer.rotateLeft(x07 + x06, 13); + x05 ^= Integer.rotateLeft(x04 + x07, 18); + x11 ^= Integer.rotateLeft(x10 + x09, 7); + x08 ^= Integer.rotateLeft(x11 + x10, 9); + x09 ^= Integer.rotateLeft(x08 + x11, 13); + x10 ^= Integer.rotateLeft(x09 + x08, 18); + x12 ^= Integer.rotateLeft(x15 + x14, 7); + x13 ^= Integer.rotateLeft(x12 + x15, 9); + x14 ^= Integer.rotateLeft(x13 + x12, 13); + x15 ^= Integer.rotateLeft(x14 + x13, 18); + } + + x[ 0] = x00 + input[ 0]; + x[ 1] = x01 + input[ 1]; + x[ 2] = x02 + input[ 2]; + x[ 3] = x03 + input[ 3]; + x[ 4] = x04 + input[ 4]; + x[ 5] = x05 + input[ 5]; + x[ 6] = x06 + input[ 6]; + x[ 7] = x07 + input[ 7]; + x[ 8] = x08 + input[ 8]; + x[ 9] = x09 + input[ 9]; + x[10] = x10 + input[10]; + x[11] = x11 + input[11]; + x[12] = x12 + input[12]; + x[13] = x13 + input[13]; + x[14] = x14 + input[14]; + x[15] = x15 + input[15]; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/HOTP.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/HOTP.java index 5c5afec082..22dea18697 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/HOTP.java +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/HOTP.java @@ -14,6 +14,21 @@ private HOTP() { public static OTP generateOTP(byte[] secret, String algo, int digits, long counter) throws NoSuchAlgorithmException, InvalidKeyException { + byte[] hash = getHash(secret, algo, counter); + + // truncate hash to get the HTOP value + // http://tools.ietf.org/html/rfc4226#section-5.4 + int offset = hash[hash.length - 1] & 0xf; + int otp = ((hash[offset] & 0x7f) << 24) + | ((hash[offset + 1] & 0xff) << 16) + | ((hash[offset + 2] & 0xff) << 8) + | (hash[offset + 3] & 0xff); + + return new OTP(otp, digits); + } + + public static byte[] getHash(byte[] secret, String algo, long counter) + throws NoSuchAlgorithmException, InvalidKeyException { SecretKeySpec key = new SecretKeySpec(secret, "RAW"); // encode counter in big endian @@ -25,16 +40,6 @@ public static OTP generateOTP(byte[] secret, String algo, int digits, long count // calculate the hash of the counter Mac mac = Mac.getInstance(algo); mac.init(key); - byte[] hash = mac.doFinal(counterBytes); - - // truncate hash to get the HTOP value - // http://tools.ietf.org/html/rfc4226#section-5.4 - int offset = hash[hash.length - 1] & 0xf; - int otp = ((hash[offset] & 0x7f) << 24) - | ((hash[offset + 1] & 0xff) << 16) - | ((hash[offset + 2] & 0xff) << 8) - | (hash[offset + 3] & 0xff); - - return new OTP(otp, digits); + return mac.doFinal(counterBytes); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/MOTP.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/MOTP.java new file mode 100644 index 0000000000..2778e28963 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/MOTP.java @@ -0,0 +1,54 @@ +package com.beemdevelopment.aegis.crypto.otp; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import com.beemdevelopment.aegis.encoding.Hex; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class MOTP { + private final String _code; + private final int _digits; + + private MOTP(String code, int digits) { + _code = code; + _digits = digits; + } + + @NonNull + public static MOTP generateOTP(byte[] secret, String algo, int digits, int period, String pin) + throws NoSuchAlgorithmException { + + return generateOTP(secret, algo, digits, period, pin, System.currentTimeMillis() / 1000); + } + + @NonNull + public static MOTP generateOTP(byte[] secret, String algo, int digits, int period, String pin, long time) + throws NoSuchAlgorithmException { + + long timeBasedCounter = time / period; + String secretAsString = Hex.encode(secret); + String toDigest = timeBasedCounter + secretAsString + pin; + String code = getDigest(algo, toDigest.getBytes(StandardCharsets.UTF_8)); + + return new MOTP(code, digits); + } + + @VisibleForTesting + @NonNull + protected static String getDigest(String algo, byte[] toDigest) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance(algo); + byte[] digest = md.digest(toDigest); + + return Hex.encode(digest); + } + + @NonNull + @Override + public String toString() { + return _code.substring(0, _digits); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/OTP.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/OTP.java index 868074f927..9480f9729a 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/OTP.java +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/OTP.java @@ -5,8 +5,8 @@ public class OTP { private static final String STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY"; - private int _code; - private int _digits; + private final int _code; + private final int _digits; public OTP(int code, int digits) { _code = code; diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/YAOTP.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/YAOTP.java new file mode 100644 index 0000000000..676856c6de --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/YAOTP.java @@ -0,0 +1,71 @@ +package com.beemdevelopment.aegis.crypto.otp; + +import androidx.annotation.NonNull; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +public class YAOTP { + private static final int EN_ALPHABET_LENGTH = 26; + private final long _code; + private final int _digits; + + private YAOTP(long code, int digits) { + _code = code; + _digits = digits; + } + + public static YAOTP generateOTP(byte[] secret, String pin, int digits, String otpAlgo, long period) + throws NoSuchAlgorithmException, InvalidKeyException, IOException { + long seconds = System.currentTimeMillis() / 1000; + return generateOTP(secret, pin, digits, otpAlgo, period, seconds); + } + + public static YAOTP generateOTP(byte[] secret, String pin, int digits, String otpAlgo, long period, long seconds) + throws NoSuchAlgorithmException, InvalidKeyException, IOException { + byte[] pinWithHash; + byte[] pinBytes = pin.getBytes(StandardCharsets.UTF_8); + try (ByteArrayOutputStream stream = new ByteArrayOutputStream(pinBytes.length + secret.length)) { + stream.write(pinBytes); + stream.write(secret); + pinWithHash = stream.toByteArray(); + } + + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] keyHash = md.digest(pinWithHash); + if (keyHash[0] == 0) { + keyHash = Arrays.copyOfRange(keyHash, 1, keyHash.length); + } + + long counter = (long) Math.floor((double) seconds / period); + byte[] periodHash = HOTP.getHash(keyHash, otpAlgo, counter); + int offset = periodHash[periodHash.length - 1] & 0xf; + periodHash[offset] &= 0x7f; + long otp = ByteBuffer.wrap(periodHash) + .order(ByteOrder.BIG_ENDIAN) + .getLong(offset); + + return new YAOTP(otp, digits); + } + + @NonNull + @Override + public String toString() { + long code = _code % (long) Math.pow(EN_ALPHABET_LENGTH, _digits); + char[] chars = new char[_digits]; + + for (int i = _digits - 1; i >= 0; i--) { + chars[i] = (char) ('a' + (code % EN_ALPHABET_LENGTH)); + code /= EN_ALPHABET_LENGTH; + } + + return new String(chars); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/pins/GuardianProjectFDroidRSA4096.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/pins/GuardianProjectFDroidRSA4096.java deleted file mode 100644 index eb4a862996..0000000000 --- a/app/src/main/java/com/beemdevelopment/aegis/crypto/pins/GuardianProjectFDroidRSA4096.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.beemdevelopment.aegis.crypto.pins; - -import info.guardianproject.trustedintents.ApkSignaturePin; - -public final class GuardianProjectFDroidRSA4096 extends ApkSignaturePin { - - public GuardianProjectFDroidRSA4096() { - fingerprints = new String[]{ - "927f7e38b6acbecd84e02dace33efa9a7a2f0979750f28f585688ee38b3a4e28", - }; - certificates = new byte[][]{ - {48, -126, 3, 95, 48, -126, 2, 71, -96, 3, 2, 1, 2, 2, 4, 28, -30, 107, -102, 48, 13, 6, 9, 42, -122, 72, -122, -9, 13, 1, 1, 11, 5, 0, 48, 96, 49, 11, 48, 9, 6, 3, 85, 4, 6, 19, 2, 85, 75, 49, 12, 48, 10, 6, 3, 85, 4, 8, 19, 3, 79, 82, 71, 49, 12, 48, 10, 6, 3, 85, 4, 7, 19, 3, 79, 82, 71, 49, 19, 48, 17, 6, 3, 85, 4, 10, 19, 10, 102, 100, 114, 111, 105, 100, 46, 111, 114, 103, 49, 15, 48, 13, 6, 3, 85, 4, 11, 19, 6, 70, 68, 114, 111, 105, 100, 49, 15, 48, 13, 6, 3, 85, 4, 3, 19, 6, 70, 68, 114, 111, 105, 100, 48, 30, 23, 13, 49, 55, 49, 50, 48, 55, 49, 55, 51, 48, 52, 50, 90, 23, 13, 52, 53, 48, 52, 50, 52, 49, 55, 51, 48, 52, 50, 90, 48, 96, 49, 11, 48, 9, 6, 3, 85, 4, 6, 19, 2, 85, 75, 49, 12, 48, 10, 6, 3, 85, 4, 8, 19, 3, 79, 82, 71, 49, 12, 48, 10, 6, 3, 85, 4, 7, 19, 3, 79, 82, 71, 49, 19, 48, 17, 6, 3, 85, 4, 10, 19, 10, 102, 100, 114, 111, 105, 100, 46, 111, 114, 103, 49, 15, 48, 13, 6, 3, 85, 4, 11, 19, 6, 70, 68, 114, 111, 105, 100, 49, 15, 48, 13, 6, 3, 85, 4, 3, 19, 6, 70, 68, 114, 111, 105, 100, 48, -126, 1, 34, 48, 13, 6, 9, 42, -122, 72, -122, -9, 13, 1, 1, 1, 5, 0, 3, -126, 1, 15, 0, 48, -126, 1, 10, 2, -126, 1, 1, 0, -107, -115, -106, 1, -26, 72, -105, -99, 62, 3, -55, 34, 99, -112, -68, -20, -115, 31, 34, 118, -50, 12, -32, -59, 74, -58, -37, -87, 21, 105, 36, -82, 13, -51, 66, 4, 55, -111, 13, -46, -7, -69, -15, 36, 118, -7, 101, -86, 123, -83, -103, 110, 116, -54, 112, 46, 12, 96, -76, -48, -70, -33, -81, 52, 59, 73, 107, -126, -72, -25, 32, 93, 29, -20, 5, -41, -27, 123, -9, 104, -31, -59, -1, -83, -93, 99, 85, -116, -62, -55, 18, -63, 6, -51, -110, 33, 9, 7, -49, 102, -20, -122, -124, -68, 93, -102, 31, 48, 86, 96, -99, 105, -52, 95, 12, 57, 99, 12, -24, 70, 40, -99, -20, -21, -85, -70, -105, 95, 117, -31, 126, -126, -39, 46, -62, 59, -23, -74, 108, -12, -56, -40, -96, 79, -37, -82, 1, 99, -104, 48, -60, 92, 14, 109, 127, -22, 31, 115, -27, 108, 9, 92, 118, -45, 103, 117, 57, -50, -82, 114, -113, 68, -82, 87, 96, 111, 72, 65, -63, 12, 31, -34, -31, -55, -101, 101, 101, 59, 73, -119, -122, 82, 28, 47, -108, -85, 59, 46, 89, -93, -1, 9, -11, -51, 63, -44, 109, -76, -103, -26, -49, -80, 6, 52, -27, 73, -104, 40, 2, -101, -124, 60, -52, -105, -70, -24, -62, 88, 38, 53, -99, -92, 31, 119, 26, 79, 60, -124, 25, -115, -89, -115, -109, 0, 6, 122, -78, 116, 82, 3, 39, -67, 45, -43, 17, -39, 2, 3, 1, 0, 1, -93, 33, 48, 31, 48, 29, 6, 3, 85, 29, 14, 4, 22, 4, 20, 63, 109, -42, -109, 25, 22, 7, -37, -22, -41, -38, 58, -56, 2, -68, -38, -22, 65, -28, -60, 48, 13, 6, 9, 42, -122, 72, -122, -9, 13, 1, 1, 11, 5, 0, 3, -126, 1, 1, 0, 94, 17, 31, 36, 85, -11, 85, 44, 19, -80, -20, -92, -118, 93, 40, 45, 96, 31, -3, -37, -110, -96, 102, 81, 61, -74, -125, -117, -112, 58, -47, 17, 78, -18, 111, -116, 26, -91, 73, 100, 84, -99, 21, 87, 73, -106, 108, -51, -125, -21, 119, -88, -78, 2, 82, -109, -64, -9, -86, -112, -115, 66, -86, 46, 71, 107, -65, 96, -102, 47, 35, -45, -126, 33, 34, 121, -25, -85, -121, -56, -42, 22, -1, -95, -86, 81, 100, -70, 113, 104, -73, 22, -19, 79, -19, 52, 62, 42, 76, -112, 94, -34, 42, -57, -75, -90, -58, 118, 127, -106, -39, 108, -56, -79, 103, -33, 22, 3, 47, 103, -76, -81, 53, -22, -44, -26, -102, 63, -99, 39, 38, -108, 75, 33, 10, 25, -110, -125, -115, 114, -69, 73, -112, 36, 74, 77, -82, -44, 29, -123, -8, -117, 71, -105, 15, -109, 51, 22, 4, 80, 1, 43, 118, 121, -113, -70, 83, -56, 82, -110, 4, -63, 16, -57, 126, -70, 81, 73, 61, 2, -61, 24, -14, -10, 4, -21, 90, 24, 66, 41, -57, -60, -113, -18, -54, -1, 103, -75, 32, -64, 67, 103, 109, -79, -12, -113, -27, 114, 89, 116, 115, -13, -123, -70, 61, -41, -46, -118, 29, -105, -97, -75, 39, -51, 60, 88, 125, 55, -46, -95, 52, 57, 52, -115, 80, 44, 109, 119, -116, -62, -77, -74, -88, 41, 57, -65, -71, -115, -67, 23, 66, -21, 56, 51, -91, 109},}; - } -} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/database/AppDatabase.java b/app/src/main/java/com/beemdevelopment/aegis/database/AppDatabase.java new file mode 100644 index 0000000000..073bb2eb9b --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/database/AppDatabase.java @@ -0,0 +1,15 @@ +package com.beemdevelopment.aegis.database; + +import android.content.Context; + +import androidx.room.Database; +import androidx.room.Room; +import androidx.room.RoomDatabase; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Database(entities = {AuditLogEntry.class}, version = 1) +public abstract class AppDatabase extends RoomDatabase { + public abstract AuditLogDao auditLogDao(); +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogDao.java b/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogDao.java new file mode 100644 index 0000000000..20c4a47a6d --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogDao.java @@ -0,0 +1,17 @@ +package com.beemdevelopment.aegis.database; + +import androidx.lifecycle.LiveData; +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.Query; + +import java.util.List; + +@Dao +public interface AuditLogDao { + @Insert + void insert(AuditLogEntry log); + + @Query("SELECT * FROM audit_logs WHERE timestamp >= strftime('%s', 'now', '-30 days') ORDER BY timestamp DESC") + LiveData> getAll(); +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogEntry.java b/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogEntry.java new file mode 100644 index 0000000000..b0888eebd0 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogEntry.java @@ -0,0 +1,61 @@ +package com.beemdevelopment.aegis.database; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.Ignore; +import androidx.room.PrimaryKey; + +import com.beemdevelopment.aegis.EventType; + +@Entity(tableName = "audit_logs") +public class AuditLogEntry { + @PrimaryKey(autoGenerate = true) + protected long id; + + @NonNull + @ColumnInfo(name = "event_type") + private final EventType _eventType; + + @ColumnInfo(name = "reference") + private final String _reference; + + @ColumnInfo(name = "timestamp") + private final long _timestamp; + + @Ignore + public AuditLogEntry(@NonNull EventType eventType) { + this(eventType, null); + } + + @Ignore + public AuditLogEntry(@NonNull EventType eventType, @Nullable String reference) { + _eventType = eventType; + _reference = reference; + _timestamp = System.currentTimeMillis(); + } + + AuditLogEntry(long id, @NonNull EventType eventType, @Nullable String reference, long timestamp) { + this.id = id; + _eventType = eventType; + _reference = reference; + _timestamp = timestamp; + } + + public long getId() { + return id; + } + + public EventType getEventType() { + return _eventType; + } + + public String getReference() { + return _reference; + } + + public long getTimestamp() { + return _timestamp; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogRepository.java b/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogRepository.java new file mode 100644 index 0000000000..cde042ffcf --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogRepository.java @@ -0,0 +1,66 @@ +package com.beemdevelopment.aegis.database; + +import androidx.lifecycle.LiveData; + +import com.beemdevelopment.aegis.EventType; + +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public class AuditLogRepository { + private final AuditLogDao _auditLogDao; + private final Executor _executor; + + public AuditLogRepository(AuditLogDao auditLogDao) { + _auditLogDao = auditLogDao; + _executor = Executors.newSingleThreadExecutor(); + } + + public LiveData> getAllAuditLogEntries() { + return _auditLogDao.getAll(); + } + + public void addVaultUnlockedEvent() { + AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_UNLOCKED); + insert(auditLogEntry); + } + + public void addBackupCreatedEvent() { + AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_BACKUP_CREATED); + insert(auditLogEntry); + } + + public void addAndroidBackupCreatedEvent() { + AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_ANDROID_BACKUP_CREATED); + insert(auditLogEntry); + } + + public void addVaultExportedEvent() { + AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_EXPORTED); + insert(auditLogEntry); + } + + public void addEntrySharedEvent(String reference) { + AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.ENTRY_SHARED, reference); + insert(auditLogEntry); + } + + public void addVaultUnlockFailedPasswordEvent() { + AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_UNLOCK_FAILED_PASSWORD); + insert(auditLogEntry); + + } + + public void addVaultUnlockFailedBiometricsEvent() { + AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_UNLOCK_FAILED_BIOMETRICS); + insert(auditLogEntry); + } + + public void insert(AuditLogEntry auditLogEntry) { + _executor.execute(() -> { + _auditLogDao.insert(auditLogEntry); + }); + } + +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/encoding/Base32.java b/app/src/main/java/com/beemdevelopment/aegis/encoding/Base32.java index df30049f6c..85fe61faae 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/encoding/Base32.java +++ b/app/src/main/java/com/beemdevelopment/aegis/encoding/Base32.java @@ -2,6 +2,7 @@ import com.google.common.io.BaseEncoding; +import java.nio.charset.StandardCharsets; import java.util.Locale; public class Base32 { @@ -20,4 +21,9 @@ public static byte[] decode(String s) throws EncodingException { public static String encode(byte[] data) { return BaseEncoding.base32().omitPadding().encode(data); } + + public static String encode(String s) { + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + return encode(bytes); + } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/encoding/EncodingException.java b/app/src/main/java/com/beemdevelopment/aegis/encoding/EncodingException.java index ac75a34788..f85ec0ecea 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/encoding/EncodingException.java +++ b/app/src/main/java/com/beemdevelopment/aegis/encoding/EncodingException.java @@ -6,4 +6,8 @@ public class EncodingException extends IOException { public EncodingException(Throwable cause) { super(cause); } + + public EncodingException(String message) { + super(message); + } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/AnimationsHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/AnimationsHelper.java new file mode 100644 index 0000000000..98d8c36756 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/AnimationsHelper.java @@ -0,0 +1,54 @@ +package com.beemdevelopment.aegis.helpers; + +import android.content.Context; +import android.provider.Settings; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.view.animation.LayoutAnimationController; + +public class AnimationsHelper { + private AnimationsHelper() { + + } + + public static Animation loadScaledAnimation(Context context, int animationResId) { + return loadScaledAnimation(context, animationResId, Scale.ANIMATOR); + } + + public static Animation loadScaledAnimation(Context context, int animationResId, Scale scale) { + Animation animation = AnimationUtils.loadAnimation(context, animationResId); + long newDuration = (long) (animation.getDuration() * scale.getValue(context)); + animation.setDuration(newDuration); + return animation; + } + + public static LayoutAnimationController loadScaledLayoutAnimation(Context context, int animationResId) { + return loadScaledLayoutAnimation(context, animationResId, Scale.ANIMATOR); + } + + public static LayoutAnimationController loadScaledLayoutAnimation(Context context, int animationResId, Scale scale) { + LayoutAnimationController controller = AnimationUtils.loadLayoutAnimation(context, animationResId); + Animation animation = controller.getAnimation(); + animation.setDuration((long) (animation.getDuration() * scale.getValue(context))); + return controller; + } + + public enum Scale { + ANIMATOR(Settings.Global.ANIMATOR_DURATION_SCALE), + TRANSITION(Settings.Global.TRANSITION_ANIMATION_SCALE); + + private final String _setting; + + Scale(String setting) { + _setting = setting; + } + + public float getValue(Context context) { + return Settings.Global.getFloat(context.getContentResolver(), _setting, 1.0f); + } + + public boolean isZero(Context context) { + return getValue(context) == 0; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/BitmapHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/BitmapHelper.java index a97fb6a46c..339c1760f9 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/helpers/BitmapHelper.java +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/BitmapHelper.java @@ -1,6 +1,13 @@ package com.beemdevelopment.aegis.helpers; import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +import com.beemdevelopment.aegis.icons.IconType; +import com.beemdevelopment.aegis.vault.VaultEntryIcon; + +import java.io.ByteArrayOutputStream; +import java.util.Objects; public class BitmapHelper { private BitmapHelper() { @@ -28,4 +35,29 @@ public static Bitmap resize(Bitmap bitmap, int maxWidth, int maxHeight) { return Bitmap.createScaledBitmap(bitmap, width, height, true); } + + public static boolean isVaultEntryIconOptimized(VaultEntryIcon icon) { + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(icon.getBytes(), 0, icon.getBytes().length, opts); + return opts.outWidth <= VaultEntryIcon.MAX_DIMENS && opts.outHeight <= VaultEntryIcon.MAX_DIMENS; + } + + public static VaultEntryIcon toVaultEntryIcon(Bitmap bitmap, IconType iconType) { + if (bitmap.getWidth() > VaultEntryIcon.MAX_DIMENS + || bitmap.getHeight() > VaultEntryIcon.MAX_DIMENS) { + bitmap = resize(bitmap, VaultEntryIcon.MAX_DIMENS, VaultEntryIcon.MAX_DIMENS); + } + + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + if (Objects.equals(iconType, IconType.PNG)) { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); + } else { + iconType = IconType.JPEG; + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream); + } + + byte[] data = stream.toByteArray(); + return new VaultEntryIcon(data, iconType); + } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/CenterVerticalSpan.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/CenterVerticalSpan.java new file mode 100644 index 0000000000..bc94a21cfa --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/CenterVerticalSpan.java @@ -0,0 +1,30 @@ +package com.beemdevelopment.aegis.helpers; + +import android.graphics.Rect; +import android.text.TextPaint; +import android.text.style.MetricAffectingSpan; + +import androidx.annotation.NonNull; + +public class CenterVerticalSpan extends MetricAffectingSpan { + Rect _substringBounds; + + public CenterVerticalSpan(Rect substringBounds) { + _substringBounds = substringBounds; + } + + @Override + public void updateMeasureState(@NonNull TextPaint textPaint) { + applyBaselineShift(textPaint); + } + + @Override + public void updateDrawState(@NonNull TextPaint textPaint) { + applyBaselineShift(textPaint); + } + + private void applyBaselineShift(TextPaint textPaint) { + float topDifference = textPaint.getFontMetrics().top - _substringBounds.top; + textPaint.baselineShift -= (topDifference / 2f); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/FabMenuHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/FabMenuHelper.java new file mode 100644 index 0000000000..1dc75f75a3 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/FabMenuHelper.java @@ -0,0 +1,193 @@ +package com.beemdevelopment.aegis.helpers; + +import android.animation.ValueAnimator; +import android.graphics.Matrix; +import android.graphics.drawable.Drawable; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.OvershootInterpolator; +import android.widget.ImageView; + + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +public class FabMenuHelper { + private final static long ANIMATION_DURATION = 300L; + private final static long ANIMATION_ACTION_DELAY = 50L; + private final View _scrim; + private final View _menuItemsContainer; + private final FloatingActionButton _mainFab; + private final List _actions; + private Consumer _stateListener; + private boolean _isOpen = false; + + public FabMenuHelper( + View scrim, + ViewGroup menuItemsContainer, + FloatingActionButton fab, + Map actions + ) { + _scrim = scrim; + _menuItemsContainer = menuItemsContainer; + _mainFab = fab; + _actions = new ArrayList<>(actions.keySet()); + + for (View action : _actions) { + action.setVisibility(View.GONE); + action.setAlpha(0f); + action.setScaleX(0f); + action.setScaleY(0f); + } + + setupClickListeners(actions); + } + + public void setOnFabMenuStateChangeListener(Consumer listener) { + _stateListener = listener; + } + + private void setupClickListeners(Map actions) { + _mainFab.setOnClickListener(v -> toggle()); + _scrim.setOnClickListener(v -> close()); + + actions.forEach((action, onClick) -> { + action.setOnClickListener(v -> { + if (onClick != null) { + onClick.run(); + } + close(); + }); + }); + } + + public void toggle() { + if (_isOpen) { + close(); + } else { + open(); + } + } + + public void open() { + if (_isOpen) { + return; + } + + _isOpen = true; + + _scrim.animate() + .alpha(0.5f) + .setDuration(ANIMATION_DURATION) + .withStartAction(() -> _scrim.setVisibility(View.VISIBLE)) + .start(); + + _menuItemsContainer.setVisibility(View.VISIBLE); + + long delay = 0L; + for (int i = _actions.size() - 1; i >= 0; i--) { + animateActionIn(_actions.get(i), delay); + delay += ANIMATION_ACTION_DELAY; + } + + animateFabIconForward(_mainFab); + + if (_stateListener != null) { + _stateListener.accept(true); + } + } + + public void close() { + if (!_isOpen) { + return; + } + + _isOpen = false; + + _scrim.animate() + .alpha(0f) + .setDuration(ANIMATION_DURATION) + .withEndAction(() -> _scrim.setVisibility(View.GONE)) + .start(); + + long delay = 0L; + for (View action : _actions) { + animateActionOut(action, delay); + delay += ANIMATION_ACTION_DELAY; + } + + animateFabIconBackward(_mainFab); + + _mainFab.postDelayed(() -> { + if (!_isOpen) { + _menuItemsContainer.setVisibility(View.GONE); + } + }, ANIMATION_DURATION); + + if (_stateListener != null) { + _stateListener.accept(false); + } + } + + private void animateFabIconForward(FloatingActionButton fab) { + animateFabIcon(fab, 0f, 45f); + } + + private void animateFabIconBackward(FloatingActionButton fab) { + animateFabIcon(fab, 45f, 0f); + } + + private void animateFabIcon(FloatingActionButton fab, float from, float to) { + Drawable drawable = _mainFab.getDrawable(); + int width = drawable.getIntrinsicWidth(); + int height = drawable.getIntrinsicHeight(); + fab.setScaleType(ImageView.ScaleType.MATRIX); + Matrix matrix = new Matrix(); + ValueAnimator anim = ValueAnimator.ofFloat(from, to); + anim.setDuration(100L); + + anim.addUpdateListener(valueAnimator -> { + Float angle = (Float) valueAnimator.getAnimatedValue(); + matrix.reset(); + matrix.postRotate(angle, width / 2f, height / 2f); + fab.setImageMatrix(matrix); + }); + + anim.start(); + } + + private void animateActionIn(View action, long delay) { + action.setVisibility(View.VISIBLE); + action.setAlpha(0f); + action.setScaleX(0.4f); + action.setScaleY(0.4f); + + action.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setDuration(ANIMATION_DURATION) + .setStartDelay(delay) + .setInterpolator(new OvershootInterpolator(1.2f)) + .start(); + } + + private void animateActionOut(View action, long delay) { + action.animate() + .alpha(0f) + .scaleX(0f) + .scaleY(0f) + .setDuration(ANIMATION_DURATION) + .setStartDelay(delay) + .withEndAction(() -> action.setVisibility(View.GONE)) + .start(); + } + + public boolean isOpen() { + return _isOpen; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/FabScrollHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/FabScrollHelper.java index c5c7da6054..6092a0ebbb 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/helpers/FabScrollHelper.java +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/FabScrollHelper.java @@ -16,9 +16,9 @@ public FabScrollHelper(View floatingActionsMenu) { } public void onScroll(int dx, int dy) { - if (dy > 0 && _fabMenu.getVisibility() == View.VISIBLE && !_isAnimating) { + if (dy > 2 && _fabMenu.getVisibility() == View.VISIBLE && !_isAnimating) { setVisible(false); - } else if (dy < 0 && _fabMenu.getVisibility() != View.VISIBLE && !_isAnimating) { + } else if (dy < -2 && _fabMenu.getVisibility() != View.VISIBLE && !_isAnimating) { setVisible(true); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/IconViewHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/IconViewHelper.java deleted file mode 100644 index df740af406..0000000000 --- a/app/src/main/java/com/beemdevelopment/aegis/helpers/IconViewHelper.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.beemdevelopment.aegis.helpers; - -import android.os.Build; -import android.widget.ImageView; - -import com.beemdevelopment.aegis.icons.IconType; - -public class IconViewHelper { - private IconViewHelper() { - - } - - /** - * Sets the layer type of the given ImageView based on the given IconType. If the - * icon type is SVG and SDK <= 27, the layer type is set to software. Otherwise, it - * is set to hardware. - */ - public static void setLayerType(ImageView view, IconType iconType) { - if (iconType == IconType.SVG && Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) { - view.setLayerType(ImageView.LAYER_TYPE_SOFTWARE, null); - return; - } - - view.setLayerType(ImageView.LAYER_TYPE_HARDWARE, null); - } -} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/PasswordStrengthHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/PasswordStrengthHelper.java index 518ebcc189..373ef1144a 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/helpers/PasswordStrengthHelper.java +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/PasswordStrengthHelper.java @@ -1,14 +1,60 @@ package com.beemdevelopment.aegis.helpers; import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.widget.EditText; +import android.widget.ProgressBar; +import android.widget.TextView; import com.beemdevelopment.aegis.R; +import com.google.android.material.textfield.TextInputLayout; +import com.google.common.base.Strings; +import com.nulabinc.zxcvbn.Strength; +import com.nulabinc.zxcvbn.Zxcvbn; public class PasswordStrengthHelper { + // Limit the password length to prevent zxcvbn4j from exploding + private static final int MAX_PASSWORD_LENGTH = 64; + // Material design color palette - private static String[] COLORS = {"#FF5252", "#FF5252", "#FFC107", "#8BC34A", "#4CAF50"}; + private final static String[] COLORS = {"#FF5252", "#FF5252", "#FFC107", "#8BC34A", "#4CAF50"}; + + private final Zxcvbn _zxcvbn = new Zxcvbn(); + private final EditText _textPassword; + private final ProgressBar _barPasswordStrength; + private final TextView _textPasswordStrength; + private final TextInputLayout _textPasswordWrapper; + + public PasswordStrengthHelper( + EditText textPassword, + ProgressBar barPasswordStrength, + TextView textPasswordStrength, + TextInputLayout textPasswordWrapper + ) { + _textPassword = textPassword; + _barPasswordStrength = barPasswordStrength; + _textPasswordStrength = textPasswordStrength; + _textPasswordWrapper = textPasswordWrapper; + } + + public void measure(Context context) { + if (_textPassword.getText().length() > MAX_PASSWORD_LENGTH) { + _barPasswordStrength.setProgress(0); + _textPasswordStrength.setText(R.string.password_strength_unknown); + } else { + Strength strength = _zxcvbn.measure(_textPassword.getText()); + _barPasswordStrength.setProgress(strength.getScore()); + _barPasswordStrength.setProgressTintList(ColorStateList.valueOf(Color.parseColor(getColor(strength.getScore())))); + _textPasswordStrength.setText((_textPassword.getText().length() != 0) ? getString(strength.getScore(), context) : ""); + String warning = strength.getFeedback().getWarning(); + _textPasswordWrapper.setError(warning); + _textPasswordWrapper.setErrorEnabled(!Strings.isNullOrEmpty(warning)); + strength.wipe(); + } + } - public static String getString(int score, Context context) { + private static String getString(int score, Context context) { if (score < 0 || score > 4) { throw new IllegalArgumentException("Not a valid zxcvbn score"); } @@ -17,7 +63,7 @@ public static String getString(int score, Context context) { return strings[score]; } - public static String getColor(int score) { + private static String getColor(int score) { if (score < 0 || score > 4) { throw new IllegalArgumentException("Not a valid zxcvbn score"); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeAnalyzer.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeAnalyzer.java index b85ff40278..2fd1b21f4e 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeAnalyzer.java +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeAnalyzer.java @@ -1,7 +1,7 @@ package com.beemdevelopment.aegis.helpers; -import android.os.Handler; -import android.os.Looper; +import static android.graphics.ImageFormat.YUV_420_888; + import android.util.Log; import android.util.Size; @@ -9,21 +9,12 @@ import androidx.camera.core.ImageAnalysis; import androidx.camera.core.ImageProxy; -import com.google.zxing.BinaryBitmap; -import com.google.zxing.ChecksumException; -import com.google.zxing.FormatException; import com.google.zxing.NotFoundException; import com.google.zxing.PlanarYUVLuminanceSource; import com.google.zxing.Result; -import com.google.zxing.common.HybridBinarizer; -import com.google.zxing.qrcode.QRCodeReader; import java.nio.ByteBuffer; -import static android.graphics.ImageFormat.YUV_420_888; -import static android.graphics.ImageFormat.YUV_422_888; -import static android.graphics.ImageFormat.YUV_444_888; - public class QrCodeAnalyzer implements ImageAnalysis.Analyzer { private static final String TAG = QrCodeAnalyzer.class.getSimpleName(); public static final Size RESOLUTION = new Size(1200, 1600); @@ -37,56 +28,39 @@ public QrCodeAnalyzer(QrCodeAnalyzer.Listener listener) { @Override public void analyze(@NonNull ImageProxy image) { int format = image.getFormat(); - if (format != YUV_420_888 && format != YUV_422_888 && format != YUV_444_888) { - Log.e(TAG, String.format("Expected YUV format, got %d instead", format)); + if (format != YUV_420_888) { + Log.e(TAG, String.format("Unexpected YUV image format: %d", format)); image.close(); return; } - byte[] data = getLuminancePlaneData(image); - PlanarYUVLuminanceSource source = new PlanarYUVLuminanceSource( - data, image.getWidth(), image.getHeight(), 0, 0, image.getWidth(), image.getHeight(), false - ); - - QRCodeReader reader = new QRCodeReader(); - BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); - try { - Result result = reader.decode(bitmap); - if (_listener != null) { - new Handler(Looper.getMainLooper()).post(() -> _listener.onQrCodeDetected(result)); - } - } catch (ChecksumException | FormatException | NotFoundException ignored) { - - } finally { - image.close(); - } - } - - private static byte[] getLuminancePlaneData(ImageProxy image) { ImageProxy.PlaneProxy plane = image.getPlanes()[0]; ByteBuffer buf = plane.getBuffer(); byte[] data = new byte[buf.remaining()]; buf.get(data); buf.rewind(); - int width = image.getWidth(); - int height = image.getHeight(); - int rowStride = plane.getRowStride(); - int pixelStride = plane.getPixelStride(); + PlanarYUVLuminanceSource source = new PlanarYUVLuminanceSource( + data, + plane.getRowStride(), + image.getHeight(), + 0, + 0, + image.getWidth(), + image.getHeight(), + false + ); - if (width != rowStride || pixelStride != 1) { - // remove padding from the Y plane data - byte[] cleanData = new byte[width * height]; - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x++) { - cleanData[y * width + x] = data[y * rowStride + x * pixelStride]; - } + try { + Result result = QrCodeHelper.decodeFromSource(source); + if (_listener != null) { + _listener.onQrCodeDetected(result); } + } catch (NotFoundException ignored) { - return cleanData; + } finally { + image.close(); } - - return data; } public interface Listener { diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeHelper.java new file mode 100644 index 0000000000..37291e9c39 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeHelper.java @@ -0,0 +1,96 @@ +package com.beemdevelopment.aegis.helpers; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; + +import androidx.annotation.ColorInt; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.BinaryBitmap; +import com.google.zxing.DecodeHintType; +import com.google.zxing.LuminanceSource; +import com.google.zxing.MultiFormatReader; +import com.google.zxing.NotFoundException; +import com.google.zxing.RGBLuminanceSource; +import com.google.zxing.Result; +import com.google.zxing.WriterException; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.common.HybridBinarizer; +import com.google.zxing.qrcode.QRCodeWriter; + +import java.io.InputStream; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class QrCodeHelper { + private QrCodeHelper() { + + } + + public static Result decodeFromSource(LuminanceSource source) throws NotFoundException { + Map hints = new HashMap<>(); + hints.put(DecodeHintType.POSSIBLE_FORMATS, Collections.singletonList(BarcodeFormat.QR_CODE)); + hints.put(DecodeHintType.ALSO_INVERTED, true); + + BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); + MultiFormatReader reader = new MultiFormatReader(); + return reader.decode(bitmap, hints); + } + + public static Result decodeFromStream(InputStream inStream) throws DecodeError { + BitmapFactory.Options bmOptions = new BitmapFactory.Options(); + Bitmap bitmap = BitmapFactory.decodeStream(inStream, null, bmOptions); + if (bitmap == null) { + throw new DecodeError("Unable to decode stream to bitmap"); + } + + // If ZXing is not able to decode the image on the first try, we try a couple of + // more times with smaller versions of the same image. + for (int i = 0; i <= 2; i++) { + if (i != 0) { + bitmap = BitmapHelper.resize(bitmap, bitmap.getWidth() / (i * 2), bitmap.getHeight() / (i * 2)); + } + + try { + int[] pixels = new int[bitmap.getWidth() * bitmap.getHeight()]; + bitmap.getPixels(pixels, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight()); + + LuminanceSource source = new RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), pixels); + return decodeFromSource(source); + } catch (NotFoundException ignored) { + + } + } + + throw new DecodeError(NotFoundException.getNotFoundInstance()); + } + + public static Bitmap encodeToBitmap(String data, int width, int height, @ColorInt int backgroundColor) throws WriterException { + QRCodeWriter writer = new QRCodeWriter(); + BitMatrix bitMatrix = writer.encode(data, BarcodeFormat.QR_CODE, width, height); + + int[] pixels = new int[width * height]; + for (int y = 0; y < height; y++) { + int offset = y * width; + for (int x = 0; x < width; x++) { + pixels[offset + x] = bitMatrix.get(x, y) ? Color.BLACK : backgroundColor; + } + } + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + bitmap.setPixels(pixels, 0, width, 0, 0, width, height); + return bitmap; + } + + public static class DecodeError extends Exception { + public DecodeError(String message) { + super(message); + } + + public DecodeError(Throwable cause) { + super(cause); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/SafHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/SafHelper.java new file mode 100644 index 0000000000..38828b25ce --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/SafHelper.java @@ -0,0 +1,47 @@ +package com.beemdevelopment.aegis.helpers; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.webkit.MimeTypeMap; + +import androidx.documentfile.provider.DocumentFile; + +public class SafHelper { + private SafHelper() { + + } + + public static String getFileName(Context context, Uri uri) { + if (uri.getScheme() != null && uri.getScheme().equals("content")) { + try (Cursor cursor = context.getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int i = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (i != -1) { + return cursor.getString(i); + } + } + } + } + + return uri.getLastPathSegment(); + } + + public static String getMimeType(Context context, Uri uri) { + DocumentFile file = DocumentFile.fromSingleUri(context, uri); + if (file != null) { + String fileType = file.getType(); + if (fileType != null) { + return fileType; + } + + String ext = MimeTypeMap.getFileExtensionFromUrl(uri.toString()); + if (ext != null) { + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext); + } + } + + return null; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleAnimationEndListener.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleAnimationEndListener.java new file mode 100644 index 0000000000..f41a9c9612 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleAnimationEndListener.java @@ -0,0 +1,32 @@ +package com.beemdevelopment.aegis.helpers; + +import android.view.animation.Animation; + +public class SimpleAnimationEndListener implements Animation.AnimationListener { + private final Listener _listener; + + public SimpleAnimationEndListener(Listener listener) { + _listener = listener; + } + + @Override + public void onAnimationStart(Animation animation) { + + } + + @Override + public void onAnimationEnd(Animation animation) { + if (_listener != null) { + _listener.onAnimationEnd(animation); + } + } + + @Override + public void onAnimationRepeat(Animation animation) { + + } + + public interface Listener { + void onAnimationEnd(Animation animation); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleItemTouchHelperCallback.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleItemTouchHelperCallback.java index c1bb1bcf11..96c672bde1 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleItemTouchHelperCallback.java +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleItemTouchHelperCallback.java @@ -1,20 +1,24 @@ package com.beemdevelopment.aegis.helpers; +import static androidx.recyclerview.widget.RecyclerView.NO_POSITION; + +import androidx.annotation.NonNull; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; -import com.beemdevelopment.aegis.vault.VaultEntry; import com.beemdevelopment.aegis.ui.views.EntryAdapter; +import com.beemdevelopment.aegis.vault.VaultEntry; public class SimpleItemTouchHelperCallback extends ItemTouchHelper.Callback { private VaultEntry _selectedEntry; - private final ItemTouchHelperAdapter _adapter; + private final EntryAdapter _adapter; private boolean _positionChanged = false; private boolean _isLongPressDragEnabled = true; + private int _dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN; - public SimpleItemTouchHelperCallback(ItemTouchHelperAdapter adapter) { + public SimpleItemTouchHelperCallback(EntryAdapter adapter) { _adapter = adapter; } @@ -28,7 +32,14 @@ public void setIsLongPressDragEnabled(boolean enabled) { } public void setSelectedEntry(VaultEntry entry) { - _selectedEntry = entry; + if (entry == null) { + _selectedEntry = null; + return; + } + + if (!entry.isFavorite()) { + _selectedEntry = entry; + } } @Override @@ -36,32 +47,55 @@ public boolean isItemViewSwipeEnabled() { return false; } + public void setDragFlags(int dragFlags) { + _dragFlags = dragFlags; + } + @Override - public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { - int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN; - int swipeFlags = 0; + public int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + // It's not clear when this can happen, but sometimes the ViewHolder + // that's passed to this function has a position of -1, leading + // to a crash down the line. + int position = viewHolder.getBindingAdapterPosition(); + if (position == NO_POSITION) { + return 0; + } - int position = viewHolder.getAdapterPosition(); - EntryAdapter adapter = (EntryAdapter)recyclerView.getAdapter(); - if (adapter.getEntryAt(position) != _selectedEntry || !isLongPressDragEnabled()) - { - dragFlags = 0; + EntryAdapter adapter = (EntryAdapter) recyclerView.getAdapter(); + if (adapter == null) { + return 0; } - return makeMovementFlags(dragFlags, swipeFlags); + int swipeFlags = 0; + if (adapter.isPositionFooter(position) + || adapter.isPositionErrorCard(position) + || adapter.getEntryAtPosition(position) != _selectedEntry + || !isLongPressDragEnabled()) { + return makeMovementFlags(0, swipeFlags); + } + + return makeMovementFlags(_dragFlags, swipeFlags); } @Override public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) { - _adapter.onItemMove(viewHolder.getAdapterPosition(), target.getAdapterPosition()); + int targetIndex = _adapter.translateEntryPosToIndex(target.getBindingAdapterPosition()); + if (targetIndex < _adapter.getShownFavoritesCount()) { + return false; + } + + int firstPosition = viewHolder.getLayoutPosition(); + int secondPosition = target.getBindingAdapterPosition(); + + _adapter.onItemMove(firstPosition, secondPosition); _positionChanged = true; return true; } @Override public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) { - _adapter.onItemDismiss(viewHolder.getAdapterPosition()); + _adapter.onItemDismiss(viewHolder.getBindingAdapterPosition()); } @Override @@ -69,10 +103,9 @@ public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHol super.clearView(recyclerView, viewHolder); if (_positionChanged) { - _adapter.onItemDrop(viewHolder.getAdapterPosition()); + _adapter.onItemDrop(viewHolder.getBindingAdapterPosition()); _positionChanged = false; + _adapter.refresh(false); } } - - } diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleTextWatcher.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleTextWatcher.java new file mode 100644 index 0000000000..3b5d616672 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleTextWatcher.java @@ -0,0 +1,33 @@ +package com.beemdevelopment.aegis.helpers; + +import android.text.Editable; +import android.text.TextWatcher; + +public final class SimpleTextWatcher implements TextWatcher { + private final Listener _listener; + + public SimpleTextWatcher(Listener listener) { + _listener = listener; + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + + } + + @Override + public void afterTextChanged(Editable s) { + if (_listener != null) { + _listener.afterTextChanged(s); + } + } + + public interface Listener { + void afterTextChanged(Editable s); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/TextDrawableHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/TextDrawableHelper.java index 6d2cca95dc..fc66b0cbd6 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/helpers/TextDrawableHelper.java +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/TextDrawableHelper.java @@ -5,6 +5,7 @@ import com.amulyakhare.textdrawable.TextDrawable; import com.amulyakhare.textdrawable.util.ColorGenerator; +import java.text.BreakIterator; import java.util.Arrays; public class TextDrawableHelper { @@ -48,6 +49,18 @@ public static TextDrawable generate(String text, String fallback, View view) { .width(view.getLayoutParams().width) .height(view.getLayoutParams().height) .endConfig() - .buildRound(text.substring(0, 1).toUpperCase(), color); + .buildRound(getFirstGrapheme(text).toUpperCase(), color); + } + + private static String getFirstGrapheme(String text) { + BreakIterator iter = BreakIterator.getCharacterInstance(); + iter.setText(text); + + int start = iter.first(), end = iter.next(); + if (end == BreakIterator.DONE) { + return ""; + } + + return text.substring(start, end); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/ThemeHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/ThemeHelper.java index cbef80fedb..0bd1dcfc0e 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/helpers/ThemeHelper.java +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/ThemeHelper.java @@ -1,23 +1,58 @@ package com.beemdevelopment.aegis.helpers; -import android.content.res.Resources; -import android.graphics.Color; -import android.util.TypedValue; +import android.content.res.Configuration; -import androidx.annotation.ColorInt; +import androidx.appcompat.app.AppCompatActivity; +import com.beemdevelopment.aegis.Preferences; import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.Theme; +import com.google.android.material.color.DynamicColors; +import com.google.android.material.color.DynamicColorsOptions; + +import java.util.Map; public class ThemeHelper { - private ThemeHelper() { + private final AppCompatActivity _activity; + private final Preferences _prefs; + + public ThemeHelper(AppCompatActivity activity, Preferences prefs) { + _activity = activity; + _prefs = prefs; + } + /** + * Sets the theme of the activity. The actual style that is set is picked from the + * given map, based on the theme configured by the user. + */ + public void setTheme(Map themeMap) { + int theme = themeMap.get(getConfiguredTheme()); + _activity.setTheme(theme); + + if (_prefs.isDynamicColorsEnabled()) { + DynamicColorsOptions.Builder optsBuilder = new DynamicColorsOptions.Builder(); + if (getConfiguredTheme().equals(Theme.AMOLED)) { + optsBuilder.setThemeOverlay(R.style.ThemeOverlay_Aegis_Dynamic_Amoled); + } else if (getConfiguredTheme().equals(Theme.DARK)) { + optsBuilder.setThemeOverlay(R.style.ThemeOverlay_Aegis_Dynamic_Dark); + } + + DynamicColors.applyToActivityIfAvailable(_activity, optsBuilder.build()); + } } - public static int getThemeColor(int attributeId, Resources.Theme currentTheme) { - TypedValue typedValue = new TypedValue(); - currentTheme.resolveAttribute(attributeId, typedValue, true); - @ColorInt int color = typedValue.data; + public Theme getConfiguredTheme() { + Theme theme = _prefs.getCurrentTheme(); + + if (theme == Theme.SYSTEM || theme == Theme.SYSTEM_AMOLED) { + int currentNightMode = _activity.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + if (currentNightMode == Configuration.UI_MODE_NIGHT_YES) { + theme = theme == Theme.SYSTEM_AMOLED ? Theme.AMOLED : Theme.DARK; + } else { + theme = Theme.LIGHT; + } + } - return color; + return theme; } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/UiRefresher.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/UiRefresher.java index 7669c26a1b..c7d0425dfb 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/helpers/UiRefresher.java +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/UiRefresher.java @@ -2,6 +2,8 @@ import android.os.Handler; +import com.beemdevelopment.aegis.VibrationPatterns; + public class UiRefresher { private boolean _running; private Listener _listener; @@ -23,7 +25,6 @@ public void start() { } _running = true; - _listener.onRefresh(); _handler.postDelayed(new Runnable() { @Override public void run() { @@ -31,6 +32,27 @@ public void run() { _handler.postDelayed(this, _listener.getMillisTillNextRefresh()); } }, _listener.getMillisTillNextRefresh()); + + _handler.postDelayed(new Runnable() { + @Override + public void run() { + _listener.onExpiring(); + _handler.postDelayed(this, getNextRun()); + } + }, getInitialRun()); + } + + private long getInitialRun() { + long sum = _listener.getMillisTillNextRefresh() - VibrationPatterns.getLengthInMillis(VibrationPatterns.EXPIRING); + if (sum < 0) { + return getNextRun(); + } + + return sum; + } + + private long getNextRun() { + return (_listener.getMillisTillNextRefresh() + _listener.getPeriodMillis()) - VibrationPatterns.getLengthInMillis(VibrationPatterns.EXPIRING); } public void stop() { @@ -40,6 +62,8 @@ public void stop() { public interface Listener { void onRefresh(); + void onExpiring(); long getMillisTillNextRefresh(); + long getPeriodMillis(); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/VibrationHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/VibrationHelper.java new file mode 100644 index 0000000000..9fbc7a00d2 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/VibrationHelper.java @@ -0,0 +1,44 @@ +package com.beemdevelopment.aegis.helpers; + +import android.content.Context; +import android.os.Build; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.os.VibratorManager; + +import com.beemdevelopment.aegis.Preferences; + +public class VibrationHelper { + private Preferences _preferences; + + public VibrationHelper(Context context) { + _preferences = new Preferences(context); + } + + public void vibratePattern(Context context, long[] pattern) { + if (!isHapticFeedbackEnabled()) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + VibratorManager vibratorManager = (VibratorManager) context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE); + if (vibratorManager != null) { + Vibrator vibrator = vibratorManager.getDefaultVibrator(); + VibrationEffect effect = VibrationEffect.createWaveform(pattern, -1); + vibrator.vibrate(effect); + } + } else { + Vibrator vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); + if (vibrator != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + VibrationEffect effect = VibrationEffect.createWaveform(pattern, -1); + vibrator.vibrate(effect); + } + } + } + } + + public boolean isHapticFeedbackEnabled() { + return _preferences.isHapticFeedbackEnabled(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/ViewHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/ViewHelper.java new file mode 100644 index 0000000000..910aae2f08 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/ViewHelper.java @@ -0,0 +1,26 @@ +package com.beemdevelopment.aegis.helpers; + +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +import com.google.android.material.appbar.AppBarLayout; + +public class ViewHelper { + private ViewHelper() { + + } + + public static void setupAppBarInsets(AppBarLayout appBar) { + ViewCompat.setOnApplyWindowInsetsListener(appBar, (targetView, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); + targetView.setPadding( + insets.left, + insets.top, + insets.right, + 0 + ); + return WindowInsetsCompat.CONSUMED; + }); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/FavoriteComparator.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/FavoriteComparator.java new file mode 100644 index 0000000000..fc16f009f9 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/FavoriteComparator.java @@ -0,0 +1,12 @@ +package com.beemdevelopment.aegis.helpers.comparators; + +import com.beemdevelopment.aegis.vault.VaultEntry; + +import java.util.Comparator; + +public class FavoriteComparator implements Comparator { + @Override + public int compare(VaultEntry a, VaultEntry b) { + return -1 * Boolean.compare(a.isFavorite(), b.isFavorite()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/LastUsedComparator.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/LastUsedComparator.java new file mode 100644 index 0000000000..5fc85edfeb --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/LastUsedComparator.java @@ -0,0 +1,12 @@ +package com.beemdevelopment.aegis.helpers.comparators; + +import com.beemdevelopment.aegis.vault.VaultEntry; + +import java.util.Comparator; + +public class LastUsedComparator implements Comparator { + @Override + public int compare(VaultEntry a, VaultEntry b) { + return Long.compare(a.getLastUsedTimestamp(), b.getLastUsedTimestamp()); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/icons/IconPack.java b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPack.java index 9ac82adea6..5f87ca552b 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/icons/IconPack.java +++ b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPack.java @@ -3,6 +3,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.beemdevelopment.aegis.util.JsonUtils; import com.google.common.base.Objects; import com.google.common.io.Files; @@ -17,7 +18,6 @@ import java.util.Collections; import java.util.List; import java.util.UUID; -import java.util.stream.Collectors; public class IconPack { private UUID _uuid; @@ -58,9 +58,21 @@ public List getSuggestedIcons(String issuer) { return new ArrayList<>(); } - return _icons.stream() - .filter(i -> i.isSuggestedFor(issuer)) - .collect(Collectors.toList()); + List icons = new ArrayList<>(); + for (Icon icon : _icons) { + MatchType matchType = icon.getMatchFor(issuer); + if (matchType != null) { + // Inverse matches (entry issuer contains icon name) are less likely + // to be good, so position them at the end of the list. + if (matchType.equals(MatchType.NORMAL)) { + icons.add(0, icon); + } else if (matchType.equals(MatchType.INVERSE)) { + icons.add(icon); + } + } + } + + return icons; } @Nullable @@ -120,13 +132,15 @@ public static IconPack fromBytes(byte[] data) throws JSONException { public static class Icon implements Serializable { private final String _relFilename; + private final String _name; private final String _category; private final List _issuers; private File _file; - protected Icon(String filename, String category, List issuers) { + protected Icon(String filename, String name, String category, List issuers) { _relFilename = filename; + _name = name; _category = category; _issuers = issuers; } @@ -148,8 +162,10 @@ public IconType getIconType() { return IconType.fromFilename(_relFilename); } - @SuppressWarnings("UnstableApiUsage") public String getName() { + if (_name != null) { + return _name; + } return Files.getNameWithoutExtension(new File(_relFilename).getName()); } @@ -157,19 +173,29 @@ public String getCategory() { return _category; } - public List getIssuers() { - return Collections.unmodifiableList(_issuers); - } + private MatchType getMatchFor(String issuer) { + String lowerEntryIssuer = issuer.toLowerCase(); + + boolean inverseMatch = false; + for (String is : _issuers) { + String lowerIconIssuer = is.toLowerCase(); + if (lowerIconIssuer.contains(lowerEntryIssuer)) { + return MatchType.NORMAL; + } + if (lowerEntryIssuer.contains(lowerIconIssuer)) { + inverseMatch = true; + } + } + if (inverseMatch) { + return MatchType.INVERSE; + } - public boolean isSuggestedFor(String issuer) { - String lowerIssuer = issuer.toLowerCase(); - return getIssuers().stream() - .map(String::toLowerCase) - .anyMatch(is -> is.contains(lowerIssuer) || lowerIssuer.contains(is)); + return null; } public static Icon fromJson(JSONObject obj) throws JSONException { String filename = obj.getString("filename"); + String name = JsonUtils.optString(obj, "name"); String category = obj.isNull("category") ? null : obj.getString("category"); JSONArray array = obj.getJSONArray("issuer"); @@ -179,7 +205,12 @@ public static Icon fromJson(JSONObject obj) throws JSONException { issuers.add(issuer); } - return new Icon(filename, category, issuers); + return new Icon(filename, name, category, issuers); } } + + private enum MatchType { + NORMAL, + INVERSE + } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackManager.java b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackManager.java index 05b2cf29b0..7aa9a2925c 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackManager.java +++ b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackManager.java @@ -42,6 +42,10 @@ private IconPack getIconPackByUUID(UUID uuid) { return packs.get(0); } + public boolean hasIconPack() { + return _iconPacks.size() > 0; + } + public List getIconPacks() { return new ArrayList<>(_iconPacks); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/icons/IconType.java b/app/src/main/java/com/beemdevelopment/aegis/icons/IconType.java index 37850b0039..c742ecc056 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/icons/IconType.java +++ b/app/src/main/java/com/beemdevelopment/aegis/icons/IconType.java @@ -23,7 +23,6 @@ public static IconType fromMimeType(String mimeType) { } } - @SuppressWarnings("UnstableApiUsage") public static IconType fromFilename(String filename) { switch (Files.getFileExtension(filename).toLowerCase(Locale.ROOT)) { case "svg": diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/AegisImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/AegisImporter.java index 1b18ae6bdf..ac42cc8727 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/AegisImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/AegisImporter.java @@ -3,8 +3,10 @@ import android.content.Context; import android.content.DialogInterface; +import androidx.annotation.Nullable; import androidx.lifecycle.Lifecycle; +import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.helpers.ContextHelper; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; import com.beemdevelopment.aegis.ui.tasks.PasswordSlotDecryptTask; @@ -14,6 +16,7 @@ import com.beemdevelopment.aegis.vault.VaultFile; import com.beemdevelopment.aegis.vault.VaultFileCredentials; import com.beemdevelopment.aegis.vault.VaultFileException; +import com.beemdevelopment.aegis.vault.VaultGroup; import com.beemdevelopment.aegis.vault.slots.PasswordSlot; import com.beemdevelopment.aegis.vault.slots.SlotList; import com.topjohnwu.superuser.io.SuFile; @@ -25,6 +28,7 @@ import java.io.IOException; import java.io.InputStream; import java.util.List; +import java.util.UUID; public class AegisImporter extends DatabaseImporter { @@ -71,7 +75,7 @@ public State decrypt(VaultFileCredentials creds) throws DatabaseImporterExceptio throw new DatabaseImporterException(e); } - return new DecryptedState(obj); + return new DecryptedState(obj, creds); } public State decrypt(char[] password) throws DatabaseImporterException { @@ -83,7 +87,7 @@ public State decrypt(char[] password) throws DatabaseImporterException { @Override public void decrypt(Context context, DecryptListener listener) { - Dialogs.showPasswordInputDialog(context, (Dialogs.TextInputListener) password -> { + Dialogs.showPasswordInputDialog(context, R.string.enter_password_aegis_title, 0, (Dialogs.TextInputListener) password -> { List slots = getSlots().findAll(PasswordSlot.class); PasswordSlotDecryptTask.Params params = new PasswordSlotDecryptTask.Params(slots, password); PasswordSlotDecryptTask task = new PasswordSlotDecryptTask(context, result -> { @@ -108,10 +112,21 @@ public void decrypt(Context context, DecryptListener listener) { public static class DecryptedState extends State { private JSONObject _obj; + private VaultFileCredentials _creds; private DecryptedState(JSONObject obj) { + this(obj, null); + } + + private DecryptedState(JSONObject obj, VaultFileCredentials creds) { super(false); _obj = obj; + _creds = creds; + } + + @Nullable + public VaultFileCredentials getCredentials() { + return _creds; } @Override @@ -119,11 +134,31 @@ public Result convert() throws DatabaseImporterException { Result result = new Result(); try { - JSONArray array = _obj.getJSONArray("entries"); - for (int i = 0; i < array.length(); i++) { - JSONObject entryObj = array.getJSONObject(i); + if (_obj.has("groups")) { + JSONArray groupArray = _obj.getJSONArray("groups"); + for (int i = 0; i < groupArray.length(); i++) { + JSONObject groupObj = groupArray.getJSONObject(i); + try { + VaultGroup group = convertGroup(groupObj); + if (!result.getGroups().has(group)) { + result.addGroup(group); + } + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + } + + JSONArray entryArray = _obj.getJSONArray("entries"); + for (int i = 0; i < entryArray.length(); i++) { + JSONObject entryObj = entryArray.getJSONObject(i); try { VaultEntry entry = convertEntry(entryObj); + for (UUID groupUuid : entry.getGroups()) { + if (!result.getGroups().has(groupUuid)) { + entry.getGroups().remove(groupUuid); + } + } result.addEntry(entry); } catch (DatabaseImporterEntryException e) { result.addError(e); @@ -143,5 +178,13 @@ private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEn throw new DatabaseImporterEntryException(e, obj.toString()); } } + + private static VaultGroup convertGroup(JSONObject obj) throws DatabaseImporterEntryException { + try { + return VaultGroup.fromJson(obj); + } catch (VaultEntryException e) { + throw new DatabaseImporterEntryException(e, obj.toString()); + } + } } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/AndOtpImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/AndOtpImporter.java index 02cbcc8332..2be31b0f06 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/AndOtpImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/AndOtpImporter.java @@ -18,9 +18,10 @@ import com.beemdevelopment.aegis.otp.SteamInfo; import com.beemdevelopment.aegis.otp.TotpInfo; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; -import com.beemdevelopment.aegis.ui.tasks.ProgressDialogTask; +import com.beemdevelopment.aegis.ui.tasks.PBKDFTask; import com.beemdevelopment.aegis.util.IOUtils; import com.beemdevelopment.aegis.vault.VaultEntry; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.topjohnwu.superuser.io.SuFile; import org.json.JSONArray; @@ -35,8 +36,6 @@ import java.security.InvalidKeyException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.KeySpec; import java.util.Arrays; import java.util.Locale; @@ -45,8 +44,6 @@ import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; public class AndOtpImporter extends DatabaseImporter { @@ -117,15 +114,21 @@ private DecryptedState decryptContent(SecretKey key, int offset) throws Database } } - private KeyDerivationParams getKeyDerivationParams(char[] password) throws DatabaseImporterException { + private PBKDFTask.Params getKeyDerivationParams(char[] password) throws DatabaseImporterException { byte[] iterBytes = Arrays.copyOfRange(_data, 0, INT_SIZE); int iterations = ByteBuffer.wrap(iterBytes).getInt(); if (iterations < 1) { throw new DatabaseImporterException(String.format("Invalid number of iterations for PBKDF: %d", iterations)); } + // If number of iterations is this high, it's probably not an andOTP file, so + // abort early in order to prevent having to wait for an extremely long key derivation + // process, only to find out that the user picked the wrong file + if (iterations > 10_000_000L) { + throw new DatabaseImporterException(String.format("Unexpectedly high number of iterations: %d", iterations)); + } byte[] salt = Arrays.copyOfRange(_data, INT_SIZE, INT_SIZE + SALT_SIZE); - return new KeyDerivationParams(password, salt, iterations); + return new PBKDFTask.Params("PBKDF2WithHmacSHA1", KEY_SIZE, password, salt, iterations); } protected DecryptedState decryptOldFormat(char[] password) throws DatabaseImporterException { @@ -149,8 +152,8 @@ protected DecryptedState decryptNewFormat(SecretKey key) throws DatabaseImporter protected DecryptedState decryptNewFormat(char[] password) throws DatabaseImporterException { - KeyDerivationParams params = getKeyDerivationParams(password); - SecretKey key = AndOtpKeyDerivationTask.deriveKey(params); + PBKDFTask.Params params = getKeyDerivationParams(password); + SecretKey key = PBKDFTask.deriveKey(params); return decryptNewFormat(key); } @@ -159,8 +162,8 @@ private void decrypt(Context context, char[] password, boolean oldFormat, Decryp DecryptedState state = decryptOldFormat(password); listener.onStateDecrypted(state); } else { - KeyDerivationParams params = getKeyDerivationParams(password); - AndOtpKeyDerivationTask task = new AndOtpKeyDerivationTask(context, key -> { + PBKDFTask.Params params = getKeyDerivationParams(password); + PBKDFTask task = new PBKDFTask(context, key -> { try { DecryptedState state = decryptNewFormat(key); listener.onStateDecrypted(state); @@ -180,7 +183,7 @@ public void decrypt(Context context, DecryptListener listener) { context.getResources().getString(R.string.andotp_old_format) }; - Dialogs.showSecureDialog(new AlertDialog.Builder(context) + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(context) .setTitle(R.string.choose_andotp_importer) .setSingleChoiceItems(choices, 0, null) .setPositiveButton(android.R.string.ok, (dialog, which) -> { @@ -263,71 +266,10 @@ private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEn } return new VaultEntry(info, name, issuer); - } catch (DatabaseImporterException | EncodingException | OtpInfoException | JSONException e) { + } catch (DatabaseImporterException | EncodingException | OtpInfoException | + JSONException e) { throw new DatabaseImporterEntryException(e, obj.toString()); } } } - - protected static class AndOtpKeyDerivationTask extends ProgressDialogTask { - private Callback _cb; - - public AndOtpKeyDerivationTask(Context context, Callback cb) { - super(context, context.getString(R.string.unlocking_vault)); - _cb = cb; - } - - @Override - protected SecretKey doInBackground(AndOtpImporter.KeyDerivationParams... args) { - setPriority(); - - AndOtpImporter.KeyDerivationParams params = args[0]; - return deriveKey(params); - } - - protected static SecretKey deriveKey(KeyDerivationParams params) { - try { - SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); - KeySpec spec = new PBEKeySpec(params.getPassword(), params.getSalt(), params.getIterations(), KEY_SIZE); - SecretKey key = factory.generateSecret(spec); - return new SecretKeySpec(key.getEncoded(), "AES"); - } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { - throw new RuntimeException(e); - } - } - - @Override - protected void onPostExecute(SecretKey key) { - super.onPostExecute(key); - _cb.onTaskFinished(key); - } - - public interface Callback { - void onTaskFinished(SecretKey key); - } - } - - protected static class KeyDerivationParams { - private final char[] _password; - private final byte[] _salt; - private final int _iterations; - - public KeyDerivationParams(char[] password, byte[] salt, int iterations) { - _iterations = iterations; - _password = password; - _salt = salt; - } - - public char[] getPassword() { - return _password; - } - - public int getIterations() { - return _iterations; - } - - public byte[] getSalt() { - return _salt; - } - } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/AuthyImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/AuthyImporter.java index 7b6e694e8b..890e9b30ab 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/AuthyImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/AuthyImporter.java @@ -16,6 +16,7 @@ import com.beemdevelopment.aegis.util.JsonUtils; import com.beemdevelopment.aegis.util.PreferenceParser; import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.Shell; import com.topjohnwu.superuser.io.SuFile; import com.topjohnwu.superuser.io.SuFileInputStream; @@ -67,14 +68,20 @@ protected SuFile getAppPath() throws PackageManager.NameNotFoundException { } @Override - public State readFromApp() throws PackageManager.NameNotFoundException, DatabaseImporterException { + public State readFromApp(Shell shell) throws PackageManager.NameNotFoundException, DatabaseImporterException { SuFile path = getAppPath(); + path.setShell(shell); JSONArray array; JSONArray authyArray; try { - array = readFile(new SuFile(path, String.format("%s.xml", _authFilename)), String.format("%s.key", _authFilename)); - authyArray = readFile(new SuFile(path, String.format("%s.xml", _authyFilename)), String.format("%s.key", _authyFilename)); + SuFile file1 = new SuFile(path, String.format("%s.xml", _authFilename)); + file1.setShell(shell); + SuFile file2 = new SuFile(path, String.format("%s.xml", _authyFilename)); + file2.setShell(shell); + + array = readFile(file1, String.format("%s.key", _authFilename)); + authyArray = readFile(file2, String.format("%s.key", _authyFilename)); } catch (IOException | XmlPullParserException e) { throw new DatabaseImporterException(e); } @@ -129,7 +136,7 @@ private State read(JSONArray array) throws DatabaseImporterException { } private JSONArray readFile(SuFile file, String key) throws IOException, XmlPullParserException { - try (SuFileInputStream inStream = new SuFileInputStream(file)) { + try (InputStream inStream = SuFileInputStream.open(file)) { XmlPullParser parser = Xml.newPullParser(); parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); parser.setInput(inStream, null); diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/BattleNetImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/BattleNetImporter.java new file mode 100644 index 0000000000..221ed0e664 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/BattleNetImporter.java @@ -0,0 +1,124 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.util.Xml; + +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.encoding.Hex; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.util.PreferenceParser; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.google.common.base.Strings; +import com.topjohnwu.superuser.io.SuFile; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.InputStream; + +public class BattleNetImporter extends DatabaseImporter { + private static final String _pkgName = "com.blizzard.messenger"; + private static final String _subPath = "shared_prefs/com.blizzard.messenger.authenticator_preferences.xml"; + + private static final byte[] _key; + + public BattleNetImporter(Context context) { + super(context); + } + + static { + try { + _key = Hex.decode("398e27fc50276a656065b0e525f4c06c04c61075286b8e7aeda59da9813b5dd6c80d2fb38068773fa59ba47c17ca6c6479015c1d5b8b8f6b9a"); + } catch (EncodingException e) { + throw new RuntimeException(e); + } + } + + @Override + protected SuFile getAppPath() throws DatabaseImporterException, PackageManager.NameNotFoundException { + return getAppPath(_pkgName, _subPath); + } + + @Override + protected State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + final String serialKey = "com.blizzard.messenger.AUTHENTICATOR_SERIAL"; + final String secretKey = "com.blizzard.messenger.AUTHENTICATOR_DEVICE_SECRET"; + + try { + XmlPullParser parser = Xml.newPullParser(); + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); + parser.setInput(stream, null); + parser.nextTag(); + + String serial = ""; + String secretValue = null; + for (PreferenceParser.XmlEntry entry : PreferenceParser.parse(parser)) { + if (entry.Name.equals(secretKey)) { + secretValue = entry.Value; + } else if (entry.Name.equals(serialKey)) { + serial = entry.Value; + } + } + + if (secretValue == null) { + throw new DatabaseImporterException(String.format("Key not found: %s", secretKey)); + } + + return new BattleNetImporter.State(serial, secretValue); + } catch (XmlPullParserException | IOException e) { + throw new DatabaseImporterException(e); + } + } + + public static class State extends DatabaseImporter.State { + private final String _serial; + private final String _secretValue; + + public State(String serial, String secretValue) { + super(false); + _serial = serial; + _secretValue = secretValue; + } + + @Override + public Result convert() { + Result result = new Result(); + + try { + VaultEntry entry = convertEntry(_serial, _secretValue); + result.addEntry(entry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + + return result; + } + + private static VaultEntry convertEntry(String serial, String secretString) throws DatabaseImporterEntryException { + try { + if (!Strings.isNullOrEmpty(serial)) { + serial = unmask(serial); + } + byte[] secret = Hex.decode(unmask(secretString)); + OtpInfo info = new TotpInfo(secret, OtpInfo.DEFAULT_ALGORITHM, 8, TotpInfo.DEFAULT_PERIOD); + return new VaultEntry(info, serial, "Battle.net"); + } catch (OtpInfoException | EncodingException e) { + throw new DatabaseImporterEntryException(e, secretString); + } + } + + private static String unmask(String s) throws EncodingException { + byte[] ds = Hex.decode(s); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < ds.length; i++) { + char c = (char) (ds[i] ^ _key[i]); + sb.append(c); + } + return sb.toString(); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/BitwardenImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/BitwardenImporter.java new file mode 100644 index 0000000000..b20ec515e1 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/BitwardenImporter.java @@ -0,0 +1,127 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.net.Uri; + +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.otp.GoogleAuthInfoException; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.SteamInfo; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.io.SuFile; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.simpleflatmapper.csv.CsvParser; +import org.simpleflatmapper.lightningcsv.Row; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; + +public class BitwardenImporter extends DatabaseImporter { + public BitwardenImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() { + throw new UnsupportedOperationException(); + } + + @Override + protected State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + String fileString; + try { + fileString = new String(IOUtils.readAll(stream), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + try { + JSONObject obj = new JSONObject(fileString); + JSONArray array = obj.getJSONArray("items"); + + List entries = new ArrayList<>(); + String entry; + for (int i = 0; i < array.length(); i++) { + entry = array.getJSONObject(i).getJSONObject("login").getString("totp"); + if (!entry.isEmpty()) { + entries.add(entry); + } + } + + return new BitwardenImporter.State(entries); + } catch (JSONException e) { + try { + Iterator rowIterator = CsvParser.separator(',').rowIterator(fileString); + List entries = new ArrayList<>(); + rowIterator.forEachRemaining((row -> { + String entry = row.get("login_totp"); + if (entry != null && !entry.isEmpty()) { + entries.add(entry); + } + })); + return new BitwardenImporter.State(entries); + } catch (IOException e2) { + throw new DatabaseImporterException(e2); + } + } + } + + public static class State extends DatabaseImporter.State { + private final List _entries; + + public State(List entries) { + super(false); + _entries = entries; + } + + @Override + public Result convert() { + Result result = new Result(); + + for (String obj : _entries) { + try { + VaultEntry entry = convertEntry(obj); + result.addEntry(entry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + + return result; + } + + private static VaultEntry convertEntry(String obj) throws DatabaseImporterEntryException { + try { + GoogleAuthInfo info = BitwardenImporter.parseUri(obj); + return new VaultEntry(info); + } catch (GoogleAuthInfoException | EncodingException | OtpInfoException | URISyntaxException e) { + throw new DatabaseImporterEntryException(e, obj); + } + } + } + + private static GoogleAuthInfo parseUri(String s) throws EncodingException, OtpInfoException, URISyntaxException, GoogleAuthInfoException { + Uri uri = Uri.parse(s); + if (Objects.equals(uri.getScheme(), "steam")) { + String secretString = uri.getAuthority(); + if (secretString == null) { + throw new GoogleAuthInfoException(uri, "Empty secret (empty authority)"); + } + byte[] secret = Base32.decode(secretString); + return new GoogleAuthInfo(new SteamInfo(secret), "Steam account", "Steam"); + } + + return GoogleAuthInfo.parseUri(uri); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java index 2e5fa4b42f..6c9bc6fecb 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java @@ -8,11 +8,14 @@ import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.util.UUIDMap; import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultGroup; +import com.topjohnwu.superuser.Shell; import com.topjohnwu.superuser.io.SuFile; import com.topjohnwu.superuser.io.SuFileInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.Serializable; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.Collections; @@ -32,12 +35,18 @@ public abstract class DatabaseImporter { _importers.add(new Definition("andOTP", AndOtpImporter.class, R.string.importer_help_andotp, false)); _importers.add(new Definition("Authenticator Plus", AuthenticatorPlusImporter.class, R.string.importer_help_authenticator_plus, false)); _importers.add(new Definition("Authy", AuthyImporter.class, R.string.importer_help_authy, true)); + _importers.add(new Definition("Battle.net Authenticator", BattleNetImporter.class, R.string.importer_help_battle_net_authenticator, true)); + _importers.add(new Definition("Bitwarden", BitwardenImporter.class, R.string.importer_help_bitwarden, false)); + _importers.add(new Definition("Duo", DuoImporter.class, R.string.importer_help_duo, true)); + _importers.add(new Definition("Ente Auth", EnteAuthImporter.class, R.string.importer_help_ente_auth, false)); _importers.add(new Definition("FreeOTP", FreeOtpImporter.class, R.string.importer_help_freeotp, true)); - _importers.add(new Definition("FreeOTP+", FreeOtpPlusImporter.class, R.string.importer_help_freeotp_plus, true)); + _importers.add(new Definition("FreeOTP+ (JSON)", FreeOtpPlusImporter.class, R.string.importer_help_freeotp_plus, true)); _importers.add(new Definition("Google Authenticator", GoogleAuthImporter.class, R.string.importer_help_google_authenticator, true)); _importers.add(new Definition("Microsoft Authenticator", MicrosoftAuthImporter.class, R.string.importer_help_microsoft_authenticator, true)); _importers.add(new Definition("Plain text", GoogleAuthUriImporter.class, R.string.importer_help_plain_text, false)); + _importers.add(new Definition("Proton Authenticator", ProtonAuthenticatorImporter.class, R.string.importer_help_proton_authenticator, false)); _importers.add(new Definition("Steam", SteamImporter.class, R.string.importer_help_steam, true)); + _importers.add(new Definition("Stratum (Authenticator Pro)", StratumImporter.class, R.string.importer_help_stratum, true)); _importers.add(new Definition("TOTP Authenticator", TotpAuthenticatorImporter.class, R.string.importer_help_totp_authenticator, true)); _importers.add(new Definition("WinAuth", WinAuthImporter.class, R.string.importer_help_winauth, false)); } @@ -46,26 +55,32 @@ public DatabaseImporter(Context context) { _context = context; } - protected Context getContext() { + protected Context requireContext() { return _context; } protected abstract SuFile getAppPath() throws DatabaseImporterException, PackageManager.NameNotFoundException; protected SuFile getAppPath(String pkgName, String subPath) throws PackageManager.NameNotFoundException { - PackageManager man = getContext().getPackageManager(); + PackageManager man = requireContext().getPackageManager(); return new SuFile(man.getApplicationInfo(pkgName, 0).dataDir, subPath); } + public boolean isInstalledAppVersionSupported() { + return true; + } + protected abstract State read(InputStream stream, boolean isInternal) throws DatabaseImporterException; public State read(InputStream stream) throws DatabaseImporterException { return read(stream, false); } - public State readFromApp() throws PackageManager.NameNotFoundException, DatabaseImporterException { + public State readFromApp(Shell shell) throws PackageManager.NameNotFoundException, DatabaseImporterException { SuFile file = getAppPath(); - try (SuFileInputStream stream = new SuFileInputStream(file)) { + file.setShell(shell); + + try (InputStream stream = SuFileInputStream.open(file)) { return read(stream, true); } catch (IOException e) { throw new DatabaseImporterException(e); @@ -89,12 +104,19 @@ public static List getImporters(boolean isDirect) { return Collections.unmodifiableList(_importers); } - public static class Definition { + public static class Definition implements Serializable { private final String _name; private final Class _type; private final @StringRes int _help; private final boolean _supportsDirect; + /** + * + * @param name The name of the Authenticator the importer handles. + * @param type The class which does the importing. + * @param help The string that explains the type of file needed (and optionally where it can be obtained). + * @param supportsDirect Whether the importer can directly import the entries from the app's internal storage using root access. + */ public Definition(String name, Class type, @StringRes int help, boolean supportsDirect) { _name = name; _type = type; @@ -149,12 +171,17 @@ public Result convert() throws DatabaseImporterException { public static class Result { private UUIDMap _entries = new UUIDMap<>(); + private UUIDMap _groups = new UUIDMap<>(); private List _errors = new ArrayList<>(); public void addEntry(VaultEntry entry) { _entries.add(entry); } + public void addGroup(VaultGroup group) { + _groups.add(group); + } + public void addError(DatabaseImporterEntryException error) { _errors.add(error); } @@ -163,6 +190,10 @@ public UUIDMap getEntries() { return _entries; } + public UUIDMap getGroups() { + return _groups; + } + public List getErrors() { return _errors; } diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/DuoImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/DuoImporter.java new file mode 100644 index 0000000000..687ab4333c --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/DuoImporter.java @@ -0,0 +1,99 @@ +package com.beemdevelopment.aegis.importers; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import android.content.Context; +import android.content.pm.PackageManager.NameNotFoundException; + +import androidx.annotation.NonNull; + +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.otp.HotpInfo; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.io.SuFile; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; + +public class DuoImporter extends DatabaseImporter { + private static final String _pkgName = "com.duosecurity.duomobile"; + private static final String _subPath = "files/duokit/accounts.json"; + + public DuoImporter(Context context) { + super(context); + } + + @Override + protected @NonNull SuFile getAppPath() throws DatabaseImporterException, NameNotFoundException { + return getAppPath(_pkgName, _subPath); + } + + @Override + protected @NonNull State read( + @NonNull InputStream stream, boolean isInternal + ) throws DatabaseImporterException { + try { + String contents = new String(IOUtils.readAll(stream), UTF_8); + return new DecryptedState(new JSONArray(contents)); + } catch (JSONException | IOException e) { + throw new DatabaseImporterException(e); + } + } + + public static class DecryptedState extends DatabaseImporter.State { + private final JSONArray _array; + + public DecryptedState(@NonNull JSONArray array) { + super(false); + _array = array; + } + + @Override + public @NonNull Result convert() throws DatabaseImporterException { + Result result = new Result(); + + try { + for (int i = 0; i < _array.length(); i++) { + JSONObject entry = _array.getJSONObject(i); + try { + result.addEntry(convertEntry(entry)); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + } catch (JSONException e) { + throw new DatabaseImporterException(e); + } + + return result; + } + + private static @NonNull VaultEntry convertEntry( + @NonNull JSONObject entry + ) throws DatabaseImporterEntryException { + try { + String label = entry.optString("name"); + JSONObject otpData = entry.getJSONObject("otpGenerator"); + byte[] secret = Base32.decode(otpData.getString("otpSecret")); + Long counter = otpData.has("counter") ? otpData.getLong("counter") : null; + + OtpInfo otp = counter == null + ? new TotpInfo(secret) + : new HotpInfo(secret, counter); + + return new VaultEntry(otp, label, ""); + } catch (JSONException | OtpInfoException | EncodingException e) { + throw new DatabaseImporterEntryException(e, entry.toString()); + } + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/EnteAuthImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/EnteAuthImporter.java new file mode 100644 index 0000000000..e9196b5143 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/EnteAuthImporter.java @@ -0,0 +1,32 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; + +import com.beemdevelopment.aegis.util.IOUtils; +import com.topjohnwu.superuser.io.SuFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +public class EnteAuthImporter extends DatabaseImporter { + public EnteAuthImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() { + throw new UnsupportedOperationException(); + } + + @Override + protected State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + try { + byte[] bytes = IOUtils.readAll(stream); + GoogleAuthUriImporter importer = new GoogleAuthUriImporter(requireContext()); + return importer.read(new ByteArrayInputStream(bytes), isInternal); + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpImporter.java index 3fc7be1826..0266ca95e5 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpImporter.java @@ -4,26 +4,53 @@ import android.content.pm.PackageManager; import android.util.Xml; +import androidx.lifecycle.Lifecycle; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.ContextHelper; import com.beemdevelopment.aegis.otp.HotpInfo; import com.beemdevelopment.aegis.otp.OtpInfo; import com.beemdevelopment.aegis.otp.OtpInfoException; import com.beemdevelopment.aegis.otp.SteamInfo; import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.tasks.PBKDFTask; import com.beemdevelopment.aegis.util.PreferenceParser; import com.beemdevelopment.aegis.vault.VaultEntry; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.topjohnwu.superuser.io.SuFile; +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1OctetString; +import org.bouncycastle.asn1.ASN1Primitive; +import org.bouncycastle.asn1.ASN1Sequence; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; +import java.io.BufferedInputStream; +import java.io.DataInputStream; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; public class FreeOtpImporter extends DatabaseImporter { private static final String _subPath = "shared_prefs/tokens.xml"; @@ -40,6 +67,24 @@ protected SuFile getAppPath() throws PackageManager.NameNotFoundException { @Override public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + try (BufferedInputStream bufInStream = new BufferedInputStream(stream); + DataInputStream dataInStream = new DataInputStream(bufInStream)) { + + dataInStream.mark(2); + int magic = dataInStream.readUnsignedShort(); + dataInStream.reset(); + + if (magic == SerializedHashMapParser.MAGIC) { + return readV2(dataInStream); + } else { + return readV1(bufInStream); + } + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + } + + private DecryptedStateV1 readV1(InputStream stream) throws DatabaseImporterException { try { XmlPullParser parser = Xml.newPullParser(); parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); @@ -52,16 +97,184 @@ public State read(InputStream stream, boolean isInternal) throws DatabaseImporte entries.add(new JSONObject(entry.Value)); } } - return new State(entries); + return new DecryptedStateV1(entries); } catch (XmlPullParserException | IOException | JSONException e) { throw new DatabaseImporterException(e); } } - public static class State extends DatabaseImporter.State { - private List _entries; + private EncryptedState readV2(DataInputStream stream) throws DatabaseImporterException { + try { + Map entries = SerializedHashMapParser.parse(stream); + JSONObject mkObj = new JSONObject(entries.get("masterKey")); + return new EncryptedState(mkObj, entries); + } catch (IOException | JSONException | SerializedHashMapParser.ParseException e) { + throw new DatabaseImporterException(e); + } + } + + public static class EncryptedState extends State { + private static final int MASTER_KEY_SIZE = 32 * 8; + + private final String _mkAlgo; + private final String _mkCipher; + private final byte[] _mkCipherText; + private final byte[] _mkParameters; + private final byte[] _mkToken; + private final byte[] _mkSalt; + private final int _mkIterations; + private final Map _entries; + + private EncryptedState(JSONObject mkObj, Map entries) + throws DatabaseImporterException, JSONException { + super(true); + + _mkAlgo = mkObj.getString("mAlgorithm"); + if (!_mkAlgo.equals("PBKDF2withHmacSHA1") && !_mkAlgo.equals("PBKDF2withHmacSHA512")) { + throw new DatabaseImporterException(String.format("Unexpected master key KDF: %s", _mkAlgo)); + } + JSONObject keyObj = mkObj.getJSONObject("mEncryptedKey"); + _mkCipher = keyObj.getString("mCipher"); + if (!_mkCipher.equals("AES/GCM/NoPadding")) { + throw new DatabaseImporterException(String.format("Unexpected master key cipher: %s", _mkCipher)); + } + _mkCipherText = toBytes(keyObj.getJSONArray("mCipherText")); + _mkParameters = toBytes(keyObj.getJSONArray("mParameters")); + _mkToken = keyObj.getString("mToken").getBytes(StandardCharsets.UTF_8); + _mkSalt = toBytes(mkObj.getJSONArray("mSalt")); + _mkIterations = mkObj.getInt("mIterations"); + _entries = entries; + } + + public State decrypt(char[] password) throws DatabaseImporterException { + PBKDFTask.Params params = new PBKDFTask.Params(_mkAlgo, MASTER_KEY_SIZE, password, _mkSalt, _mkIterations); + SecretKey passKey = PBKDFTask.deriveKey(params); + return decrypt(passKey); + } + + public State decrypt(SecretKey passKey) throws DatabaseImporterException { + byte[] masterKeyBytes; + try { + byte[] nonce = parseNonce(_mkParameters); + IvParameterSpec spec = new IvParameterSpec(nonce); + Cipher cipher = Cipher.getInstance(_mkCipher); + cipher.init(Cipher.DECRYPT_MODE, passKey, spec); + cipher.updateAAD(_mkToken); + masterKeyBytes = cipher.doFinal(_mkCipherText); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | BadPaddingException | + IllegalBlockSizeException | InvalidKeyException | + InvalidAlgorithmParameterException | IOException e) { + throw new DatabaseImporterException(e); + } + + SecretKey masterKey = new SecretKeySpec(masterKeyBytes, 0, masterKeyBytes.length, "AES"); + return new DecryptedStateV2(_entries, masterKey); + } + + @Override + public void decrypt(Context context, DecryptListener listener) { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(context, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(R.string.importer_warning_title_freeotp2) + .setMessage(R.string.importer_warning_message_freeotp2) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setCancelable(false) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + Dialogs.showPasswordInputDialog(context, R.string.enter_password_aegis_title, 0, password -> { + PBKDFTask.Params params = getKeyDerivationParams(password, _mkAlgo); + PBKDFTask task = new PBKDFTask(context, key -> { + try { + State state = decrypt(key); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }); + Lifecycle lifecycle = ContextHelper.getLifecycle(context); + task.execute(lifecycle, params); + }, dialog1 -> listener.onCanceled()); + }) + .create()); + } + + private PBKDFTask.Params getKeyDerivationParams(char[] password, String algo) { + return new PBKDFTask.Params(algo, MASTER_KEY_SIZE, password, _mkSalt, _mkIterations); + } + } + + public static class DecryptedStateV2 extends DatabaseImporter.State { + private final Map _entries; + private final SecretKey _masterKey; + + public DecryptedStateV2(Map entries, SecretKey masterKey) { + super(false); + _entries = entries; + _masterKey = masterKey; + } + + @Override + public Result convert() throws DatabaseImporterException { + Result result = new Result(); + + for (Map.Entry entry : _entries.entrySet()) { + if (entry.getKey().endsWith("-token") || entry.getKey().equals("masterKey")) { + continue; + } + + try { + JSONObject encObj = new JSONObject(entry.getValue()); + String tokenKey = String.format("%s-token", entry.getKey()); + JSONObject tokenObj = new JSONObject(_entries.get(tokenKey)); + + VaultEntry vaultEntry = convertEntry(encObj, tokenObj); + result.addEntry(vaultEntry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } catch (JSONException ignored) { + } + } + + return result; + } + + private VaultEntry convertEntry(JSONObject encObj, JSONObject tokenObj) + throws DatabaseImporterEntryException { + try { + JSONObject keyObj = new JSONObject(encObj.getString("key")); + String cipherName = keyObj.getString("mCipher"); + if (!cipherName.equals("AES/GCM/NoPadding")) { + throw new DatabaseImporterException(String.format("Unexpected cipher: %s", cipherName)); + } + byte[] cipherText = toBytes(keyObj.getJSONArray("mCipherText")); + byte[] parameters = toBytes(keyObj.getJSONArray("mParameters")); + byte[] token = keyObj.getString("mToken").getBytes(StandardCharsets.UTF_8); + + byte[] nonce = parseNonce(parameters); + IvParameterSpec spec = new IvParameterSpec(nonce); + Cipher cipher = Cipher.getInstance(cipherName); + cipher.init(Cipher.DECRYPT_MODE, _masterKey, spec); + cipher.updateAAD(token); + byte[] secretBytes = cipher.doFinal(cipherText); + + JSONArray secretArray = new JSONArray(); + for (byte b : secretBytes) { + secretArray.put(b); + } + tokenObj.put("secret", secretArray); + + return DecryptedStateV1.convertEntry(tokenObj); + } catch (DatabaseImporterException | JSONException | NoSuchAlgorithmException | + NoSuchPaddingException | InvalidAlgorithmParameterException | + InvalidKeyException | BadPaddingException | IllegalBlockSizeException | + IOException e) { + throw new DatabaseImporterEntryException(e, tokenObj.toString()); + } + } + } + + public static class DecryptedStateV1 extends DatabaseImporter.State { + private final List _entries; - public State(List entries) { + public DecryptedStateV1(List entries) { super(false); _entries = entries; } @@ -85,8 +298,8 @@ public Result convert() { private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEntryException { try { String type = obj.getString("type").toLowerCase(Locale.ROOT); - String algo = obj.getString("algo"); - int digits = obj.getInt("digits"); + String algo = obj.optString("algo", OtpInfo.DEFAULT_ALGORITHM); + int digits = obj.optInt("digits", OtpInfo.DEFAULT_DIGITS); byte[] secret = toBytes(obj.getJSONArray("secret")); String issuer = obj.getString("issuerExt"); @@ -95,7 +308,7 @@ private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEn OtpInfo info; switch (type) { case "totp": - int period = obj.getInt("period"); + int period = obj.optInt("period", TotpInfo.DEFAULT_PERIOD); if (issuer.equals("Steam")) { info = new SteamInfo(secret, algo, digits, period); } else { @@ -116,6 +329,23 @@ private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEn } } + private static byte[] parseNonce(byte[] parameters) throws IOException { + ASN1Primitive prim = ASN1Sequence.fromByteArray(parameters); + if (prim instanceof ASN1OctetString) { + return ((ASN1OctetString) prim).getOctets(); + } + + if (prim instanceof ASN1Sequence) { + for (ASN1Encodable enc : (ASN1Sequence) prim) { + if (enc instanceof ASN1OctetString) { + return ((ASN1OctetString) enc).getOctets(); + } + } + } + + throw new IOException("Unable to find nonce in parameters"); + } + private static byte[] toBytes(JSONArray array) throws JSONException { byte[] bytes = new byte[array.length()]; for (int i = 0; i < array.length(); i++) { @@ -123,4 +353,119 @@ private static byte[] toBytes(JSONArray array) throws JSONException { } return bytes; } + private static class SerializedHashMapParser { + private static final int MAGIC = 0xaced; + private static final int VERSION = 5; + private static final long SERIAL_VERSION_UID = 362498820763181265L; + + private static final byte TC_NULL = 0x70; + private static final byte TC_CLASSDESC = 0x72; + private static final byte TC_OBJECT = 0x73; + private static final byte TC_STRING = 0x74; + + private SerializedHashMapParser() { + + } + + public static Map parse(DataInputStream inStream) + throws IOException, ParseException { + Map map = new HashMap<>(); + + // Read/validate the magic number and version + int magic = inStream.readUnsignedShort(); + int version = inStream.readUnsignedShort(); + if (magic != MAGIC || version != VERSION) { + throw new ParseException("Not a serialized Java Object"); + } + + // Read the class descriptor info for HashMap + byte b = inStream.readByte(); + if (b != TC_OBJECT) { + throw new ParseException("Expected an object, found: " + b); + } + b = inStream.readByte(); + if (b != TC_CLASSDESC) { + throw new ParseException("Expected a class desc, found: " + b); + } + parseClassDescriptor(inStream); + + // Not interested in the capacity of the map + inStream.readInt(); + // Read the number of elements in the HashMap + int size = inStream.readInt(); + + // Parse each key-value pair in the map + for (int i = 0; i < size; i++) { + String key = parseStringObject(inStream); + String value = parseStringObject(inStream); + map.put(key, value); + } + + return map; + } + + private static void parseClassDescriptor(DataInputStream inputStream) + throws IOException, ParseException { + // Check whether we're dealing with a HashMap and a version we support + String className = parseUTF(inputStream); + if (!className.equals(HashMap.class.getName())) { + throw new ParseException(String.format("Unexpected class name: %s", className)); + } + long serialVersionUID = inputStream.readLong(); + if (serialVersionUID != SERIAL_VERSION_UID) { + throw new ParseException(String.format("Unexpected serial version UID: %d", serialVersionUID)); + } + + // Read past all of the fields in the class + byte fieldDescriptor = inputStream.readByte(); + if (fieldDescriptor == TC_NULL) { + return; + } + int totalFieldSkip = 0; + int fieldCount = inputStream.readUnsignedShort(); + for (int i = 0; i < fieldCount; i++) { + char fieldType = (char) inputStream.readByte(); + parseUTF(inputStream); + switch (fieldType) { + case 'F': // float (4 bytes) + case 'I': // int (4 bytes) + totalFieldSkip += 4; + break; + default: + throw new ParseException(String.format("Unexpected field type: %s", fieldType)); + } + } + inputStream.skipBytes(totalFieldSkip); + + // Not sure what these bytes are, just skip them + inputStream.skipBytes(4); + } + + private static String parseStringObject(DataInputStream inputStream) + throws IOException, ParseException { + byte objectType = inputStream.readByte(); + if (objectType != TC_STRING) { + throw new ParseException(String.format("Expected a string object, found: %d", objectType)); + } + + int length = inputStream.readUnsignedShort(); + byte[] strBytes = new byte[length]; + inputStream.readFully(strBytes); + + return new String(strBytes, StandardCharsets.UTF_8); + } + + private static String parseUTF(DataInputStream inputStream) throws IOException { + int length = inputStream.readUnsignedShort(); + byte[] strBytes = new byte[length]; + inputStream.readFully(strBytes); + return new String(strBytes, StandardCharsets.UTF_8); + } + + private static class ParseException extends Exception { + public ParseException(String message) { + super(message); + } + } + } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpPlusImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpPlusImporter.java index 3761658dda..02cbd19949 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpPlusImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpPlusImporter.java @@ -34,7 +34,7 @@ public State read(InputStream stream, boolean isInternal) throws DatabaseImporte State state; if (isInternal) { - state = new FreeOtpImporter(getContext()).read(stream); + state = new FreeOtpImporter(requireContext()).read(stream); } else { try { String json = new String(IOUtils.readAll(stream), StandardCharsets.UTF_8); @@ -46,7 +46,7 @@ public State read(InputStream stream, boolean isInternal) throws DatabaseImporte entries.add(array.getJSONObject(i)); } - state = new FreeOtpImporter.State(entries); + state = new FreeOtpImporter.DecryptedStateV1(entries); } catch (IOException | JSONException e) { throw new DatabaseImporterException(e); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/GoogleAuthImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/GoogleAuthImporter.java index 74987d3971..267a73a910 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/GoogleAuthImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/GoogleAuthImporter.java @@ -1,9 +1,11 @@ package com.beemdevelopment.aegis.importers; import android.content.Context; +import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.database.Cursor; +import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.encoding.EncodingException; import com.beemdevelopment.aegis.otp.GoogleAuthInfo; import com.beemdevelopment.aegis.otp.HotpInfo; @@ -11,6 +13,7 @@ import com.beemdevelopment.aegis.otp.OtpInfoException; import com.beemdevelopment.aegis.otp.TotpInfo; import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.Shell; import com.topjohnwu.superuser.io.SuFile; import java.io.InputStream; @@ -29,30 +32,49 @@ public GoogleAuthImporter(Context context) { @Override protected SuFile getAppPath() throws PackageManager.NameNotFoundException { - return getAppPath(_pkgName, _subPath); + SuFile file = getAppPath(_pkgName, _subPath); + return file; } - + + @Override + public boolean isInstalledAppVersionSupported() { + PackageInfo info; + try { + info = requireContext().getPackageManager().getPackageInfo(_pkgName, 0); + } catch (PackageManager.NameNotFoundException e) { + return false; + } + + return info.versionCode <= 5000100; + } + @Override public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { - SqlImporterHelper helper = new SqlImporterHelper(getContext()); + final Context context = requireContext(); + SqlImporterHelper helper = new SqlImporterHelper(context); List entries = helper.read(Entry.class, stream, "accounts"); - return new State(entries); + return new State(entries, context); } @Override - public DatabaseImporter.State readFromApp() throws PackageManager.NameNotFoundException, DatabaseImporterException { + public DatabaseImporter.State readFromApp(Shell shell) throws PackageManager.NameNotFoundException, DatabaseImporterException { SuFile path = getAppPath(); - SqlImporterHelper helper = new SqlImporterHelper(getContext()); + path.setShell(shell); + + final Context context = requireContext(); + SqlImporterHelper helper = new SqlImporterHelper(context); List entries = helper.read(Entry.class, path, "accounts"); - return new State(entries); + return new State(entries, context); } public static class State extends DatabaseImporter.State { private List _entries; + private Context _context; - private State(List entries) { + private State(List entries, Context context) { super(false); _entries = entries; + _context = context; } @Override @@ -61,7 +83,7 @@ public Result convert() { for (Entry sqlEntry : _entries) { try { - VaultEntry entry = convertEntry(sqlEntry); + VaultEntry entry = convertEntry(sqlEntry, _context); result.addEntry(entry); } catch (DatabaseImporterEntryException e) { result.addError(e); @@ -71,8 +93,11 @@ public Result convert() { return result; } - private static VaultEntry convertEntry(Entry entry) throws DatabaseImporterEntryException { + private static VaultEntry convertEntry(Entry entry, Context context) throws DatabaseImporterEntryException { try { + if (entry.isEncrypted()) { + throw new DatabaseImporterException(context.getString(R.string.importer_encrypted_exception_google_authenticator, entry.getEmail())); + } byte[] secret = GoogleAuthInfo.parseSecret(entry.getSecret()); OtpInfo info; @@ -102,6 +127,7 @@ private static VaultEntry convertEntry(Entry entry) throws DatabaseImporterEntry private static class Entry extends SqlImporterHelper.Entry { private int _type; + private boolean _isEncrypted; private String _secret; private String _email; private String _issuer; @@ -114,12 +140,18 @@ public Entry(Cursor cursor) { _email = SqlImporterHelper.getString(cursor, "email", ""); _issuer = SqlImporterHelper.getString(cursor, "issuer", ""); _counter = SqlImporterHelper.getLong(cursor, "counter"); + _isEncrypted = (cursor.getColumnIndex("isencrypted") != -1 && SqlImporterHelper.getInt(cursor, "isencrypted") > 0); } + public int getType() { return _type; } + public boolean isEncrypted() { + return _isEncrypted; + } + public String getSecret() { return _secret; } diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/MicrosoftAuthImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/MicrosoftAuthImporter.java index f2920e99ee..4e8428445b 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/MicrosoftAuthImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/MicrosoftAuthImporter.java @@ -11,6 +11,7 @@ import com.beemdevelopment.aegis.otp.OtpInfoException; import com.beemdevelopment.aegis.otp.TotpInfo; import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.Shell; import com.topjohnwu.superuser.io.SuFile; import java.io.InputStream; @@ -34,15 +35,17 @@ protected SuFile getAppPath() throws PackageManager.NameNotFoundException { @Override public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { - SqlImporterHelper helper = new SqlImporterHelper(getContext()); + SqlImporterHelper helper = new SqlImporterHelper(requireContext()); List entries = helper.read(Entry.class, stream, "accounts"); return new State(entries); } @Override - public DatabaseImporter.State readFromApp() throws PackageManager.NameNotFoundException, DatabaseImporterException { + public DatabaseImporter.State readFromApp(Shell shell) throws PackageManager.NameNotFoundException, DatabaseImporterException { SuFile path = getAppPath(); - SqlImporterHelper helper = new SqlImporterHelper(getContext()); + path.setShell(shell); + + SqlImporterHelper helper = new SqlImporterHelper(requireContext()); List entries = helper.read(Entry.class, path, "accounts"); return new State(entries); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/ProtonAuthenticatorImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/ProtonAuthenticatorImporter.java new file mode 100644 index 0000000000..2ac34db4e4 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/ProtonAuthenticatorImporter.java @@ -0,0 +1,96 @@ +package com.beemdevelopment.aegis.importers; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.otp.GoogleAuthInfoException; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.io.SuFile; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; + +public class ProtonAuthenticatorImporter extends DatabaseImporter { + + public ProtonAuthenticatorImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() { + throw new UnsupportedOperationException(); + } + + @Override + protected @NonNull State read(@NonNull InputStream stream, boolean isInternal) throws DatabaseImporterException { + try { + String contents = new String(IOUtils.readAll(stream), UTF_8); + JSONObject json = new JSONObject(contents); + + return new DecryptedState(json); + } catch (JSONException | IOException e) { + throw new DatabaseImporterException(e); + } + } + + public static class DecryptedState extends DatabaseImporter.State { + private final JSONObject _json; + + public DecryptedState(@NonNull JSONObject json) { + super(false); + _json = json; + } + + @Override + public @NonNull Result convert() throws DatabaseImporterException { + Result result = new Result(); + + try { + JSONArray entries = _json.getJSONArray("entries"); + for (int i = 0; i < entries.length(); i++) { + JSONObject entry = entries.getJSONObject(i); + try { + result.addEntry(convertEntry(entry)); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + } catch (JSONException e) { + throw new DatabaseImporterException(e); + } + + return result; + } + + private static @NonNull VaultEntry convertEntry(@NonNull JSONObject entry) throws DatabaseImporterEntryException { + try { + JSONObject content = entry.getJSONObject("content"); + String name = content.getString("name"); + String uriString = content.getString("uri"); + + Uri uri = Uri.parse(uriString); + try { + GoogleAuthInfo info = GoogleAuthInfo.parseUri(uri); + OtpInfo otp = info.getOtpInfo(); + + return new VaultEntry(otp, name, info.getIssuer()); + } catch (GoogleAuthInfoException e) { + throw new DatabaseImporterEntryException(e, uriString); + } + } catch (JSONException e) { + throw new DatabaseImporterEntryException(e, entry.toString()); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/SqlImporterHelper.java b/app/src/main/java/com/beemdevelopment/aegis/importers/SqlImporterHelper.java index efb5496813..f1403d961d 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/SqlImporterHelper.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/SqlImporterHelper.java @@ -1,5 +1,7 @@ package com.beemdevelopment.aegis.importers; +import static android.database.sqlite.SQLiteDatabase.OPEN_READONLY; + import android.annotation.SuppressLint; import android.content.Context; import android.database.Cursor; @@ -19,8 +21,6 @@ import java.util.ArrayList; import java.util.List; -import static android.database.sqlite.SQLiteDatabase.OPEN_READONLY; - public class SqlImporterHelper { private Context _context; @@ -36,7 +36,7 @@ public List read(Class type, SuFile path, String table) for (SuFile file : SqlImporterHelper.findDatabaseFiles(path)) { // create temporary copies of the database files so that SQLiteDatabase can open them File fileCopy = null; - try (SuFileInputStream inStream = new SuFileInputStream(file)) { + try (InputStream inStream = SuFileInputStream.open(file)) { fileCopy = new File(dir, file.getName()); try (FileOutputStream out = new FileOutputStream(fileCopy)) { IOUtils.copy(inStream, out); diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/SteamImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/SteamImporter.java index 3ef362777c..bdb550ef32 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/SteamImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/SteamImporter.java @@ -1,6 +1,7 @@ package com.beemdevelopment.aegis.importers; import android.content.Context; +import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import com.beemdevelopment.aegis.encoding.Base64; @@ -17,6 +18,10 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.sql.Array; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; public class SteamImporter extends DatabaseImporter { private static final String _subDir = "files"; @@ -39,34 +44,60 @@ protected SuFile getAppPath() throws DatabaseImporterException, PackageManager.N return files[0]; } + @Override + public boolean isInstalledAppVersionSupported() { + PackageInfo info; + try { + info = requireContext().getPackageManager().getPackageInfo(_pkgName, 0); + } catch (PackageManager.NameNotFoundException e) { + return false; + } + + return info.versionCode < 7460894; + } + @Override public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { try { byte[] bytes = IOUtils.readAll(stream); JSONObject obj = new JSONObject(new String(bytes, StandardCharsets.UTF_8)); - return new State(obj); + + List objs = new ArrayList<>(); + if (obj.has("accounts")) { + JSONObject accounts = obj.getJSONObject("accounts"); + Iterator keys = accounts.keys(); + while (keys.hasNext()) { + String key = keys.next(); + objs.add(accounts.getJSONObject(key)); + } + } else { + objs.add(obj); + } + return new State(objs); } catch (IOException | JSONException e) { throw new DatabaseImporterException(e); } } public static class State extends DatabaseImporter.State { - private JSONObject _obj; + private final List _objs; - private State(JSONObject obj) { + private State(List objs) { super(false); - _obj = obj; + _objs = objs; } @Override public Result convert() { Result result = new Result(); - try { - VaultEntry entry = convertEntry(_obj); - result.addEntry(entry); - } catch (DatabaseImporterEntryException e) { - result.addError(e); + for (JSONObject obj : _objs) { + try { + VaultEntry entry = convertEntry(obj); + result.addEntry(entry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } } return result; diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/StratumImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/StratumImporter.java new file mode 100644 index 0000000000..7504d98aa6 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/StratumImporter.java @@ -0,0 +1,380 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.Cursor; + +import androidx.lifecycle.Lifecycle; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.helpers.ContextHelper; +import com.beemdevelopment.aegis.otp.HotpInfo; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.SteamInfo; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.tasks.Argon2Task; +import com.beemdevelopment.aegis.ui.tasks.PBKDFTask; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.io.SuFile; + +import org.bouncycastle.crypto.params.Argon2Parameters; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UTFDataFormatException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.List; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; + +public class StratumImporter extends DatabaseImporter { + private static final String HEADER = "AUTHENTICATORPRO"; + private static final String HEADER_LEGACY = "AuthenticatorPro"; + private static final String PKG_NAME = "com.stratumauth.app"; + private static final String PKG_DB_PATH = "databases/authenticator.db3"; + + private enum Algorithm { + SHA1, + SHA256, + SHA512 + } + + public StratumImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() throws DatabaseImporterException, PackageManager.NameNotFoundException { + return getAppPath(PKG_NAME, PKG_DB_PATH); + } + + @Override + protected State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + return isInternal ? readInternal(stream) : readExternal(stream); + } + + private State readInternal(InputStream stream) throws DatabaseImporterException { + List entries = new SqlImporterHelper(requireContext()).read(SqlEntry.class, stream, "authenticator"); + return new SqlState(entries); + } + + private static State readExternal(InputStream stream) throws DatabaseImporterException { + byte[] data; + try { + data = IOUtils.readAll(stream); + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + + try { + return new JsonState(new JSONObject(new String(data, StandardCharsets.UTF_8))); + } catch (JSONException e) { + return readEncrypted(new DataInputStream(new ByteArrayInputStream(data))); + } + } + + private static State readEncrypted(DataInputStream stream) throws DatabaseImporterException { + try { + byte[] headerBytes = new byte[HEADER.getBytes(StandardCharsets.UTF_8).length]; + stream.readFully(headerBytes); + String header = new String(headerBytes, StandardCharsets.UTF_8); + switch (header) { + case HEADER: + return EncryptedState.parseHeader(stream); + case HEADER_LEGACY: + return LegacyEncryptedState.parseHeader(stream); + default: + throw new DatabaseImporterException("Invalid file header"); + } + } catch (UTFDataFormatException e) { + throw new DatabaseImporterException("Invalid file header"); + } catch (IOException | NoSuchPaddingException | NoSuchAlgorithmException e) { + throw new DatabaseImporterException(e); + } + } + + private static OtpInfo parseOtpInfo(int type, byte[] secret, Algorithm algo, int digits, int period, int counter) + throws OtpInfoException, DatabaseImporterEntryException { + switch (type) { + case 1: + return new HotpInfo(secret, algo.name(), digits, counter); + case 2: + return new TotpInfo(secret, algo.name(), digits, period); + case 4: + return new SteamInfo(secret, algo.name(), digits, period); + default: + throw new DatabaseImporterEntryException(String.format("Unsupported otp type: %d", type), null); + } + } + + static class EncryptedState extends State { + private static final int KEY_SIZE = 32; + private static final int MEMORY_COST = 16; // 2^16 KiB = 64 MiB + private static final int PARALLELISM = 4; + private static final int ITERATIONS = 3; + private static final int SALT_SIZE = 16; + private static final int IV_SIZE = 12; + + private final Cipher _cipher; + private final byte[] _salt; + private final byte[] _iv; + private final byte[] _data; + + public EncryptedState(Cipher cipher, byte[] salt, byte[] iv, byte[] data) { + super(true); + _cipher = cipher; + _salt = salt; + _iv = iv; + _data = data; + } + + public JsonState decrypt(char[] password) throws DatabaseImporterException { + Argon2Task.Params params = getKeyDerivationParams(password); + SecretKey key = Argon2Task.deriveKey(params); + return decrypt(key); + } + + public JsonState decrypt(SecretKey key) throws DatabaseImporterException { + try { + _cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(_iv)); + byte[] decrypted = _cipher.doFinal(_data); + return new JsonState(new JSONObject(new String(decrypted, StandardCharsets.UTF_8))); + } catch (InvalidAlgorithmParameterException | IllegalBlockSizeException + | JSONException | InvalidKeyException | BadPaddingException e) { + throw new DatabaseImporterException(e); + } + } + + @Override + public void decrypt(Context context, DecryptListener listener) throws DatabaseImporterException { + Dialogs.showPasswordInputDialog(context, R.string.enter_password_aegis_title, 0, (Dialogs.TextInputListener) password -> { + Argon2Task.Params params = getKeyDerivationParams(password); + Argon2Task task = new Argon2Task(context, key -> { + try { + StratumImporter.JsonState state = decrypt(key); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }); + Lifecycle lifecycle = ContextHelper.getLifecycle(context); + task.execute(lifecycle, params); + }, dialog -> listener.onCanceled()); + } + + private Argon2Task.Params getKeyDerivationParams(char[] password) { + Argon2Parameters argon2Params = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withIterations(ITERATIONS) + .withParallelism(PARALLELISM) + .withMemoryPowOfTwo(MEMORY_COST) + .withSalt(_salt) + .build(); + return new Argon2Task.Params(password, argon2Params, KEY_SIZE); + } + + private static EncryptedState parseHeader(DataInputStream stream) + throws IOException, NoSuchPaddingException, NoSuchAlgorithmException { + byte[] salt = new byte[SALT_SIZE]; + stream.readFully(salt); + + byte[] iv = new byte[IV_SIZE]; + stream.readFully(iv); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + return new EncryptedState(cipher, salt, iv, IOUtils.readAll(stream)); + } + } + + static class LegacyEncryptedState extends State { + private static final int ITERATIONS = 64000; + private static final int KEY_SIZE = 32 * Byte.SIZE; + private static final int SALT_SIZE = 20; + + private final Cipher _cipher; + private final byte[] _salt; + private final byte[] _iv; + private final byte[] _data; + + public LegacyEncryptedState(Cipher cipher, byte[] salt, byte[] iv, byte[] data) { + super(true); + _cipher = cipher; + _salt = salt; + _iv = iv; + _data = data; + } + + public JsonState decrypt(char[] password) throws DatabaseImporterException { + PBKDFTask.Params params = getKeyDerivationParams(password); + SecretKey key = PBKDFTask.deriveKey(params); + return decrypt(key); + } + + public JsonState decrypt(SecretKey key) throws DatabaseImporterException { + try { + _cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(_iv)); + byte[] decrypted = _cipher.doFinal(_data); + return new JsonState(new JSONObject(new String(decrypted, StandardCharsets.UTF_8))); + } catch (InvalidAlgorithmParameterException | IllegalBlockSizeException + | JSONException | InvalidKeyException | BadPaddingException e) { + throw new DatabaseImporterException(e); + } + } + + @Override + public void decrypt(Context context, DecryptListener listener) throws DatabaseImporterException { + Dialogs.showPasswordInputDialog(context, R.string.enter_password_aegis_title, 0, (Dialogs.TextInputListener) password -> { + PBKDFTask.Params params = getKeyDerivationParams(password); + PBKDFTask task = new PBKDFTask(context, key -> { + try { + StratumImporter.JsonState state = decrypt(key); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }); + Lifecycle lifecycle = ContextHelper.getLifecycle(context); + task.execute(lifecycle, params); + }, dialog -> listener.onCanceled()); + } + + private PBKDFTask.Params getKeyDerivationParams(char[] password) { + return new PBKDFTask.Params("PBKDF2WithHmacSHA1", KEY_SIZE, password, _salt, ITERATIONS); + } + + private static LegacyEncryptedState parseHeader(DataInputStream stream) + throws IOException, NoSuchPaddingException, NoSuchAlgorithmException { + byte[] salt = new byte[SALT_SIZE]; + stream.readFully(salt); + + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + int ivSize = cipher.getBlockSize(); + byte[] iv = new byte[ivSize]; + stream.readFully(iv); + return new LegacyEncryptedState(cipher, salt, iv, IOUtils.readAll(stream)); + } + } + + private static class JsonState extends State { + private final JSONObject _obj; + + public JsonState(JSONObject obj) { + super(false); + _obj = obj; + } + + @Override + public Result convert() throws DatabaseImporterException { + Result res = new Result(); + + try { + JSONArray array = _obj.getJSONArray("Authenticators"); + for (int i = 0; i < array.length(); i++) { + JSONObject obj = array.getJSONObject(i); + try { + res.addEntry(convertEntry(obj)); + } catch (DatabaseImporterEntryException e) { + res.addError(e); + } + } + } catch (JSONException e) { + throw new DatabaseImporterException(e); + } + + return res; + } + + private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEntryException { + try { + int type = obj.getInt("Type"); + String issuer = obj.getString("Issuer"); + Object nullableUsername = obj.get("Username"); + String username = nullableUsername == JSONObject.NULL ? "" : nullableUsername.toString(); + byte[] secret = Base32.decode(obj.getString("Secret")); + Algorithm algo = Algorithm.values()[obj.getInt("Algorithm")]; + int digits = obj.getInt("Digits"); + int period = obj.getInt("Period"); + int counter = obj.getInt("Counter"); + + OtpInfo info = parseOtpInfo(type, secret, algo, digits, period, counter); + return new VaultEntry(info, username, issuer); + } catch (OtpInfoException | EncodingException | JSONException e) { + throw new DatabaseImporterEntryException(e, null); + } + } + } + + private static class SqlState extends State { + private final List _entries; + + public SqlState(List entries) { + super(false); + _entries = entries; + } + + @Override + public Result convert() throws DatabaseImporterException { + Result res = new Result(); + + for (SqlEntry entry : _entries) { + try { + res.addEntry(entry.convert()); + } catch (DatabaseImporterEntryException e) { + res.addError(e); + } + } + + return res; + } + } + + private static class SqlEntry extends SqlImporterHelper.Entry { + private final int _type; + private final String _issuer; + private final String _username; + private final String _secret; + private final Algorithm _algo; + private final int _digits; + private final int _period; + private final int _counter; + + public SqlEntry(Cursor cursor) { + super(cursor); + _type = SqlImporterHelper.getInt(cursor, "type"); + _issuer = SqlImporterHelper.getString(cursor, "issuer"); + _username = SqlImporterHelper.getString(cursor, "username"); + _secret = SqlImporterHelper.getString(cursor, "secret"); + _algo = Algorithm.values()[SqlImporterHelper.getInt(cursor, "algorithm")]; + _digits = SqlImporterHelper.getInt(cursor, "digits"); + _period = SqlImporterHelper.getInt(cursor, "period"); + _counter = SqlImporterHelper.getInt(cursor, "counter"); + } + + public VaultEntry convert() throws DatabaseImporterEntryException { + try { + byte[] secret = Base32.decode(_secret); + OtpInfo info = parseOtpInfo(_type, secret, _algo, _digits, _period, _counter); + return new VaultEntry(info, _username, _issuer); + } catch (EncodingException | OtpInfoException e) { + throw new DatabaseImporterEntryException(e, null); + } + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/TotpAuthenticatorImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/TotpAuthenticatorImporter.java index b603eb8696..699f39b231 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/TotpAuthenticatorImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/TotpAuthenticatorImporter.java @@ -4,8 +4,6 @@ import android.content.pm.PackageManager; import android.util.Xml; -import androidx.appcompat.app.AlertDialog; - import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.crypto.CryptoUtils; import com.beemdevelopment.aegis.encoding.Base32; @@ -18,6 +16,7 @@ import com.beemdevelopment.aegis.util.IOUtils; import com.beemdevelopment.aegis.util.PreferenceParser; import com.beemdevelopment.aegis.vault.VaultEntry; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.topjohnwu.superuser.io.SuFile; import org.json.JSONArray; @@ -154,7 +153,7 @@ protected DecryptedState decrypt(char[] password) throws DatabaseImporterExcepti @Override public void decrypt(Context context, DecryptListener listener) { - Dialogs.showSecureDialog(new AlertDialog.Builder(context) + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(context) .setMessage(R.string.choose_totpauth_importer) .setPositiveButton(R.string.yes, (dialog, which) -> { Dialogs.showPasswordInputDialog(context, password -> { diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/TwoFASImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/TwoFASImporter.java index 6a76474aac..f0387afbb2 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/TwoFASImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/TwoFASImporter.java @@ -2,13 +2,21 @@ import android.content.Context; -import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.crypto.CryptoUtils; +import com.beemdevelopment.aegis.encoding.Base64; import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.otp.HotpInfo; import com.beemdevelopment.aegis.otp.OtpInfo; import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.SteamInfo; import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.util.JsonUtils; import com.beemdevelopment.aegis.vault.VaultEntry; +import com.google.common.base.Strings; import com.topjohnwu.superuser.io.SuFile; import org.json.JSONArray; @@ -18,10 +26,27 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; import java.util.ArrayList; import java.util.List; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + public class TwoFASImporter extends DatabaseImporter { + private static final int ITERATION_COUNT = 10_000; + private static final int KEY_SIZE = 256; // bits + public TwoFASImporter(Context context) { super(context); } @@ -37,26 +62,96 @@ public State read(InputStream stream, boolean isInternal) throws DatabaseImporte String json = new String(IOUtils.readAll(stream), StandardCharsets.UTF_8); JSONObject obj = new JSONObject(json); int version = obj.getInt("schemaVersion"); - if (version > 1) { + if (version > 4) { throw new DatabaseImporterException(String.format("Unsupported schema version: %d", version)); } - JSONArray array = obj.getJSONArray("services"); - List entries = new ArrayList<>(); - for (int i = 0; i < array.length(); i++) { - entries.add(array.getJSONObject(i)); + String encryptedString = JsonUtils.optString(obj, "servicesEncrypted"); + if (encryptedString == null) { + JSONArray array = obj.getJSONArray("services"); + List entries = arrayToList(array); + return new DecryptedState(entries); + } + + String[] parts = encryptedString.split(":"); + if (parts.length < 3) { + throw new DatabaseImporterException(String.format("Unexpected format of encrypted data (parts: %d)", parts.length)); } - return new TwoFASImporter.State(entries); + byte[] data = Base64.decode(parts[0]); + byte[] salt = Base64.decode(parts[1]); + byte[] iv = Base64.decode(parts[2]); + return new EncryptedState(data, salt, iv); } catch (IOException | JSONException e) { throw new DatabaseImporterException(e); } } - public static class State extends DatabaseImporter.State { + private static List arrayToList(JSONArray array) throws JSONException { + List list = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + list.add(array.getJSONObject(i)); + } + + return list; + } + + public static class EncryptedState extends State { + private final byte[] _data; + private final byte[] _salt; + private final byte[] _iv; + + private EncryptedState(byte[] data, byte[] salt, byte[] iv) { + super(true); + _data = data; + _salt = salt; + _iv = iv; + } + + private SecretKey deriveKey(char[] password) + throws NoSuchAlgorithmException, InvalidKeySpecException { + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + KeySpec spec = new PBEKeySpec(password, _salt, ITERATION_COUNT, KEY_SIZE); + SecretKey key = factory.generateSecret(spec); + return new SecretKeySpec(key.getEncoded(), "AES"); + } + + public DecryptedState decrypt(char[] password) throws DatabaseImporterException { + try { + SecretKey key = deriveKey(password); + Cipher cipher = CryptoUtils.createDecryptCipher(key, _iv); + byte[] decrypted = cipher.doFinal(_data); + String json = new String(decrypted, StandardCharsets.UTF_8); + return new DecryptedState(arrayToList(new JSONArray(json))); + } catch (BadPaddingException | JSONException e) { + throw new DatabaseImporterException(e); + } catch (NoSuchAlgorithmException + | InvalidKeySpecException + | InvalidAlgorithmParameterException + | NoSuchPaddingException + | InvalidKeyException + | IllegalBlockSizeException e) { + throw new RuntimeException(e); + } + } + + @Override + public void decrypt(Context context, DecryptListener listener) { + Dialogs.showPasswordInputDialog(context, R.string.enter_password_2fas_message, 0, password -> { + try { + DecryptedState state = decrypt(password); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }, dialog -> listener.onCanceled()); + } + } + + public static class DecryptedState extends DatabaseImporter.State { private final List _entries; - public State(List entries) { + public DecryptedState(List entries) { super(false); _entries = entries; } @@ -79,12 +174,31 @@ public Result convert() { private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEntryException { try { - byte[] secret = Base32.decode(obj.getString("secret")); + byte[] secret = GoogleAuthInfo.parseSecret(obj.getString("secret")); JSONObject info = obj.getJSONObject("otp"); - String issuer = info.getString("issuer"); + String issuer = obj.optString("name"); + if (Strings.isNullOrEmpty(issuer)) { + issuer = info.optString("issuer"); + } String name = info.optString("account"); + int digits = info.optInt("digits", TotpInfo.DEFAULT_DIGITS); + String algorithm = info.optString("algorithm", TotpInfo.DEFAULT_ALGORITHM); + + OtpInfo otp; + String tokenType = JsonUtils.optString(info, "tokenType"); + if (tokenType == null || tokenType.equals("TOTP")) { + int period = info.optInt("period", TotpInfo.DEFAULT_PERIOD); + otp = new TotpInfo(secret, algorithm, digits, period); + } else if (tokenType.equals("HOTP")) { + long counter = info.optLong("counter", 0); + otp = new HotpInfo(secret, algorithm, digits, counter); + } else if (tokenType.equals("STEAM")) { + int period = info.optInt("period", TotpInfo.DEFAULT_PERIOD); + otp = new SteamInfo(secret, algorithm, digits, period); + } else { + throw new DatabaseImporterEntryException(String.format("Unrecognized tokenType: %s", tokenType), obj.toString()); + } - OtpInfo otp = new TotpInfo(secret); return new VaultEntry(otp, name, issuer); } catch (OtpInfoException | JSONException | EncodingException e) { throw new DatabaseImporterEntryException(e, obj.toString()); diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/WinAuthImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/WinAuthImporter.java index 6af3ee60f1..ecedf38fe8 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/WinAuthImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/WinAuthImporter.java @@ -19,7 +19,7 @@ protected SuFile getAppPath() { @Override public WinAuthImporter.State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { - GoogleAuthUriImporter importer = new GoogleAuthUriImporter(getContext()); + GoogleAuthUriImporter importer = new GoogleAuthUriImporter(requireContext()); DatabaseImporter.State state = importer.read(stream); return new State(state); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/licenses/GlideLicense.java b/app/src/main/java/com/beemdevelopment/aegis/licenses/GlideLicense.java deleted file mode 100644 index 3d3435d242..0000000000 --- a/app/src/main/java/com/beemdevelopment/aegis/licenses/GlideLicense.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.beemdevelopment.aegis.licenses; - -import android.content.Context; - -import com.beemdevelopment.aegis.R; - -import de.psdev.licensesdialog.licenses.License; - -public class GlideLicense extends License { - @Override - public String getName() { - return "Glide License"; - } - - @Override - public String readSummaryTextFromResources(Context context) { - return getContent(context, R.raw.glide_license); - } - - @Override - public String readFullTextFromResources(Context context) { - return getContent(context, R.raw.glide_license); - } - - @Override - public String getVersion() { - return null; - } - - @Override - public String getUrl() { - return "https://github.com/bumptech/glide/blob/master/LICENSE"; - } -} diff --git a/app/src/main/java/com/beemdevelopment/aegis/licenses/ProtobufLicense.java b/app/src/main/java/com/beemdevelopment/aegis/licenses/ProtobufLicense.java deleted file mode 100644 index f6b937fa46..0000000000 --- a/app/src/main/java/com/beemdevelopment/aegis/licenses/ProtobufLicense.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.beemdevelopment.aegis.licenses; - -import android.content.Context; - -import com.beemdevelopment.aegis.R; - -import de.psdev.licensesdialog.licenses.License; - -public class ProtobufLicense extends License { - @Override - public String getName() { - return "Protocol Buffers License"; - } - - @Override - public String readSummaryTextFromResources(Context context) { - return getContent(context, R.raw.protobuf_license); - } - - @Override - public String readFullTextFromResources(Context context) { - return getContent(context, R.raw.protobuf_license); - } - - @Override - public String getVersion() { - return null; - } - - @Override - public String getUrl() { - return "https://raw.githubusercontent.com/protocolbuffers/protobuf/master/LICENSE"; - } -} diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java index fbfd044d48..f41f155c08 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java @@ -2,17 +2,24 @@ import android.net.Uri; +import androidx.annotation.NonNull; + import com.beemdevelopment.aegis.GoogleAuthProtos; import com.beemdevelopment.aegis.encoding.Base32; import com.beemdevelopment.aegis.encoding.Base64; import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.encoding.Hex; +import com.google.protobuf.ByteString; import com.google.protobuf.InvalidProtocolBufferException; import java.io.Serializable; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; -public class GoogleAuthInfo implements Serializable { +public class GoogleAuthInfo implements Transferable, Serializable { public static final String SCHEME = "otpauth"; public static final String SCHEME_EXPORT = "otpauth-migration"; @@ -26,42 +33,6 @@ public GoogleAuthInfo(OtpInfo info, String accountName, String issuer) { _issuer = issuer; } - public OtpInfo getOtpInfo() { - return _info; - } - - public Uri getUri() { - Uri.Builder builder = new Uri.Builder(); - builder.scheme(SCHEME); - - if (_info instanceof TotpInfo) { - if (_info instanceof SteamInfo) { - builder.authority("steam"); - } else { - builder.authority("totp"); - } - builder.appendQueryParameter("period", Integer.toString(((TotpInfo)_info).getPeriod())); - } else if (_info instanceof HotpInfo) { - builder.authority("hotp"); - builder.appendQueryParameter("counter", Long.toString(((HotpInfo)_info).getCounter())); - } else { - throw new RuntimeException(String.format("Unsupported OtpInfo type: %s", _info.getClass())); - } - - builder.appendQueryParameter("digits", Integer.toString(_info.getDigits())); - builder.appendQueryParameter("algorithm", _info.getAlgorithm(false)); - builder.appendQueryParameter("secret", Base32.encode(_info.getSecret())); - - if (_issuer != null && !_issuer.equals("")) { - builder.path(String.format("%s:%s", _issuer, _accountName)); - builder.appendQueryParameter("issuer", _issuer); - } else { - builder.path(_accountName); - } - - return builder.build(); - } - public static GoogleAuthInfo parseUri(String s) throws GoogleAuthInfoException { Uri uri = Uri.parse(s); if (uri == null) { @@ -72,7 +43,7 @@ public static GoogleAuthInfo parseUri(String s) throws GoogleAuthInfoException { public static GoogleAuthInfo parseUri(Uri uri) throws GoogleAuthInfoException { String scheme = uri.getScheme(); - if (scheme == null || !scheme.equals(SCHEME)) { + if (scheme == null || !(scheme.equals(SCHEME) || scheme.equals(MotpInfo.SCHEME))) { throw new GoogleAuthInfoException(uri, String.format("Unsupported protocol: %s", scheme)); } @@ -84,14 +55,18 @@ public static GoogleAuthInfo parseUri(Uri uri) throws GoogleAuthInfoException { byte[] secret; try { - secret = parseSecret(encodedSecret); + secret = (scheme.equals(MotpInfo.SCHEME)) ? Hex.decode(encodedSecret) : parseSecret(encodedSecret); } catch (EncodingException e) { throw new GoogleAuthInfoException(uri, "Bad secret", e); } + if (secret.length == 0) { + throw new GoogleAuthInfoException(uri, "Secret is empty"); + } OtpInfo info; + String issuer = ""; try { - String type = uri.getHost(); + String type = (scheme.equals(MotpInfo.SCHEME)) ? MotpInfo.ID : uri.getHost(); if (type == null) { throw new GoogleAuthInfoException(uri, String.format("Host not present in URI: %s", uri.toString())); } @@ -122,10 +97,22 @@ public static GoogleAuthInfo parseUri(Uri uri) throws GoogleAuthInfoException { hotpInfo.setCounter(Long.parseLong(counter)); info = hotpInfo; break; + case YandexInfo.HOST_ID: + String pin = uri.getQueryParameter("pin"); + if (pin != null) { + pin = new String(parseSecret(pin), StandardCharsets.UTF_8); + } + + info = new YandexInfo(secret, pin); + issuer = info.getType(); + break; + case MotpInfo.ID: + info = new MotpInfo(secret); + break; default: throw new GoogleAuthInfoException(uri, String.format("Unsupported OTP type: %s", type)); } - } catch (OtpInfoException | NumberFormatException e) { + } catch (OtpInfoException | NumberFormatException | EncodingException e) { throw new GoogleAuthInfoException(uri, e); } @@ -134,7 +121,6 @@ public static GoogleAuthInfo parseUri(Uri uri) throws GoogleAuthInfoException { String label = path != null && path.length() > 0 ? path.substring(1) : ""; String accountName = ""; - String issuer = ""; if (label.contains(":")) { // a label can only contain one colon @@ -151,7 +137,9 @@ public static GoogleAuthInfo parseUri(Uri uri) throws GoogleAuthInfoException { // label only contains the account name // grab the issuer's info from the 'issuer' parameter if it's present String issuerParam = uri.getQueryParameter("issuer"); - issuer = issuerParam != null ? issuerParam : ""; + if (issuer.isEmpty()) { + issuer = issuerParam != null ? issuerParam : ""; + } accountName = label; } @@ -248,6 +236,10 @@ public static Export parseExportUri(Uri uri) throws GoogleAuthInfoException { } byte[] secret = params.getSecret().toByteArray(); + if (secret.length == 0) { + throw new GoogleAuthInfoException(uri, "Secret is empty"); + } + switch (params.getType()) { case OTP_TYPE_UNSPECIFIED: // intentional fallthrough @@ -260,7 +252,7 @@ public static Export parseExportUri(Uri uri) throws GoogleAuthInfoException { default: throw new GoogleAuthInfoException(uri, String.format("Unsupported algorithm: %d", params.getType().ordinal())); } - } catch (OtpInfoException e){ + } catch (OtpInfoException e) { throw new GoogleAuthInfoException(uri, e); } @@ -279,6 +271,55 @@ public static Export parseExportUri(Uri uri) throws GoogleAuthInfoException { return new Export(infos, payload.getBatchId(), payload.getBatchIndex(), payload.getBatchSize()); } + public OtpInfo getOtpInfo() { + return _info; + } + + @Override + public Uri getUri() { + Uri.Builder builder = new Uri.Builder(); + + if (_info instanceof MotpInfo) { + builder.scheme(MotpInfo.SCHEME); + builder.appendQueryParameter("secret", Hex.encode(_info.getSecret())); + } else { + builder.scheme(SCHEME); + + if (_info instanceof TotpInfo) { + if (_info instanceof SteamInfo) { + builder.authority("steam"); + } else if (_info instanceof YandexInfo) { + builder.authority(YandexInfo.HOST_ID); + } else { + builder.authority("totp"); + } + builder.appendQueryParameter("period", Integer.toString(((TotpInfo) _info).getPeriod())); + } else if (_info instanceof HotpInfo) { + builder.authority("hotp"); + builder.appendQueryParameter("counter", Long.toString(((HotpInfo) _info).getCounter())); + } else { + throw new RuntimeException(String.format("Unsupported OtpInfo type: %s", _info.getClass())); + } + + builder.appendQueryParameter("digits", Integer.toString(_info.getDigits())); + builder.appendQueryParameter("algorithm", _info.getAlgorithm(false)); + builder.appendQueryParameter("secret", Base32.encode(_info.getSecret())); + + if (_info instanceof YandexInfo) { + builder.appendQueryParameter("pin", Base32.encode(((YandexInfo) _info).getPin())); + } + } + + if (_issuer != null && !_issuer.equals("")) { + builder.path(String.format("%s:%s", _issuer, _accountName)); + builder.appendQueryParameter("issuer", _issuer); + } else { + builder.path(_accountName); + } + + return builder.build(); + } + public String getIssuer() { return _issuer; } @@ -287,7 +328,7 @@ public String getAccountName() { return _accountName; } - public static class Export { + public static class Export implements Transferable, Serializable { private int _batchId; private int _batchIndex; private int _batchSize; @@ -315,5 +356,110 @@ public int getBatchIndex() { public int getBatchId() { return _batchId; } + + public static List getMissingIndices(@NonNull List exports) throws IllegalArgumentException { + if (!isSingleBatch(exports)) { + throw new IllegalArgumentException("Export list contains entries from different batches"); + } + + List indicesMissing = new ArrayList<>(); + if (exports.isEmpty()) { + return indicesMissing; + } + + Set indicesPresent = exports.stream() + .map(Export::getBatchIndex) + .collect(Collectors.toSet()); + + for (int i = 0; i < exports.get(0).getBatchSize(); i++) { + if (!indicesPresent.contains(i)) { + indicesMissing.add(i); + } + } + + return indicesMissing; + } + + public static boolean isSingleBatch(@NonNull List exports) { + if (exports.isEmpty()) { + return true; + } + + int batchId = exports.get(0).getBatchId(); + for (Export export : exports) { + if (export.getBatchId() != batchId) { + return false; + } + } + + return true; + } + + @Override + public Uri getUri() throws GoogleAuthInfoException { + GoogleAuthProtos.MigrationPayload.Builder builder = GoogleAuthProtos.MigrationPayload.newBuilder(); + builder.setBatchId(_batchId) + .setBatchIndex(_batchIndex) + .setBatchSize(_batchSize) + .setVersion(1); + + for (GoogleAuthInfo info: _entries) { + GoogleAuthProtos.MigrationPayload.OtpParameters.Builder parameters = GoogleAuthProtos.MigrationPayload.OtpParameters.newBuilder() + .setSecret(ByteString.copyFrom(info.getOtpInfo().getSecret())) + .setName(info.getAccountName()) + .setIssuer(info.getIssuer()); + + switch (info.getOtpInfo().getAlgorithm(false)) { + case "SHA1": + parameters.setAlgorithm(GoogleAuthProtos.MigrationPayload.Algorithm.ALGORITHM_SHA1); + break; + case "SHA256": + parameters.setAlgorithm(GoogleAuthProtos.MigrationPayload.Algorithm.ALGORITHM_SHA256); + break; + case "SHA512": + parameters.setAlgorithm(GoogleAuthProtos.MigrationPayload.Algorithm.ALGORITHM_SHA512); + break; + case "MD5": + parameters.setAlgorithm(GoogleAuthProtos.MigrationPayload.Algorithm.ALGORITHM_MD5); + break; + default: + throw new GoogleAuthInfoException(info.getUri(), String.format("Unsupported Algorithm: %s", info.getOtpInfo().getAlgorithm(false))); + } + + switch (info.getOtpInfo().getDigits()) { + case 6: + parameters.setDigits(GoogleAuthProtos.MigrationPayload.DigitCount.DIGIT_COUNT_SIX); + break; + case 8: + parameters.setDigits(GoogleAuthProtos.MigrationPayload.DigitCount.DIGIT_COUNT_EIGHT); + break; + default: + throw new GoogleAuthInfoException(info.getUri(), String.format("Unsupported number of digits: %s", info.getOtpInfo().getDigits())); + } + + switch (info.getOtpInfo().getType().toLowerCase()) { + case HotpInfo.ID: + parameters.setType(GoogleAuthProtos.MigrationPayload.OtpType.OTP_TYPE_HOTP); + parameters.setCounter(((HotpInfo) info.getOtpInfo()).getCounter()); + break; + case TotpInfo.ID: + parameters.setType(GoogleAuthProtos.MigrationPayload.OtpType.OTP_TYPE_TOTP); + break; + default: + throw new GoogleAuthInfoException(info.getUri(), String.format("Type unsupported by GoogleAuthProtos: %s", info.getOtpInfo().getType())); + } + + builder.addOtpParameters(parameters.build()); + } + + Uri.Builder exportUriBuilder = new Uri.Builder() + .scheme(SCHEME_EXPORT) + .authority("offline"); + + String data = Base64.encode(builder.build().toByteArray()); + exportUriBuilder.appendQueryParameter("data", data); + + return exportUriBuilder.build(); + } } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfoException.java b/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfoException.java index c26cd3f5fa..c912a5d5a6 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfoException.java +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfoException.java @@ -30,7 +30,9 @@ public boolean isPhoneFactor() { @Override public String getMessage() { Throwable cause = getCause(); - if (cause == null) { + if (cause == null + || this == cause + || (super.getMessage() != null && super.getMessage().equals(cause.getMessage()))) { return super.getMessage(); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/HotpInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/HotpInfo.java index 2d4a0a914b..7fc91fcaa0 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/otp/HotpInfo.java +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/HotpInfo.java @@ -30,7 +30,9 @@ public HotpInfo(byte[] secret, String algorithm, int digits, long counter) throw } @Override - public String getOtp() { + public String getOtp() throws OtpInfoException { + checkSecret(); + try { OTP otp = HOTP.generateOTP(getSecret(), getAlgorithm(true), getDigits(), getCounter()); return otp.toString(); diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/MotpInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/MotpInfo.java new file mode 100644 index 0000000000..585d54857d --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/MotpInfo.java @@ -0,0 +1,81 @@ +package com.beemdevelopment.aegis.otp; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.beemdevelopment.aegis.crypto.otp.MOTP; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.security.NoSuchAlgorithmException; +import java.util.Objects; + +public class MotpInfo extends TotpInfo { + public static final String ID = "motp"; + public static final String SCHEME = "motp"; + public static final String ALGORITHM = "MD5"; + + public static final int PERIOD = 10; + public static final int DIGITS = 6; + + private String _pin; + + public MotpInfo(@NonNull byte[] secret) throws OtpInfoException { + this(secret, null); + } + + public MotpInfo(byte[] secret, String pin) throws OtpInfoException { + super(secret, ALGORITHM, DIGITS, PERIOD); + setPin(pin); + } + + @Override + public String getOtp(long time) { + if (_pin == null) { + throw new IllegalStateException("PIN must be set before generating an OTP"); + } + + try { + MOTP otp = MOTP.generateOTP(getSecret(), getAlgorithm(false), getDigits(), getPeriod(), getPin(), time); + return otp.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + @Override + public String getTypeId() { + return ID; + } + + @Override + public JSONObject toJson() { + JSONObject result = super.toJson(); + try { + result.put("pin", getPin()); + } catch (JSONException e) { + throw new RuntimeException(e); + } + return result; + } + + @Nullable + public String getPin() { + return _pin; + } + + public void setPin(@NonNull String pin) { + this._pin = pin; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof MotpInfo)) { + return false; + } + + MotpInfo info = (MotpInfo) o; + return super.equals(o) && Objects.equals(getPin(), info.getPin()); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfo.java index a340861084..3fbaa96872 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfo.java +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfo.java @@ -28,7 +28,13 @@ public OtpInfo(byte[] secret, String algorithm, int digits) throws OtpInfoExcept setDigits(digits); } - public abstract String getOtp(); + public abstract String getOtp() throws OtpInfoException; + + protected void checkSecret() throws OtpInfoException { + if (getSecret().length == 0) { + throw new OtpInfoException("Secret is empty"); + } + } public abstract String getTypeId(); @@ -40,7 +46,7 @@ public JSONObject toJson() { JSONObject obj = new JSONObject(); try { - obj.put("secret", new String(Base32.encode(getSecret()))); + obj.put("secret", Base32.encode(getSecret())); obj.put("algo", getAlgorithm(false)); obj.put("digits", getDigits()); } catch (JSONException e) { @@ -70,7 +76,8 @@ public void setSecret(byte[] secret) { } public static boolean isAlgorithmValid(String algorithm) { - return algorithm.equals("SHA1") || algorithm.equals("SHA256") || algorithm.equals("SHA512"); + return algorithm.equals("SHA1") || algorithm.equals("SHA256") || + algorithm.equals("SHA512") || algorithm.equals("MD5"); } public void setAlgorithm(String algorithm) throws OtpInfoException { @@ -105,6 +112,12 @@ public static OtpInfo fromJson(String type, JSONObject obj) throws OtpInfoExcept String algo = obj.getString("algo"); int digits = obj.getInt("digits"); + // Special case to work around a bug where a user could accidentally + // set the hash algorithm of a non-mOTP entry to MD5 + if (!type.equals(MotpInfo.ID) && algo.equals("MD5")) { + algo = DEFAULT_ALGORITHM; + } + switch (type) { case TotpInfo.ID: info = new TotpInfo(secret, algo, digits, obj.getInt("period")); @@ -115,6 +128,12 @@ public static OtpInfo fromJson(String type, JSONObject obj) throws OtpInfoExcept case HotpInfo.ID: info = new HotpInfo(secret, algo, digits, obj.getLong("counter")); break; + case YandexInfo.ID: + info = new YandexInfo(secret, obj.getString("pin")); + break; + case MotpInfo.ID: + info = new MotpInfo(secret, obj.getString("pin")); + break; default: throw new OtpInfoException("unsupported otp type: " + type); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/SteamInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/SteamInfo.java index a475f40387..1adcd98354 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/otp/SteamInfo.java +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/SteamInfo.java @@ -20,9 +20,11 @@ public SteamInfo(byte[] secret, String algorithm, int digits, int period) throws } @Override - public String getOtp() { + public String getOtp(long time) throws OtpInfoException { + checkSecret(); + try { - OTP otp = TOTP.generateOTP(getSecret(), getAlgorithm(true), getDigits(), getPeriod()); + OTP otp = TOTP.generateOTP(getSecret(), getAlgorithm(true), getDigits(), getPeriod(), time); return otp.toSteamString(); } catch (InvalidKeyException | NoSuchAlgorithmException e) { throw new RuntimeException(e); diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/TotpInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/TotpInfo.java index a27e3671fc..cba249eb22 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/otp/TotpInfo.java +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/TotpInfo.java @@ -26,16 +26,13 @@ public TotpInfo(byte[] secret, String algorithm, int digits, int period) throws } @Override - public String getOtp() { - try { - OTP otp = TOTP.generateOTP(getSecret(), getAlgorithm(true), getDigits(), getPeriod()); - return otp.toString(); - } catch (InvalidKeyException | NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } + public String getOtp() throws OtpInfoException { + return getOtp(System.currentTimeMillis() / 1000); } - public String getOtp(long time) { + public String getOtp(long time) throws OtpInfoException { + checkSecret(); + try { OTP otp = TOTP.generateOTP(getSecret(), getAlgorithm(true), getDigits(), getPeriod(), time); return otp.toString(); diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/Transferable.java b/app/src/main/java/com/beemdevelopment/aegis/otp/Transferable.java new file mode 100644 index 0000000000..475db0773d --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/Transferable.java @@ -0,0 +1,7 @@ +package com.beemdevelopment.aegis.otp; + +import android.net.Uri; + +public interface Transferable { + Uri getUri() throws GoogleAuthInfoException; +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/YandexInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/YandexInfo.java new file mode 100644 index 0000000000..0afdf6150c --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/YandexInfo.java @@ -0,0 +1,188 @@ +package com.beemdevelopment.aegis.otp; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.beemdevelopment.aegis.crypto.otp.YAOTP; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Locale; +import java.util.Objects; + +public class YandexInfo extends TotpInfo { + public static final String DEFAULT_ALGORITHM = "SHA256"; + public static final int DIGITS = 8; + + public static final int SECRET_LENGTH = 16; + public static final int SECRET_FULL_LENGTH = 26; + public static final String ID = "yandex"; + public static final String HOST_ID = "yaotp"; + + @Nullable + private String _pin; + + public YandexInfo(@NonNull byte[] secret) throws OtpInfoException { + this(secret, null); + } + + public YandexInfo(@NonNull byte[] secret, @Nullable String pin) throws OtpInfoException { + super(secret, DEFAULT_ALGORITHM, DIGITS, TotpInfo.DEFAULT_PERIOD); + setSecret(parseSecret(secret)); + _pin = pin; + } + + @Override + public String getOtp(long time) { + if (_pin == null) { + throw new IllegalStateException("PIN must be set before generating an OTP"); + } + + try { + YAOTP otp = YAOTP.generateOTP(getSecret(), getPin(), getDigits(), getAlgorithm(true), getPeriod(), time); + return otp.toString(); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + throw new RuntimeException(e); + } + } + + @Nullable + public String getPin() { + return _pin; + } + + public void setPin(@NonNull String pin) { + _pin = pin; + } + + @Override + public String getTypeId() { + return ID; + } + + @Override + public String getType() { + String id = getTypeId(); + return id.substring(0, 1).toUpperCase(Locale.ROOT) + id.substring(1); + } + + @Override + public JSONObject toJson() { + JSONObject result = super.toJson(); + try { + result.put("pin", getPin()); + } catch (JSONException e) { + throw new RuntimeException(e); + } + return result; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof YandexInfo)) { + return false; + } + + YandexInfo info = (YandexInfo) o; + return super.equals(o) && Objects.equals(getPin(), info.getPin()); + } + + public static byte[] parseSecret(byte[] secret) throws OtpInfoException { + validateSecret(secret); + + if (secret.length != SECRET_LENGTH) { + return Arrays.copyOfRange(secret, 0, SECRET_LENGTH); + } + + return secret; + } + + /** + * Java implementation of ChecksumIsValid + * From: https://github.com/norblik/KeeYaOtp/blob/188a1a99f13f82e4ef8df8a1b9b9351ba236e2a1/KeeYaOtp/Core/Secret.cs + * License: GPLv3+ + */ + public static void validateSecret(byte[] secret) throws OtpInfoException { + if (secret.length != SECRET_LENGTH && secret.length != SECRET_FULL_LENGTH) { + throw new OtpInfoException(String.format("Invalid Yandex secret length: %d bytes", secret.length)); + } + + // Secrets originating from a QR code do not have a checksum, so we assume those are valid + if (secret.length == SECRET_LENGTH) { + return; + } + + char originalChecksum = (char) ((secret[secret.length - 2] & 0x0F) << 8 | secret[secret.length - 1] & 0xff); + + char accum = 0; + int accumBits = 0; + + int inputTotalBitsAvailable = secret.length * 8 - 12; + int inputIndex = 0; + int inputBitsAvailable = 8; + + while (inputTotalBitsAvailable > 0) { + int requiredBits = 13 - accumBits; + if (inputTotalBitsAvailable < requiredBits) { + requiredBits = inputTotalBitsAvailable; + } + + while (requiredBits > 0) { + int curInput = (secret[inputIndex] & (1 << inputBitsAvailable) - 1) & 0xff; + int bitsToRead = Math.min(requiredBits, inputBitsAvailable); + + curInput >>= inputBitsAvailable - bitsToRead; + accum = (char) (accum << bitsToRead | curInput); + + inputTotalBitsAvailable -= bitsToRead; + requiredBits -= bitsToRead; + inputBitsAvailable -= bitsToRead; + accumBits += bitsToRead; + + if (inputBitsAvailable == 0) { + inputIndex += 1; + inputBitsAvailable = 8; + } + } + + if (accumBits == 13) { + accum ^= 0b1_1000_1111_0011; + } + accumBits = 16 - getNumberOfLeadingZeros(accum); + } + + if (accum != originalChecksum) { + throw new OtpInfoException("Yandex secret checksum invalid"); + } + } + + private static int getNumberOfLeadingZeros(char value) { + if (value == 0) { + return 16; + } + + int n = 0; + if ((value & 0xFF00) == 0) { + n += 8; + value <<= 8; + } + if ((value & 0xF000) == 0) { + n += 4; + value <<= 4; + } + if ((value & 0xC000) == 0) { + n += 2; + value <<= 2; + } + if ((value & 0x8000) == 0) { + n++; + } + + return n; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/receivers/QsTileRefreshReceiver.java b/app/src/main/java/com/beemdevelopment/aegis/receivers/QsTileRefreshReceiver.java new file mode 100644 index 0000000000..fb156acd9d --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/receivers/QsTileRefreshReceiver.java @@ -0,0 +1,31 @@ +package com.beemdevelopment.aegis.receivers; + +import static android.content.Intent.ACTION_BOOT_COMPLETED; + +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.service.quicksettings.TileService; + +import com.beemdevelopment.aegis.services.LaunchAppTileService; +import com.beemdevelopment.aegis.services.LaunchScannerTileService; + +public class QsTileRefreshReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction() == null + || (!intent.getAction().equals(ACTION_BOOT_COMPLETED) + && !intent.getAction().equals(Intent.ACTION_USER_UNLOCKED))) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + TileService.requestListeningState(context, + new ComponentName(context, LaunchAppTileService.class)); + TileService.requestListeningState(context, + new ComponentName(context, LaunchScannerTileService.class)); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/receivers/VaultLockReceiver.java b/app/src/main/java/com/beemdevelopment/aegis/receivers/VaultLockReceiver.java new file mode 100644 index 0000000000..aada5b2b9a --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/receivers/VaultLockReceiver.java @@ -0,0 +1,35 @@ +package com.beemdevelopment.aegis.receivers; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import com.beemdevelopment.aegis.BuildConfig; +import com.beemdevelopment.aegis.Preferences; +import com.beemdevelopment.aegis.vault.VaultManager; + +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint +public class VaultLockReceiver extends BroadcastReceiver { + public static final String ACTION_LOCK_VAULT + = String.format("%s.LOCK_VAULT", BuildConfig.APPLICATION_ID); + + @Inject + protected VaultManager _vaultManager; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction() == null + || (!intent.getAction().equals(ACTION_LOCK_VAULT) + && !intent.getAction().equals(Intent.ACTION_SCREEN_OFF))) { + return; + } + + if (_vaultManager.isAutoLockEnabled(Preferences.AUTO_LOCK_ON_DEVICE_LOCK)) { + _vaultManager.lock(false); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/services/LaunchAppTileService.java b/app/src/main/java/com/beemdevelopment/aegis/services/LaunchAppTileService.java new file mode 100644 index 0000000000..036c2c1459 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/services/LaunchAppTileService.java @@ -0,0 +1,44 @@ +package com.beemdevelopment.aegis.services; + +import android.annotation.SuppressLint; +import android.app.PendingIntent; +import android.content.Intent; +import android.os.Build; +import android.service.quicksettings.Tile; +import android.service.quicksettings.TileService; + +import androidx.annotation.RequiresApi; + +import com.beemdevelopment.aegis.ui.MainActivity; + +@RequiresApi(api = Build.VERSION_CODES.N) +public class LaunchAppTileService extends TileService { + + @Override + public void onStartListening() { + super.onStartListening(); + Tile tile = getQsTile(); + if (tile != null) { + tile.setState(Tile.STATE_INACTIVE); + tile.updateTile(); + } + } + + @SuppressLint("StartActivityAndCollapseDeprecated") + @Override + public void onClick() { + super.onClick(); + + Intent intent = new Intent(this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.setAction(Intent.ACTION_MAIN); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + int flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE; + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, flags); + startActivityAndCollapse(pendingIntent); + } else { + startActivityAndCollapse(intent); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/services/LaunchScannerTileService.java b/app/src/main/java/com/beemdevelopment/aegis/services/LaunchScannerTileService.java new file mode 100644 index 0000000000..d02685663f --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/services/LaunchScannerTileService.java @@ -0,0 +1,45 @@ +package com.beemdevelopment.aegis.services; + +import android.annotation.SuppressLint; +import android.app.PendingIntent; +import android.content.Intent; +import android.os.Build; +import android.service.quicksettings.Tile; +import android.service.quicksettings.TileService; + +import androidx.annotation.RequiresApi; + +import com.beemdevelopment.aegis.ui.MainActivity; + +@RequiresApi(api = Build.VERSION_CODES.N) +public class LaunchScannerTileService extends TileService { + + @Override + public void onStartListening() { + super.onStartListening(); + Tile tile = getQsTile(); + if (tile != null) { + tile.setState(Tile.STATE_INACTIVE); + tile.updateTile(); + } + } + + @SuppressLint("StartActivityAndCollapseDeprecated") + @Override + public void onClick() { + super.onClick(); + + Intent intent = new Intent(this, MainActivity.class); + intent.putExtra("action", "scan"); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.setAction(Intent.ACTION_MAIN); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + int flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE; + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, flags); + startActivityAndCollapse(pendingIntent); + } else { + startActivityAndCollapse(intent); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/services/NotificationService.java b/app/src/main/java/com/beemdevelopment/aegis/services/NotificationService.java index 489d8b6b26..bcc9030a39 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/services/NotificationService.java +++ b/app/src/main/java/com/beemdevelopment/aegis/services/NotificationService.java @@ -1,5 +1,6 @@ package com.beemdevelopment.aegis.services; +import android.annotation.SuppressLint; import android.app.PendingIntent; import android.app.Service; import android.content.Intent; @@ -9,13 +10,14 @@ import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; +import com.beemdevelopment.aegis.BuildConfig; import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.receivers.VaultLockReceiver; public class NotificationService extends Service { - public static final int VAULT_UNLOCKED_ID = 1; + private static final int NOTIFICATION_VAULT_UNLOCKED = 1; - private static final String CODE_LOCK_STATUS_ID = "lock_status_channel"; - private static final String CODE_LOCK_VAULT_ACTION = "lock_vault"; + private static final String CHANNEL_ID = "lock_status_channel"; @Override public int onStartCommand(Intent intent,int flags, int startId){ @@ -24,25 +26,30 @@ public int onStartCommand(Intent intent,int flags, int startId){ return Service.START_STICKY; } + @SuppressLint("LaunchActivityFromNotification") public void serviceMethod() { - Intent intentAction = new Intent(CODE_LOCK_VAULT_ACTION); - PendingIntent lockDatabaseIntent = PendingIntent.getBroadcast(this, 1, intentAction, 0); - NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CODE_LOCK_STATUS_ID) - .setSmallIcon(R.drawable.ic_fingerprint_black_24dp) + int flags = PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE; + Intent intent = new Intent(this, VaultLockReceiver.class); + intent.setAction(VaultLockReceiver.ACTION_LOCK_VAULT); + intent.setPackage(BuildConfig.APPLICATION_ID); + PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 1, intent, flags); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_aegis_notification) .setContentTitle(getString(R.string.app_name_full)) .setContentText(getString(R.string.vault_unlocked_state)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setOngoing(true) - .setContentIntent(lockDatabaseIntent); + .setContentIntent(pendingIntent); - NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); - notificationManager.notify(VAULT_UNLOCKED_ID, builder.build()); + // NOTE: Disabled for now. See issue: #1047 + //startForeground(NOTIFICATION_VAULT_UNLOCKED, builder.build()); } @Override public void onDestroy() { NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); - notificationManager.cancel(VAULT_UNLOCKED_ID); + notificationManager.cancel(NOTIFICATION_VAULT_UNLOCKED); super.onDestroy(); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/AboutActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/AboutActivity.java index f732e398ce..4b974b6844 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/AboutActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/AboutActivity.java @@ -13,20 +13,16 @@ import androidx.annotation.AttrRes; import androidx.annotation.StringRes; -import androidx.core.view.LayoutInflaterCompat; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; import com.beemdevelopment.aegis.BuildConfig; import com.beemdevelopment.aegis.R; -import com.beemdevelopment.aegis.Theme; -import com.beemdevelopment.aegis.helpers.ThemeHelper; -import com.beemdevelopment.aegis.licenses.GlideLicense; -import com.beemdevelopment.aegis.licenses.ProtobufLicense; import com.beemdevelopment.aegis.ui.dialogs.ChangelogDialog; import com.beemdevelopment.aegis.ui.dialogs.LicenseDialog; -import com.mikepenz.iconics.context.IconicsLayoutInflater2; - -import de.psdev.licensesdialog.LicenseResolver; -import de.psdev.licensesdialog.LicensesDialog; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.google.android.material.color.MaterialColors; public class AboutActivity extends AegisActivity { @@ -40,11 +36,14 @@ public class AboutActivity extends AegisActivity { @Override protected void onCreate(Bundle savedInstanceState) { - LayoutInflaterCompat.setFactory2(getLayoutInflater(), new IconicsLayoutInflater2(getDelegate())); - super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } + setContentView(R.layout.activity_about); setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); if (getSupportActionBar() != null) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); @@ -54,12 +53,15 @@ protected void onCreate(Bundle savedInstanceState) { View btnLicense = findViewById(R.id.btn_license); btnLicense.setOnClickListener(v -> { LicenseDialog.create() - .setTheme(getConfiguredTheme()) + .setTheme(_themeHelper.getConfiguredTheme()) .show(getSupportFragmentManager(), null); }); View btnThirdPartyLicenses = findViewById(R.id.btn_third_party_licenses); - btnThirdPartyLicenses.setOnClickListener(v -> showThirdPartyLicenseDialog()); + btnThirdPartyLicenses.setOnClickListener(v -> { + Intent intent = new Intent(this, LicensesActivity.class); + startActivity(intent); + }); TextView appVersion = findViewById(R.id.app_version); appVersion.setText(getCurrentAppVersion()); @@ -90,9 +92,20 @@ protected void onCreate(Bundle savedInstanceState) { View btnChangelog = findViewById(R.id.btn_changelog); btnChangelog.setOnClickListener(v -> { ChangelogDialog.create() - .setTheme(getConfiguredTheme()) + .setTheme(_themeHelper.getConfiguredTheme()) .show(getSupportFragmentManager(), null); }); + + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.about_scroll_view), (targetView, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); + targetView.setPadding( + 0, + 0, + 0, + insets.bottom + ); + return WindowInsetsCompat.CONSUMED; + }); } private static String getCurrentAppVersion() { @@ -124,40 +137,19 @@ private void openMail(String mailaddress) { mailIntent.putExtra(Intent.EXTRA_EMAIL, mailaddress); mailIntent.putExtra(Intent.EXTRA_SUBJECT, R.string.app_name_full); - startActivity(Intent.createChooser(mailIntent, this.getString(R.string.email))); - } - - private void showThirdPartyLicenseDialog() { - String stylesheet = getString(R.string.custom_notices_format_style); - int backgroundColorResource = getConfiguredTheme() == Theme.AMOLED ? R.attr.cardBackgroundFocused : R.attr.cardBackground; - String backgroundColor = getThemeColorAsHex(backgroundColorResource); - String textColor = getThemeColorAsHex(R.attr.primaryText); - String licenseColor = getThemeColorAsHex(R.attr.cardBackgroundFocused); - String linkColor = getThemeColorAsHex(R.attr.colorAccent); - - stylesheet = String.format(stylesheet, backgroundColor, textColor, licenseColor, linkColor); - - LicenseResolver.registerLicense(new GlideLicense()); - LicenseResolver.registerLicense(new ProtobufLicense()); - - new LicensesDialog.Builder(this) - .setNotices(R.raw.notices) - .setTitle(R.string.third_party_licenses) - .setNoticesCssStyle(stylesheet) - .setIncludeOwnLicense(true) - .build() - .show(); + startActivity(Intent.createChooser(mailIntent, getString(R.string.email))); } private String getThemeColorAsHex(@AttrRes int attributeId) { - return String.format("%06X", (0xFFFFFF & ThemeHelper.getThemeColor(attributeId, getTheme()))); + int color = MaterialColors.getColor(this, attributeId, getClass().getCanonicalName()); + return String.format("%06X", 0xFFFFFF & color); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case android.R.id.home: - onBackPressed(); + finish(); break; default: return super.onOptionsItemSelected(item); diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/AegisActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/AegisActivity.java index 6838f94beb..5a467fbcb2 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/AegisActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/AegisActivity.java @@ -1,66 +1,86 @@ package com.beemdevelopment.aegis.ui; +import android.annotation.SuppressLint; import android.app.Activity; -import android.content.ActivityNotFoundException; import android.content.Intent; import android.content.res.Configuration; +import android.graphics.drawable.Drawable; import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; import android.view.WindowManager; import android.widget.Toast; import androidx.annotation.CallSuper; -import androidx.annotation.Nullable; +import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; -import androidx.fragment.app.Fragment; +import androidx.appcompat.view.ActionMode; +import androidx.core.view.ViewPropertyAnimatorCompat; -import com.beemdevelopment.aegis.AegisApplication; import com.beemdevelopment.aegis.Preferences; import com.beemdevelopment.aegis.R; -import com.beemdevelopment.aegis.Theme; import com.beemdevelopment.aegis.ThemeMap; -import com.beemdevelopment.aegis.ui.dialogs.Dialogs; -import com.beemdevelopment.aegis.vault.VaultManagerException; - +import com.beemdevelopment.aegis.database.AuditLogRepository; +import com.beemdevelopment.aegis.helpers.ThemeHelper; +import com.beemdevelopment.aegis.icons.IconPackManager; +import com.beemdevelopment.aegis.vault.VaultManager; +import com.beemdevelopment.aegis.vault.VaultRepositoryException; +import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.color.MaterialColors; + +import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Locale; -import java.util.Map; -public abstract class AegisActivity extends AppCompatActivity implements AegisApplication.LockListener { - private AegisApplication _app; +import javax.inject.Inject; + +import dagger.hilt.InstallIn; +import dagger.hilt.android.AndroidEntryPoint; +import dagger.hilt.android.EarlyEntryPoint; +import dagger.hilt.android.EarlyEntryPoints; +import dagger.hilt.components.SingletonComponent; + +@AndroidEntryPoint +public abstract class AegisActivity extends AppCompatActivity implements VaultManager.LockListener { + protected Preferences _prefs; + protected ThemeHelper _themeHelper; + + @Inject + protected VaultManager _vaultManager; + + @Inject + protected AuditLogRepository _auditLogRepository; + + @Inject + protected IconPackManager _iconPackManager; + + private ActionModeStatusGuardHack _statusGuardHack; @Override protected void onCreate(Bundle savedInstanceState) { - _app = (AegisApplication) getApplication(); - // set the theme and locale before creating the activity - Preferences prefs = getPreferences(); + _prefs = EarlyEntryPoints.get(getApplicationContext(), PrefEntryPoint.class).getPreferences(); + _themeHelper = new ThemeHelper(this, _prefs); onSetTheme(); - setLocale(prefs.getLocale()); + setLocale(_prefs.getLocale()); super.onCreate(savedInstanceState); - // if the app was killed, relaunch MainActivity and close everything else - if (savedInstanceState != null && isOrphan()) { - Intent intent = new Intent(this, MainActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - startActivity(intent); - finish(); - return; - } + _statusGuardHack = new ActionModeStatusGuardHack(); // set FLAG_SECURE on the window of every AegisActivity - if (getPreferences().isSecureScreenEnabled()) { + if (_prefs.isSecureScreenEnabled()) { getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); } // register a callback to listen for lock events - _app.registerLockListener(this); + _vaultManager.registerLockListener(this); } @Override @CallSuper protected void onDestroy() { - _app.unregisterLockListener(this); + _vaultManager.unregisterLockListener(this); super.onDestroy(); } @@ -68,59 +88,33 @@ protected void onDestroy() { @Override protected void onResume() { super.onResume(); - _app.setBlockAutoLock(false); + _vaultManager.setBlockAutoLock(false); } + @SuppressLint("SoonBlockedPrivateApi") + @SuppressWarnings("JavaReflectionMemberAccess") @Override public void onLocked(boolean userInitiated) { setResult(RESULT_CANCELED, null); + try { + // Call a private overload of the finish() method to prevent the app + // from disappearing from the recent apps menu Method method = Activity.class.getDeclaredMethod("finish", int.class); method.setAccessible(true); method.invoke(this, 2); // FINISH_TASK_WITH_ACTIVITY = 2 } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { - e.printStackTrace(); + // On recent Android versions, the overload of the finish() method + // used above is no longer accessible finishAndRemoveTask(); } } - protected AegisApplication getApp() { - return _app; - } - - protected Preferences getPreferences() { - return _app.getPreferences(); - } - /** * Called when the activity is expected to set its theme. */ protected void onSetTheme() { - setTheme(ThemeMap.DEFAULT); - } - - /** - * Sets the theme of the activity. The actual style that is set is picked from the - * given map, based on the theme configured by the user. - */ - protected void setTheme(Map themeMap) { - int theme = themeMap.get(getConfiguredTheme()); - setTheme(theme); - } - - protected Theme getConfiguredTheme() { - Theme theme = getPreferences().getCurrentTheme(); - - if (theme == Theme.SYSTEM || theme == Theme.SYSTEM_AMOLED) { - int currentNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; - if (currentNightMode == Configuration.UI_MODE_NIGHT_YES) { - theme = theme == Theme.SYSTEM_AMOLED ? Theme.AMOLED : Theme.DARK; - } else { - theme = Theme.LIGHT; - } - } - - return theme; + _themeHelper.setTheme(ThemeMap.DEFAULT); } protected void setLocale(Locale locale) { @@ -129,87 +123,133 @@ protected void setLocale(Locale locale) { Configuration config = new Configuration(); config.locale = locale; - this.getResources().updateConfiguration(config, this.getResources().getDisplayMetrics()); + getResources().updateConfiguration(config, getResources().getDisplayMetrics()); } - protected boolean saveVault(boolean backup) { + protected boolean saveVault() { try { - getApp().getVaultManager().save(backup); + _vaultManager.save(); return true; - } catch (VaultManagerException e) { + } catch (VaultRepositoryException e) { + Toast.makeText(this, getString(R.string.saving_error), Toast.LENGTH_LONG).show(); + return false; + } + } + + protected boolean saveAndBackupVault() { + try { + _vaultManager.saveAndBackup(); + return true; + } catch (VaultRepositoryException e) { Toast.makeText(this, getString(R.string.saving_error), Toast.LENGTH_LONG).show(); return false; } } /** - * Reports whether this Activity instance has become an orphan. This can happen if - * the vault was locked by an external trigger while the Activity was still open. + * Closes this activity if it has become an orphan (isOrphan() == true) and launches MainActivity. + * @param savedInstanceState the bundle passed to onCreate. + * @return whether to abort onCreate. */ - protected boolean isOrphan() { - return !(this instanceof MainActivity) && !(this instanceof AuthActivity) && !(this instanceof IntroActivity) && _app.isVaultLocked(); + protected boolean abortIfOrphan(Bundle savedInstanceState) { + if (savedInstanceState == null || !isOrphan()) { + return false; + } + + Intent intent = new Intent(this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + finish(); + return true; } - public static class Helper { - private Helper() { + @Override + public void onSupportActionModeStarted(@NonNull ActionMode mode) { + super.onSupportActionModeStarted(mode); + _statusGuardHack.apply(View.VISIBLE); + } - } + @Override + public void onSupportActionModeFinished(@NonNull ActionMode mode) { + super.onSupportActionModeFinished(mode); + _statusGuardHack.apply(View.GONE); + } - /** - * Starts an external activity, temporarily blocks automatic lock of Aegis and - * shows an error dialog if the target activity is not found. - */ - public static void startExtActivityForResult(Activity activity, Intent intent, int requestCode) { - AegisApplication app = (AegisApplication) activity.getApplication(); - app.setBlockAutoLock(true); + /** + * When starting/finishing an action mode, forcefully cancel the fade in/out animation and + * set the status bar color. This requires the abc_decor_view_status_guard colors to be set + * to transparent. + * + * This should fix any inconsistencies between the color of the action bar and the status bar + * when an action mode is active. + */ + private class ActionModeStatusGuardHack { + private Field _fadeAnimField; + private Field _actionModeViewField; + private Drawable _appBarBackground; + private ActionModeStatusGuardHack() { try { - activity.startActivityForResult(intent, requestCode, null); - } catch (ActivityNotFoundException e) { - e.printStackTrace(); - - if (isDocsAction(intent.getAction())) { - Dialogs.showErrorDialog(activity, R.string.documentsui_error, e); - } else { - throw e; - } + _fadeAnimField = getDelegate().getClass().getDeclaredField("mFadeAnim"); + _fadeAnimField.setAccessible(true); + _actionModeViewField = getDelegate().getClass().getDeclaredField("mActionModeView"); + _actionModeViewField.setAccessible(true); + } catch (NoSuchFieldException ignored) { } } - /** - * Starts an external activity, temporarily blocks automatic lock of Aegis and - * shows an error dialog if the target activity is not found. - */ - public static void startExtActivity(Fragment fragment, Intent intent) { - startExtActivityForResult(fragment, intent, -1); - } - - /** - * Starts an external activity, temporarily blocks automatic lock of Aegis and - * shows an error dialog if the target activity is not found. - */ - public static void startExtActivityForResult(Fragment fragment, Intent intent, int requestCode) { - AegisApplication app = (AegisApplication) fragment.getActivity().getApplication(); - app.setBlockAutoLock(true); + private void apply(int visibility) { + if (_fadeAnimField == null || _actionModeViewField == null) { + return; + } + ViewPropertyAnimatorCompat fadeAnim; + ViewGroup actionModeView; try { - fragment.startActivityForResult(intent, requestCode, null); - } catch (ActivityNotFoundException e) { - e.printStackTrace(); - - if (isDocsAction(intent.getAction())) { - Dialogs.showErrorDialog(fragment.getContext(), R.string.documentsui_error, e); - } else { - throw e; - } + fadeAnim = (ViewPropertyAnimatorCompat) _fadeAnimField.get(getDelegate()); + actionModeView = (ViewGroup) _actionModeViewField.get(getDelegate()); + } catch (IllegalAccessException e) { + return; } - } - private static boolean isDocsAction(@Nullable String action) { - return action != null && (action.equals(Intent.ACTION_GET_CONTENT) - || action.equals(Intent.ACTION_CREATE_DOCUMENT) - || action.equals(Intent.ACTION_OPEN_DOCUMENT) - || action.equals(Intent.ACTION_OPEN_DOCUMENT_TREE)); + AppBarLayout appBarLayout = findViewById(R.id.app_bar_layout); + if (appBarLayout != null && _appBarBackground == null) { + _appBarBackground = appBarLayout.getBackground(); + } + + if (fadeAnim == null || actionModeView == null || appBarLayout == null || _appBarBackground == null) { + return; + } + + fadeAnim.cancel(); + + if (visibility == View.VISIBLE) { + actionModeView.setVisibility(visibility); + actionModeView.setAlpha(1f); + int color = MaterialColors.getColor(appBarLayout, com.google.android.material.R.attr.colorSurfaceContainer); + appBarLayout.setBackgroundColor(color); + } else { + actionModeView.setVisibility(visibility); + actionModeView.setAlpha(0f); + appBarLayout.setBackground(_appBarBackground); + } } } + + /** + * Reports whether this Activity instance has become an orphan. This can happen if + * the vault was killed/locked by an external trigger while the Activity was still open. + */ + private boolean isOrphan() { + return !(this instanceof MainActivity) + && !(this instanceof AuthActivity) + && !(this instanceof IntroActivity) + && !_vaultManager.isVaultLoaded(); + } + + @EarlyEntryPoint + @InstallIn(SingletonComponent.class) + public interface PrefEntryPoint { + Preferences getPreferences(); + } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/AssignIconsActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/AssignIconsActivity.java new file mode 100644 index 0000000000..024f9200d1 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/AssignIconsActivity.java @@ -0,0 +1,281 @@ +package com.beemdevelopment.aegis.ui; + +import android.content.Intent; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.MetricsHelper; +import com.beemdevelopment.aegis.icons.IconPack; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.dialogs.IconPickerDialog; +import com.beemdevelopment.aegis.ui.glide.GlideHelper; +import com.beemdevelopment.aegis.ui.models.AssignIconEntry; +import com.beemdevelopment.aegis.ui.views.AssignIconAdapter; +import com.beemdevelopment.aegis.ui.views.IconAdapter; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultEntryIcon; +import com.bumptech.glide.Glide; +import com.bumptech.glide.ListPreloader; +import com.bumptech.glide.RequestBuilder; +import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader; +import com.bumptech.glide.util.ViewPreloadSizeProvider; +import com.google.android.material.bottomsheet.BottomSheetDialog; + +import java.io.FileInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +public class AssignIconsActivity extends AegisActivity implements AssignIconAdapter.Listener { + private AssignIconAdapter _adapter; + private ArrayList _entries = new ArrayList<>(); + private RecyclerView _entriesView; + private AssignIconsActivity.BackPressHandler _backPressHandler; + private ViewPreloadSizeProvider _preloadSizeProvider; + private IconPack _favoriteIconPack; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } + + setContentView(R.layout.activity_assign_icons); + setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setDisplayShowHomeEnabled(true); + } + + ArrayList assignIconEntriesIds = (ArrayList) getIntent().getSerializableExtra("entries"); + for (UUID entryId: assignIconEntriesIds) { + VaultEntry vaultEntry = _vaultManager.getVault().getEntryByUUID(entryId); + _entries.add(new AssignIconEntry(vaultEntry)); + } + + _backPressHandler = new AssignIconsActivity.BackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _backPressHandler); + + IconPreloadProvider modelProvider1 = new IconPreloadProvider(); + EntryIconPreloadProvider modelProvider2 = new EntryIconPreloadProvider(); + _preloadSizeProvider = new ViewPreloadSizeProvider<>(); + RecyclerViewPreloader preloader1 = new RecyclerViewPreloader(this, modelProvider1, _preloadSizeProvider, 10); + RecyclerViewPreloader preloader2 = new RecyclerViewPreloader(this, modelProvider2, _preloadSizeProvider, 10); + + _adapter = new AssignIconAdapter(this); + _entriesView = findViewById(R.id.list_assign_icons); + LinearLayoutManager layoutManager = new LinearLayoutManager(this); + _entriesView.setLayoutManager(layoutManager); + _entriesView.setAdapter(_adapter); + _entriesView.setNestedScrollingEnabled(false); + _entriesView.addItemDecoration(new SpacesItemDecoration(8)); + _entriesView.addOnScrollListener(preloader1); + _entriesView.addOnScrollListener(preloader2); + + Optional favoriteIconPack = _iconPackManager.getIconPacks().stream() + .sorted(Comparator.comparing(IconPack::getName)) + .findFirst(); + + if (!favoriteIconPack.isPresent()) { + throw new RuntimeException(String.format("Started %s without any icon packs present", AssignIconsActivity.class.getName())); + } + + _favoriteIconPack = favoriteIconPack.get(); + + for (AssignIconEntry entry : _entries) { + IconPack.Icon suggestedIcon = findSuggestedIcon(entry); + if (suggestedIcon != null) { + entry.setNewIcon(suggestedIcon); + } + } + + _adapter.addEntries(_entries); + } + + private IconPack.Icon findSuggestedIcon(AssignIconEntry entry) { + List suggestedIcons = _favoriteIconPack.getSuggestedIcons(entry.getEntry().getIssuer()); + if (suggestedIcons.size() > 0) { + return suggestedIcons.get(0); + } + + return null; + } + + private void saveAndFinish() throws IOException { + ArrayList uuids = new ArrayList<>(); + for (AssignIconEntry selectedEntry : _entries) { + VaultEntry entry = selectedEntry.getEntry(); + if (selectedEntry.getNewIcon() != null) { + byte[] iconBytes; + try (FileInputStream inStream = new FileInputStream(selectedEntry.getNewIcon().getFile())){ + iconBytes = IOUtils.readFile(inStream); + } + + VaultEntryIcon icon = new VaultEntryIcon(iconBytes, selectedEntry.getNewIcon().getIconType()); + entry.setIcon(icon); + uuids.add(entry.getUUID()); + + _vaultManager.getVault().replaceEntry(entry); + } + } + + Intent intent = new Intent(); + intent.putExtra("entryUUIDs", uuids); + + if (saveAndBackupVault()) { + setResult(RESULT_OK, intent); + finish(); + } + } + + private void discardAndFinish() { + Dialogs.showDiscardDialog(this, + (dialog, which) -> { + try { + saveAndFinish(); + } catch (IOException e) { + Toast.makeText(this, R.string.saving_assign_icons_error, Toast.LENGTH_SHORT).show(); + } + }, + (dialog, which) -> finish()); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_assign_icons, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + discardAndFinish(); + } else if (itemId == R.id.action_save) { + try { + saveAndFinish(); + } catch (IOException e) { + Toast.makeText(this, R.string.saving_assign_icons_error, Toast.LENGTH_SHORT).show(); + } + } else { + return super.onOptionsItemSelected(item); + } + + return true; + } + + @Override + public void onAssignIconEntryClick(AssignIconEntry entry) { + List iconPacks = _iconPackManager.getIconPacks().stream() + .sorted(Comparator.comparing(IconPack::getName)) + .collect(Collectors.toList()); + + BottomSheetDialog dialog = IconPickerDialog.create(this, iconPacks, entry.getEntry().getIssuer(), false, new IconAdapter.Listener() { + @Override + public void onIconSelected(IconPack.Icon icon) { + entry.setNewIcon(icon); + } + + @Override + public void onCustomSelected() { } + }); + Dialogs.showSecureDialog(dialog); + } + + @Override + public void onSetPreloadView(View view) { + _preloadSizeProvider.setView(view); + } + + private class BackPressHandler extends OnBackPressedCallback { + public BackPressHandler() { + super(false); + } + + @Override + public void handleOnBackPressed() { + discardAndFinish(); + } + } + + private class EntryIconPreloadProvider implements ListPreloader.PreloadModelProvider { + @NonNull + @Override + public List getPreloadItems(int position) { + VaultEntry entry = _entries.get(position).getEntry(); + if (entry.hasIcon()) { + return Collections.singletonList(entry); + } + return Collections.emptyList(); + } + + @Nullable + @Override + public RequestBuilder getPreloadRequestBuilder(@NonNull VaultEntry entry) { + RequestBuilder rb = Glide.with(AssignIconsActivity.this) + .load(entry.getIcon()); + return GlideHelper.setCommonOptions(rb, entry.getIcon().getType()); + } + } + + private class IconPreloadProvider implements ListPreloader.PreloadModelProvider { + @NonNull + @Override + public List getPreloadItems(int position) { + AssignIconEntry entry = _entries.get(position); + if (entry.getNewIcon() != null) { + return Collections.singletonList(entry.getNewIcon()); + } + return Collections.emptyList(); + } + + @Nullable + @Override + public RequestBuilder getPreloadRequestBuilder(@NonNull IconPack.Icon icon) { + RequestBuilder rb = Glide.with(AssignIconsActivity.this) + .load(icon.getFile()); + return GlideHelper.setCommonOptions(rb, icon.getIconType()); + } + } + + private class SpacesItemDecoration extends RecyclerView.ItemDecoration { + private final int _space; + + public SpacesItemDecoration(int dpSpace) { + + this._space = MetricsHelper.convertDpToPixels(AssignIconsActivity.this, dpSpace); + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + outRect.left = _space; + outRect.right = _space; + outRect.bottom = _space; + + if (parent.getChildLayoutPosition(view) == 0) { + outRect.top = _space; + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/AuthActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/AuthActivity.java index b8282644aa..a30c552cf7 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/AuthActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/AuthActivity.java @@ -2,7 +2,6 @@ import android.content.Context; import android.content.Intent; -import android.os.Build; import android.os.Bundle; import android.text.InputType; import android.view.KeyEvent; @@ -18,14 +17,11 @@ import android.widget.TextView; import android.widget.Toast; +import androidx.activity.OnBackPressedCallback; import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; import androidx.biometric.BiometricPrompt; -import com.beemdevelopment.aegis.AegisApplication; -import com.beemdevelopment.aegis.Preferences; import com.beemdevelopment.aegis.R; -import com.beemdevelopment.aegis.ThemeMap; import com.beemdevelopment.aegis.crypto.KeyStoreHandle; import com.beemdevelopment.aegis.crypto.KeyStoreHandleException; import com.beemdevelopment.aegis.crypto.MasterKey; @@ -37,13 +33,16 @@ import com.beemdevelopment.aegis.ui.tasks.PasswordSlotDecryptTask; import com.beemdevelopment.aegis.vault.VaultFile; import com.beemdevelopment.aegis.vault.VaultFileCredentials; -import com.beemdevelopment.aegis.vault.VaultManagerException; +import com.beemdevelopment.aegis.vault.VaultRepository; +import com.beemdevelopment.aegis.vault.VaultRepositoryException; import com.beemdevelopment.aegis.vault.slots.BiometricSlot; import com.beemdevelopment.aegis.vault.slots.PasswordSlot; import com.beemdevelopment.aegis.vault.slots.Slot; import com.beemdevelopment.aegis.vault.slots.SlotException; import com.beemdevelopment.aegis.vault.slots.SlotIntegrityException; import com.beemdevelopment.aegis.vault.slots.SlotList; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputLayout; import java.util.List; @@ -51,12 +50,18 @@ import javax.crypto.SecretKey; public class AuthActivity extends AegisActivity { + // Permission request codes + private static final int CODE_PERM_NOTIFICATIONS = 0; + private EditText _textPassword; + private VaultFile _vaultFile; private SlotList _slots; + private SecretKey _bioKey; private BiometricSlot _bioSlot; private BiometricPrompt _bioPrompt; + private Button _decryptButton; private int _failedUnlockAttempts; @@ -64,46 +69,66 @@ public class AuthActivity extends AegisActivity { // biometric prompt by setting 'inhibitBioPrompt' to true through the intent private boolean _inhibitBioPrompt; - private Preferences _prefs; - @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - _prefs = new Preferences(this); setContentView(R.layout.activity_auth); - _textPassword = findViewById(R.id.text_password); + + TextInputLayout layoutStandard = findViewById(R.id.layout_standard); + TextInputLayout layoutNoAutofill = findViewById(R.id.layout_no_autofill); + EditText editStandard = findViewById(R.id.text_password); + EditText editNoAutofill = findViewById(R.id.text_password_no_autofill); + + if (_prefs.isPinKeyboardEnabled()) { + layoutStandard.setVisibility(View.GONE); + layoutNoAutofill.setVisibility(View.VISIBLE); + _textPassword = editNoAutofill; + } else { + layoutStandard.setVisibility(View.VISIBLE); + layoutNoAutofill.setVisibility(View.GONE); + _textPassword = editStandard; + } + LinearLayout boxBiometricInfo = findViewById(R.id.box_biometric_info); - Button decryptButton = findViewById(R.id.button_decrypt); + _decryptButton = findViewById(R.id.button_decrypt); TextView biometricsButton = findViewById(R.id.button_biometrics); + getOnBackPressedDispatcher().addCallback(this, new BackPressHandler()); + _textPassword.setOnEditorActionListener((v, actionId, event) -> { if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER)) || (actionId == EditorInfo.IME_ACTION_DONE)) { - decryptButton.performClick(); + _decryptButton.performClick(); } return false; }); - if (_prefs.isPinKeyboardEnabled()) { - _textPassword.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD); - } - Intent intent = getIntent(); if (savedInstanceState == null) { _inhibitBioPrompt = intent.getBooleanExtra("inhibitBioPrompt", false); + + // A persistent notification is shown to let the user know that the vault is unlocked. Permission + // to do so is required since API 33, so for existing users, we have to request permission here + // in order to be able to show the notification after unlock. + // + // NOTE: Disabled for now. See issue: #1047 + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + PermissionHelper.request(this, CODE_PERM_NOTIFICATIONS, Manifest.permission.POST_NOTIFICATIONS); + }*/ } else { _inhibitBioPrompt = savedInstanceState.getBoolean("inhibitBioPrompt", false); } try { - VaultFile vaultFile = getApp().loadVaultFile(); - _slots = vaultFile.getHeader().getSlots(); - } catch (VaultManagerException e) { - e.printStackTrace(); - Dialogs.showErrorDialog(this, R.string.vault_load_error, e, (dialog, which) -> onBackPressed()); + _vaultFile = VaultRepository.readVaultFile(this); + } catch (VaultRepositoryException e) { + Dialogs.showErrorDialog(this, R.string.vault_load_error, e, (dialog, which) -> { + getOnBackPressedDispatcher().onBackPressed(); + }); return; } // only show the biometric prompt if the api version is new enough, permission is granted, a scanner is found and a biometric slot is found + _slots = _vaultFile.getHeader().getSlots(); if (_slots.has(BiometricSlot.class) && BiometricsHelper.isAvailable(this)) { boolean invalidated = false; @@ -139,7 +164,7 @@ protected void onCreate(Bundle savedInstanceState) { } } - decryptButton.setOnClickListener(v -> { + _decryptButton.setOnClickListener(v -> { InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE); imm.hideSoftInputFromWindow(v.getWindowToken(), 0); @@ -148,10 +173,24 @@ protected void onCreate(Bundle savedInstanceState) { PasswordSlotDecryptTask.Params params = new PasswordSlotDecryptTask.Params(slots, password); PasswordSlotDecryptTask task = new PasswordSlotDecryptTask(AuthActivity.this, new PasswordDerivationListener()); task.execute(getLifecycle(), params); + + _decryptButton.setEnabled(false); }); biometricsButton.setOnClickListener(v -> { - showBiometricPrompt(); + if (_prefs.isPasswordReminderNeeded()) { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(getString(R.string.password_reminder_dialog_title)) + .setMessage(getString(R.string.password_reminder_dialog_message)) + .setCancelable(false) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(android.R.string.ok, (dialog1, which) -> { + showBiometricPrompt(); + }) + .create()); + } else { + showBiometricPrompt(); + } }); } @@ -161,11 +200,6 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { outState.putBoolean("inhibitBioPrompt", _inhibitBioPrompt); } - @Override - protected void onSetTheme() { - setTheme(ThemeMap.NO_ACTION_BAR); - } - private void selectPassword() { _textPassword.selectAll(); @@ -173,11 +207,6 @@ private void selectPassword() { imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0); } - @Override - public void onBackPressed() { - finishAffinity(); - } - @Override public void onResume() { super.onResume(); @@ -223,10 +252,11 @@ private void showPasswordReminder() { PopupWindow popup = new PopupWindow(popupLayout, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); popup.setFocusable(false); popup.setOutsideTouchable(true); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP){ - popup.setElevation(5.0f); - } _textPassword.post(() -> { + if (isFinishing() || !_textPassword.isAttachedToWindow()) { + return; + } + // calculating the actual height of the popup window does not seem possible // adding 25dp seems to look good enough int yoff = _textPassword.getHeight() @@ -266,12 +296,11 @@ private void finish(MasterKey key, boolean isSlotRepaired) { VaultFileCredentials creds = new VaultFileCredentials(key, _slots); try { - AegisApplication app = getApp(); - app.initVaultManager(app.loadVaultFile(), creds); + _vaultManager.loadFrom(_vaultFile, creds); if (isSlotRepaired) { - saveVault(true); + saveAndBackupVault(); } - } catch (VaultManagerException e) { + } catch (VaultRepositoryException e) { e.printStackTrace(); Dialogs.showErrorDialog(this, R.string.decryption_corrupt_error, e); return; @@ -282,10 +311,11 @@ private void finish(MasterKey key, boolean isSlotRepaired) { } private void onInvalidPassword() { - Dialogs.showSecureDialog(new AlertDialog.Builder(AuthActivity.this) + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(AuthActivity.this, R.style.ThemeOverlay_Aegis_AlertDialog_Error) .setTitle(getString(R.string.unlock_vault_error)) .setMessage(getString(R.string.unlock_vault_error_description)) .setCancelable(false) + .setIconAttribute(android.R.attr.alertDialogIcon) .setPositiveButton(android.R.string.ok, (dialog, which) -> selectPassword()) .create()); @@ -296,6 +326,20 @@ private void onInvalidPassword() { } } + private class BackPressHandler extends OnBackPressedCallback { + public BackPressHandler() { + super(true); + } + + @Override + public void handleOnBackPressed() { + // This breaks predictive back gestures, but it doesn't make sense + // to go back to MainActivity when cancelling auth + setResult(RESULT_CANCELED); + finishAffinity(); + } + } + private class PasswordDerivationListener implements PasswordSlotDecryptTask.Callback { @Override public void onTaskFinished(PasswordSlotDecryptTask.Result result) { @@ -305,12 +349,15 @@ public void onTaskFinished(PasswordSlotDecryptTask.Result result) { _slots.replace(result.getSlot()); } - if (result.getSlot().getType() == Slot.TYPE_DERIVED) { + if (result.getSlot().getType() == Slot.TYPE_PASSWORD) { _prefs.resetPasswordReminderTimestamp(); } finish(result.getKey(), result.isSlotRepaired()); } else { + _decryptButton.setEnabled(true); + + _auditLogRepository.addVaultUnlockFailedPasswordEvent(); onInvalidPassword(); } } @@ -323,6 +370,7 @@ public void onAuthenticationError(int errorCode, @NonNull CharSequence errString _bioPrompt = null; if (!BiometricsHelper.isCanceled(errorCode)) { + _auditLogRepository.addVaultUnlockFailedBiometricsEvent(); Toast.makeText(AuthActivity.this, errString, Toast.LENGTH_LONG).show(); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java index 090ed81ef1..3601186147 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java @@ -7,7 +7,6 @@ import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; -import android.text.Editable; import android.text.TextWatcher; import android.view.Menu; import android.view.MenuItem; @@ -16,125 +15,180 @@ import android.view.animation.AccelerateInterpolator; import android.view.animation.AlphaAnimation; import android.view.animation.Animation; -import android.webkit.MimeTypeMap; -import android.widget.AdapterView; import android.widget.AutoCompleteTextView; +import android.widget.Button; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.RelativeLayout; +import android.widget.TextView; +import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; -import androidx.documentfile.provider.DocumentFile; import com.amulyakhare.textdrawable.TextDrawable; import com.avito.android.krop.KropView; import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.encoding.Base32; import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.encoding.Hex; +import com.beemdevelopment.aegis.helpers.AnimationsHelper; +import com.beemdevelopment.aegis.helpers.BitmapHelper; import com.beemdevelopment.aegis.helpers.DropdownHelper; import com.beemdevelopment.aegis.helpers.EditTextHelper; -import com.beemdevelopment.aegis.helpers.IconViewHelper; +import com.beemdevelopment.aegis.helpers.SafHelper; +import com.beemdevelopment.aegis.helpers.SimpleAnimationEndListener; +import com.beemdevelopment.aegis.helpers.SimpleTextWatcher; import com.beemdevelopment.aegis.helpers.TextDrawableHelper; +import com.beemdevelopment.aegis.helpers.ViewHelper; import com.beemdevelopment.aegis.icons.IconPack; import com.beemdevelopment.aegis.icons.IconType; import com.beemdevelopment.aegis.otp.GoogleAuthInfo; import com.beemdevelopment.aegis.otp.HotpInfo; +import com.beemdevelopment.aegis.otp.MotpInfo; import com.beemdevelopment.aegis.otp.OtpInfo; import com.beemdevelopment.aegis.otp.OtpInfoException; import com.beemdevelopment.aegis.otp.SteamInfo; import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.otp.YandexInfo; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; import com.beemdevelopment.aegis.ui.dialogs.IconPickerDialog; -import com.beemdevelopment.aegis.ui.glide.IconLoader; +import com.beemdevelopment.aegis.ui.glide.GlideHelper; +import com.beemdevelopment.aegis.ui.models.VaultGroupModel; import com.beemdevelopment.aegis.ui.tasks.ImportFileTask; import com.beemdevelopment.aegis.ui.views.IconAdapter; import com.beemdevelopment.aegis.util.Cloner; import com.beemdevelopment.aegis.util.IOUtils; import com.beemdevelopment.aegis.vault.VaultEntry; -import com.beemdevelopment.aegis.vault.VaultManager; +import com.beemdevelopment.aegis.vault.VaultEntryIcon; +import com.beemdevelopment.aegis.vault.VaultGroup; +import com.beemdevelopment.aegis.vault.VaultRepository; import com.bumptech.glide.Glide; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.request.target.CustomTarget; import com.bumptech.glide.request.transition.Transition; import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.imageview.ShapeableImageView; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.text.DateFormat; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.Date; +import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.Locale; -import java.util.TreeSet; +import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; -import de.hdodenhof.circleimageview.CircleImageView; - public class EditEntryActivity extends AegisActivity { - private static final int PICK_IMAGE_REQUEST = 0; - private boolean _isNew = false; private boolean _isManual = false; private VaultEntry _origEntry; - private TreeSet _groups; + private Collection _groups; private boolean _hasCustomIcon = false; // keep track of icon changes separately as the generated jpeg's are not deterministic private boolean _hasChangedIcon = false; private IconPack.Icon _selectedIcon; - private boolean _isEditingIcon; - private CircleImageView _iconView; + private String _pickedMimeType; + private ShapeableImageView _iconView; private ImageView _saveImageButton; private TextInputEditText _textName; private TextInputEditText _textIssuer; + private TextInputLayout _textGroupLayout; + private TextInputEditText _textGroup; private TextInputEditText _textPeriodCounter; private TextInputLayout _textPeriodCounterLayout; private TextInputEditText _textDigits; private TextInputLayout _textDigitsLayout; private TextInputEditText _textSecret; + private TextInputEditText _textPin; + private LinearLayout _textPinLayout; private TextInputEditText _textUsageCount; private TextInputEditText _textNote; + private TextView _textLastUsed; private AutoCompleteTextView _dropdownType; private AutoCompleteTextView _dropdownAlgo; private TextInputLayout _dropdownAlgoLayout; - private AutoCompleteTextView _dropdownGroup; - private List _dropdownGroupList = new ArrayList<>(); + private List _selectedGroups = new ArrayList<>(); private KropView _kropView; private RelativeLayout _advancedSettingsHeader; - private RelativeLayout _advancedSettings; + private LinearLayout _advancedSettingsLayout; + + private BackPressHandler _backPressHandler; + private IconBackPressHandler _iconBackPressHandler; - private VaultManager _vault; + private final ActivityResultLauncher pickImageResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + Intent data = activityResult.getData(); + if (activityResult.getResultCode() != RESULT_OK || data == null || data.getData() == null) { + return; + } + _pickedMimeType = SafHelper.getMimeType(this, data.getData()); + if (_pickedMimeType != null && _pickedMimeType.equals(IconType.SVG.toMimeType())) { + ImportFileTask.Params params = new ImportFileTask.Params(data.getData(), "icon", null); + ImportFileTask task = new ImportFileTask(this, result -> { + if (result.getError() == null) { + CustomSvgIcon icon = new CustomSvgIcon(result.getFile()); + selectIcon(icon); + } else { + Dialogs.showErrorDialog(this, R.string.reading_file_error, result.getError()); + } + }); + task.execute(getLifecycle(), params); + } else { + startEditingIcon(data.getData()); + } + }); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } setContentView(R.layout.activity_edit_entry); setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); - _vault = getApp().getVaultManager(); - _groups = _vault.getGroups(); + _groups = _vaultManager.getVault().getGroups(); ActionBar bar = getSupportActionBar(); - bar.setHomeAsUpIndicator(R.drawable.ic_close); - bar.setDisplayHomeAsUpEnabled(true); + if (bar != null) { + bar.setHomeAsUpIndicator(R.drawable.ic_outline_close_24); + bar.setDisplayHomeAsUpEnabled(true); + } + + _backPressHandler = new BackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _backPressHandler); + _iconBackPressHandler = new IconBackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _iconBackPressHandler); // retrieve info from the calling activity Intent intent = getIntent(); UUID entryUUID = (UUID) intent.getSerializableExtra("entryUUID"); if (entryUUID != null) { - _origEntry = _vault.getEntryByUUID(entryUUID); + _origEntry = _vaultManager.getVault().getEntryByUUID(entryUUID); } else { _origEntry = (VaultEntry) intent.getSerializableExtra("newEntry"); _isManual = intent.getBooleanExtra("isManual", false); @@ -148,30 +202,40 @@ protected void onCreate(Bundle savedInstanceState) { _saveImageButton = findViewById(R.id.iv_saveImage); _textName = findViewById(R.id.text_name); _textIssuer = findViewById(R.id.text_issuer); + _textGroup = findViewById(R.id.text_group); + _textGroupLayout = findViewById(R.id.text_group_layout); _textPeriodCounter = findViewById(R.id.text_period_counter); _textPeriodCounterLayout = findViewById(R.id.text_period_counter_layout); _textDigits = findViewById(R.id.text_digits); _textDigitsLayout = findViewById(R.id.text_digits_layout); _textSecret = findViewById(R.id.text_secret); + _textPin = findViewById(R.id.text_pin); + _textPinLayout = findViewById(R.id.layout_pin); _textUsageCount = findViewById(R.id.text_usage_count); _textNote = findViewById(R.id.text_note); + _textLastUsed = findViewById(R.id.text_last_used); _dropdownType = findViewById(R.id.dropdown_type); DropdownHelper.fillDropdown(this, _dropdownType, R.array.otp_types_array); _dropdownAlgoLayout = findViewById(R.id.dropdown_algo_layout); _dropdownAlgo = findViewById(R.id.dropdown_algo); DropdownHelper.fillDropdown(this, _dropdownAlgo, R.array.otp_algo_array); - _dropdownGroup = findViewById(R.id.dropdown_group); - updateGroupDropdownList(); - DropdownHelper.fillDropdown(this, _dropdownGroup, _dropdownGroupList); // if this is NOT a manually entered entry, move the "Secret" field from basic to advanced settings - if (!_isNew || (_isNew && !_isManual)) { + if (!_isNew || !_isManual) { + int secretIndex = 0; LinearLayout layoutSecret = findViewById(R.id.layout_secret); LinearLayout layoutBasic = findViewById(R.id.layout_basic); LinearLayout layoutAdvanced = findViewById(R.id.layout_advanced); layoutBasic.removeView(layoutSecret); - layoutAdvanced.addView(layoutSecret, 0); - ((LinearLayout.LayoutParams) layoutSecret.getLayoutParams()).topMargin = 0; + if (!_isNew) { + secretIndex = 1; + layoutBasic.removeView(_textPinLayout); + layoutAdvanced.addView(_textPinLayout, 0); + ((LinearLayout.LayoutParams) _textPinLayout.getLayoutParams()).topMargin = 0; + } else { + ((LinearLayout.LayoutParams) layoutSecret.getLayoutParams()).topMargin = 0; + } + layoutAdvanced.addView(layoutSecret, secretIndex); if (_isNew && !_isManual) { setViewEnabled(layoutAdvanced, false); @@ -183,22 +247,12 @@ protected void onCreate(Bundle savedInstanceState) { _advancedSettingsHeader = findViewById(R.id.accordian_header); _advancedSettingsHeader.setOnClickListener(v -> openAdvancedSettings()); - _advancedSettings = findViewById(R.id.expandableLayout); + _advancedSettingsLayout = findViewById(R.id.layout_advanced); // fill the fields with values if possible + GlideHelper.loadEntryIcon(Glide.with(this), _origEntry, _iconView); if (_origEntry.hasIcon()) { - IconViewHelper.setLayerType(_iconView, _origEntry.getIconType()); - Glide.with(this) - .asDrawable() - .load(_origEntry) - .set(IconLoader.ICON_TYPE, _origEntry.getIconType()) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .skipMemoryCache(false) - .into(_iconView); _hasCustomIcon = true; - } else { - TextDrawable drawable = TextDrawableHelper.generate(_origEntry.getIssuer(), _origEntry.getName(), _iconView); - _iconView.setImageDrawable(drawable); } _textName.setText(_origEntry.getName()); @@ -206,6 +260,7 @@ protected void onCreate(Bundle savedInstanceState) { _textNote.setText(_origEntry.getNote()); OtpInfo info = _origEntry.getInfo(); + if (info instanceof TotpInfo) { _textPeriodCounterLayout.setHint(R.string.period_hint); _textPeriodCounter.setText(Integer.toString(((TotpInfo) info).getPeriod())); @@ -219,20 +274,50 @@ protected void onCreate(Bundle savedInstanceState) { byte[] secretBytes = _origEntry.getInfo().getSecret(); if (secretBytes != null) { - String secretString = Base32.encode(secretBytes); + String secretString = (info instanceof MotpInfo) ? Hex.encode(secretBytes) : Base32.encode(secretBytes); _textSecret.setText(secretString); } _dropdownType.setText(_origEntry.getInfo().getType(), false); _dropdownAlgo.setText(_origEntry.getInfo().getAlgorithm(false), false); + + if (info instanceof YandexInfo) { + _textPin.setText(((YandexInfo) info).getPin()); + } else if (info instanceof MotpInfo) { + _textPin.setText(((MotpInfo) info).getPin()); + } + updateAdvancedFieldStatus(_origEntry.getInfo().getTypeId()); + updatePinFieldVisibility(_origEntry.getInfo().getTypeId()); - String group = _origEntry.getGroup(); - setGroup(group); + Set groups = _origEntry.getGroups(); + if (groups.isEmpty()) { + _textGroup.setText(getString(R.string.no_group)); + } else { + String text = groups.stream().map(uuid -> { + VaultGroup group = _vaultManager.getVault().getGroupByUUID(uuid); + return group.getName(); + }) + .collect(Collectors.joining(", ")); + _selectedGroups.addAll(groups); + _textGroup.setText(text); + } - // update the icon if the text changed - _textIssuer.addTextChangedListener(_iconChangeListener); - _textName.addTextChangedListener(_iconChangeListener); + // Update the icon if the issuer or name has changed + _textIssuer.addTextChangedListener(_nameChangeListener); + _textName.addTextChangedListener(_nameChangeListener); + + // Register listeners to trigger validation + _textIssuer.addTextChangedListener(_validationListener); + _textGroup.addTextChangedListener(_validationListener); + _textName.addTextChangedListener(_validationListener); + _textNote.addTextChangedListener(_validationListener); + _textSecret.addTextChangedListener(_validationListener); + _dropdownType.addTextChangedListener(_validationListener); + _dropdownAlgo.addTextChangedListener(_validationListener); + _textPeriodCounter.addTextChangedListener(_validationListener); + _textDigits.addTextChangedListener(_validationListener); + _textPin.addTextChangedListener(_validationListener); // show/hide period and counter fields on type change _dropdownType.setOnItemClickListener((parent, view, position, id) -> { @@ -245,144 +330,194 @@ protected void onCreate(Bundle savedInstanceState) { _textDigits.setText(String.valueOf(SteamInfo.DIGITS)); break; case TotpInfo.ID: + _dropdownAlgo.setText(OtpInfo.DEFAULT_ALGORITHM, false); _textPeriodCounterLayout.setHint(R.string.period_hint); _textPeriodCounter.setText(String.valueOf(TotpInfo.DEFAULT_PERIOD)); _textDigits.setText(String.valueOf(OtpInfo.DEFAULT_DIGITS)); break; case HotpInfo.ID: + _dropdownAlgo.setText(OtpInfo.DEFAULT_ALGORITHM, false); _textPeriodCounterLayout.setHint(R.string.counter); _textPeriodCounter.setText(String.valueOf(HotpInfo.DEFAULT_COUNTER)); _textDigits.setText(String.valueOf(OtpInfo.DEFAULT_DIGITS)); break; + case YandexInfo.ID: + _dropdownAlgo.setText(YandexInfo.DEFAULT_ALGORITHM, false); + _textPeriodCounterLayout.setHint(R.string.period_hint); + _textPeriodCounter.setText(String.valueOf(TotpInfo.DEFAULT_PERIOD)); + _textDigits.setText(String.valueOf(YandexInfo.DIGITS)); + break; + case MotpInfo.ID: + _dropdownAlgo.setText(MotpInfo.ALGORITHM, false); + _textPeriodCounterLayout.setHint(R.string.period_hint); + _textPeriodCounter.setText(String.valueOf(MotpInfo.PERIOD)); + _textDigits.setText(String.valueOf(MotpInfo.DIGITS)); + break; default: throw new RuntimeException(String.format("Unsupported OTP type: %s", type)); } updateAdvancedFieldStatus(type); + updatePinFieldVisibility(type); }); _iconView.setOnClickListener(v -> { startIconSelection(); }); - _dropdownGroup.setOnItemClickListener(new AdapterView.OnItemClickListener() { - private int prevPosition = _dropdownGroupList.indexOf(_dropdownGroup.getText().toString()); - - @Override - public void onItemClick(AdapterView parent, View view, int position, long id) { - if (position == _dropdownGroupList.size() - 1) { - Dialogs.showTextInputDialog(EditEntryActivity.this, R.string.set_group, R.string.group_name_hint, text -> { - String groupName = new String(text); - if (!groupName.isEmpty()) { - _groups.add(groupName); - updateGroupDropdownList(); - _dropdownGroup.setText(groupName, false); - } - }); - _dropdownGroup.setText(_dropdownGroupList.get(prevPosition), false); - } else { - prevPosition = position; - } + _textGroup.setShowSoftInputOnFocus(false); + _textGroup.setOnClickListener(v -> showGroupSelectionDialog()); + _textGroup.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + showGroupSelectionDialog(); } }); - _textUsageCount.setText(getPreferences().getUsageCount(entryUUID).toString()); - } + _textGroupLayout.setOnClickListener(v -> { + showGroupSelectionDialog(); + }); - private void updateAdvancedFieldStatus(String otpType) { - boolean enabled = !otpType.equals(SteamInfo.ID) && (!_isNew || _isManual); - _textDigitsLayout.setEnabled(enabled); - _textPeriodCounterLayout.setEnabled(enabled); - _dropdownAlgoLayout.setEnabled(enabled); + _textUsageCount.setText(_prefs.getUsageCount(entryUUID).toString()); + setLastUsedTimestamp(_prefs.getLastUsedTimestamp(entryUUID)); } - private void setGroup(String groupName) { - int pos = 0; - if (groupName != null) { - pos = _groups.contains(groupName) ? _groups.headSet(groupName).size() + 1 : 0; - } + private void showGroupSelectionDialog() { + BottomSheetDialog dialog = new BottomSheetDialog(this); + View view = getLayoutInflater().inflate(R.layout.dialog_select_groups, null); + dialog.setContentView(view); - _dropdownGroup.setText(_dropdownGroupList.get(pos), false); - } + ChipGroup chipGroup = view.findViewById(R.id.groupChipGroup); + TextView addGroupInfo = view.findViewById(R.id.addGroupInfo); + LinearLayout addGroup = view.findViewById(R.id.addGroup); + Button clearButton = view.findViewById(R.id.btnClear); + Button saveButton = view.findViewById(R.id.btnSave); - private void openAdvancedSettings() { - Animation fadeOut = new AlphaAnimation(1, 0); - fadeOut.setInterpolator(new AccelerateInterpolator()); - fadeOut.setDuration(220); - _advancedSettingsHeader.startAnimation(fadeOut); - - Animation fadeIn = new AlphaAnimation(0, 1); - fadeIn.setInterpolator(new AccelerateInterpolator()); - fadeIn.setDuration(250); + chipGroup.removeAllViews(); + addGroupInfo.setVisibility(View.VISIBLE); + addGroup.setVisibility(View.VISIBLE); - fadeOut.setAnimationListener(new Animation.AnimationListener() { - @Override - public void onAnimationStart(Animation animation) { + for (VaultGroup group : _groups) { + addChipTo(chipGroup, new VaultGroupModel(group), false); + } - } + addGroup.setOnClickListener(v1 -> { + Dialogs.TextInputListener onAddGroup = text -> { + String groupName = new String(text).trim(); + if (!groupName.isEmpty()) { + VaultGroup group = _vaultManager.getVault().findGroupByName(groupName); + if (group == null) { + group = new VaultGroup(groupName); + _vaultManager.getVault().addGroup(group); + } - @Override - public void onAnimationEnd(Animation animation) { - _advancedSettingsHeader.setVisibility(View.GONE); - _advancedSettings.startAnimation(fadeIn); - } + _selectedGroups.add(group.getUUID()); + addChipTo(chipGroup, new VaultGroupModel(group), true); + } + }; - @Override - public void onAnimationRepeat(Animation animation) { + Dialogs.showTextInputDialog(EditEntryActivity.this, R.string.set_group, R.string.group_name_hint, onAddGroup); + }); + saveButton.setOnClickListener(v1 -> { + if(getCheckedUUID(chipGroup).isEmpty()) { + _selectedGroups.clear(); + _textGroup.setText(getString(R.string.no_group)); + } else { + _selectedGroups.clear(); + _selectedGroups.addAll(getCheckedUUID(chipGroup)); + _textGroup.setText(getCheckedNames(chipGroup)); } + dialog.dismiss(); }); - fadeIn.setAnimationListener(new Animation.AnimationListener() { - @Override - public void onAnimationStart(Animation animation) { + clearButton.setOnClickListener(v1 -> { + chipGroup.clearCheck(); + }); - } + Dialogs.showSecureDialog(dialog); + } - @Override - public void onAnimationEnd(Animation animation) { - _advancedSettings.setVisibility(View.VISIBLE); - } + private void addChipTo(ChipGroup chipGroup, VaultGroupModel group, Boolean isNew) { + Chip chip = (Chip) getLayoutInflater().inflate(R.layout.chip_group_filter, null, false); + chip.setText(group.getName()); + chip.setCheckable(true); - @Override - public void onAnimationRepeat(Animation animation) { + chip.setChecked((!_selectedGroups.isEmpty() && _selectedGroups.contains(group.getUUID())) || isNew); + chip.setCheckedIconVisible(true); + chip.setTag(group); + chipGroup.addView(chip); + } - } - }); + private static Set getCheckedUUID(ChipGroup chipGroup) { + return chipGroup.getCheckedChipIds().stream() + .map(i -> { + Chip chip = chipGroup.findViewById(i); + VaultGroupModel group = (VaultGroupModel) chip.getTag(); + return group.getUUID(); + }) + .collect(Collectors.toSet()); } - private void updateGroupDropdownList() { - Resources res = getResources(); - _dropdownGroupList.clear(); - _dropdownGroupList.add(res.getString(R.string.no_group)); - _dropdownGroupList.addAll(_groups); - _dropdownGroupList.add(res.getString(R.string.new_group)); + private static String getCheckedNames(ChipGroup chipGroup) { + return chipGroup.getCheckedChipIds().stream() + .map(i -> { + Chip chip = chipGroup.findViewById(i); + VaultGroupModel group = (VaultGroupModel) chip.getTag(); + return group.getName(); + }) + .collect(Collectors.joining(", ")); } - @Override - public void onBackPressed() { - if (_isEditingIcon) { - stopEditingIcon(false); - return; - } + private void updateAdvancedFieldStatus(String otpType) { + boolean enabled = !otpType.equals(SteamInfo.ID) && !otpType.equals(YandexInfo.ID) + && !otpType.equals(MotpInfo.ID) && (!_isNew || _isManual); + _textDigitsLayout.setEnabled(enabled); + _textPeriodCounterLayout.setEnabled(enabled); + _dropdownAlgoLayout.setEnabled(enabled); + } + + private void updatePinFieldVisibility(String otpType) { + boolean visible = otpType.equals(YandexInfo.ID) || otpType.equals(MotpInfo.ID); + _textPinLayout.setVisibility(visible ? View.VISIBLE : View.GONE); + _textPin.setHint(otpType.equals(MotpInfo.ID) ? R.string.motp_pin : R.string.yandex_pin); + } + private void openAdvancedSettings() { + Animation fadeOut = new AlphaAnimation(1, 0); + fadeOut.setInterpolator(new AccelerateInterpolator()); + fadeOut.setDuration((long) (220 * AnimationsHelper.Scale.ANIMATOR.getValue(this))); + _advancedSettingsHeader.startAnimation(fadeOut); + + fadeOut.setAnimationListener(new SimpleAnimationEndListener((a) -> { + _advancedSettingsHeader.setVisibility(View.GONE); + _advancedSettingsLayout.setVisibility(View.VISIBLE); + _advancedSettingsLayout.animate() + .setInterpolator(new AccelerateInterpolator()) + .setDuration((long) (250 * AnimationsHelper.Scale.ANIMATOR.getValue(this))) + .alpha(1); + })); + } + + private boolean hasUnsavedChanges(VaultEntry newEntry) { + return _hasChangedIcon || !_origEntry.equals(newEntry); + } + + private void discardAndFinish() { AtomicReference msg = new AtomicReference<>(); AtomicReference entry = new AtomicReference<>(); - try { entry.set(parseEntry()); } catch (ParseException e) { msg.set(e.getMessage()); } - // close the activity if the entry has not been changed - if (!_hasChangedIcon && _origEntry.equals(entry.get())) { - super.onBackPressed(); + if (!hasUnsavedChanges(entry.get())) { + finish(); return; } // ask for confirmation if the entry has been changed - Dialogs.showDiscardDialog(this, + Dialogs.showDiscardDialog(EditEntryActivity.this, (dialog, which) -> { // if the entry couldn't be parsed, we show an error dialog if (msg.get() != null) { @@ -392,44 +527,39 @@ public void onBackPressed() { addAndFinish(entry.get()); }, - (dialog, which) -> super.onBackPressed() + (dialog, which) -> finish() ); } @Override public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - onBackPressed(); - break; - case R.id.action_save: - onSave(); - break; - case R.id.action_delete: - Dialogs.showDeleteEntriesDialog(this, Collections.singletonList(_origEntry), (dialog, which) -> { - deleteAndFinish(_origEntry); - }); - break; - case R.id.action_edit_icon: - startIconSelection(); - break; - case R.id.action_reset_usage_count: - Dialogs.showSecureDialog(new AlertDialog.Builder(this) - .setTitle(R.string.action_reset_usage_count) - .setMessage(R.string.action_reset_usage_count_dialog) - .setPositiveButton(android.R.string.yes, (dialog, which) -> resetUsageCount()) - .setNegativeButton(android.R.string.no, null) - .create()); - break; - case R.id.action_default_icon: - TextDrawable drawable = TextDrawableHelper.generate(_origEntry.getIssuer(), _origEntry.getName(), _iconView); - _iconView.setImageDrawable(drawable); - - _selectedIcon = null; - _hasCustomIcon = false; - _hasChangedIcon = true; - default: - return super.onOptionsItemSelected(item); + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + discardAndFinish(); + } else if (itemId == R.id.action_save) { + onSave(); + } else if (itemId == R.id.action_delete) { + Dialogs.showDeleteEntriesDialog(this, Collections.singletonList(_origEntry), (dialog, which) -> { + deleteAndFinish(_origEntry); + }); + } else if (itemId == R.id.action_edit_icon) { + startIconSelection(); + } else if (itemId == R.id.action_reset_usage_count) { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this) + .setTitle(R.string.action_reset_usage_count) + .setMessage(R.string.action_reset_usage_count_dialog) + .setPositiveButton(android.R.string.yes, (dialog, which) -> resetUsageCount()) + .setNegativeButton(android.R.string.no, null) + .create()); + } else if (itemId == R.id.action_default_icon) { + TextDrawable drawable = TextDrawableHelper.generate(_origEntry.getIssuer(), _origEntry.getName(), _iconView); + _iconView.setImageDrawable(drawable); + + _selectedIcon = null; + _hasCustomIcon = false; + _hasChangedIcon = true; + } else { + return super.onOptionsItemSelected(item); } return true; @@ -444,16 +574,16 @@ private void startImageSelectionActivity() { Intent chooserIntent = Intent.createChooser(galleryIntent, getString(R.string.select_icon)); chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[] { fileIntent }); - AegisActivity.Helper.startExtActivityForResult(this, chooserIntent, PICK_IMAGE_REQUEST); + _vaultManager.fireIntentLauncher(this, chooserIntent, pickImageResultLauncher); } private void resetUsageCount() { - getPreferences().resetUsageCount(_origEntry.getUUID()); + _prefs.resetUsageCount(_origEntry.getUUID()); _textUsageCount.setText("0"); } private void startIconSelection() { - List iconPacks = getApp().getIconPackManager().getIconPacks().stream() + List iconPacks = _iconPackManager.getIconPacks().stream() .sorted(Comparator.comparing(IconPack::getName)) .collect(Collectors.toList()); if (iconPacks.size() == 0) { @@ -461,7 +591,7 @@ private void startIconSelection() { return; } - BottomSheetDialog dialog = IconPickerDialog.create(this, iconPacks, _textIssuer.getText().toString(), new IconAdapter.Listener() { + BottomSheetDialog dialog = IconPickerDialog.create(this, iconPacks, _textIssuer.getText().toString(), true, new IconAdapter.Listener() { @Override public void onIconSelected(IconPack.Icon icon) { selectIcon(icon); @@ -480,14 +610,7 @@ private void selectIcon(IconPack.Icon icon) { _hasCustomIcon = true; _hasChangedIcon = true; - IconViewHelper.setLayerType(_iconView, icon.getIconType()); - Glide.with(EditEntryActivity.this) - .asDrawable() - .load(icon.getFile()) - .set(IconLoader.ICON_TYPE, icon.getIconType()) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .skipMemoryCache(false) - .into(_iconView); + GlideHelper.loadIcon(Glide.with(EditEntryActivity.this), icon, _iconView); } private void startEditingIcon(Uri data) { @@ -514,7 +637,7 @@ public void onLoadCleared(@Nullable Drawable placeholder) { stopEditingIcon(true); }); - _isEditingIcon = true; + _iconBackPressHandler.setEnabled(true); } private void stopEditingIcon(boolean save) { @@ -526,7 +649,7 @@ private void stopEditingIcon(boolean save) { _hasCustomIcon = _hasCustomIcon || save; _hasChangedIcon = save; - _isEditingIcon = false; + _iconBackPressHandler.setEnabled(false); } @Override @@ -547,17 +670,28 @@ private void addAndFinish(VaultEntry entry) { // vault to disk failed, causing the user to tap 'Save' again. Calling addEntry // again would cause a crash in that case, so the isEntryDuplicate check prevents // that. - if (_isNew && !_vault.isEntryDuplicate(entry)) { - _vault.addEntry(entry); + VaultRepository vault = _vaultManager.getVault(); + if (_isNew && !vault.isEntryDuplicate(entry)) { + vault.addEntry(entry); } else { - _vault.replaceEntry(entry); + vault.replaceEntry(entry); } saveAndFinish(entry, false); } + private void setLastUsedTimestamp(long timestamp) { + String readableDate = getString(R.string.last_used_never); + if (timestamp != 0) { + DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM, Locale.getDefault()); + readableDate = dateFormat.format(new Date(timestamp)); + } + + _textLastUsed.setText(String.format("%s: %s", getString(R.string.last_used), readableDate)); + } + private void deleteAndFinish(VaultEntry entry) { - _vault.removeEntry(entry); + _vaultManager.getVault().removeEntry(entry); saveAndFinish(entry, true); } @@ -566,52 +700,12 @@ private void saveAndFinish(VaultEntry entry, boolean delete) { intent.putExtra("entryUUID", entry.getUUID()); intent.putExtra("delete", delete); - if (saveVault(true)) { + if (saveAndBackupVault()) { setResult(RESULT_OK, intent); finish(); } } - @Override - protected void onActivityResult(int requestCode, final int resultCode, Intent data) { - if (requestCode == PICK_IMAGE_REQUEST && resultCode == RESULT_OK && data != null && data.getData() != null) { - String fileType = getMimeType(data.getData()); - if (fileType != null && fileType.equals(IconType.SVG.toMimeType())) { - ImportFileTask.Params params = new ImportFileTask.Params(data.getData(), "icon", null); - ImportFileTask task = new ImportFileTask(this, result -> { - if (result.getException() == null) { - CustomSvgIcon icon = new CustomSvgIcon(result.getFile()); - selectIcon(icon); - } else { - Dialogs.showErrorDialog(this, R.string.reading_file_error, result.getException()); - } - }); - task.execute(getLifecycle(), params); - } else { - startEditingIcon(data.getData()); - } - } - - super.onActivityResult(requestCode, resultCode, data); - } - - private String getMimeType(Uri uri) { - DocumentFile file = DocumentFile.fromSingleUri(this, uri); - if (file != null) { - String fileType = file.getType(); - if (fileType != null) { - return fileType; - } - - String ext = MimeTypeMap.getFileExtensionFromUrl(uri.toString()); - if (ext != null) { - return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext); - } - } - - return null; - } - private int parsePeriod() throws ParseException { try { return Integer.parseInt(_textPeriodCounter.getText().toString()); @@ -627,6 +721,17 @@ private VaultEntry parseEntry() throws ParseException { String type = _dropdownType.getText().toString(); String algo = _dropdownAlgo.getText().toString(); + String lowerCasedType = type.toLowerCase(Locale.ROOT); + + if (lowerCasedType.equals(YandexInfo.ID) || lowerCasedType.equals(MotpInfo.ID)) { + int pinLength = _textPin.length(); + if (pinLength < 4) { + throw new ParseException("PIN is a required field. Must have a minimum length of 4 digits."); + } + if (pinLength != 4 && lowerCasedType.equals(MotpInfo.ID)) { + throw new ParseException("PIN must have a length of 4 digits."); + } + } int digits; try { @@ -638,12 +743,18 @@ private VaultEntry parseEntry() throws ParseException { byte[] secret; try { String secretString = new String(EditTextHelper.getEditTextChars(_textSecret)); - secret = GoogleAuthInfo.parseSecret(secretString); + + secret = (lowerCasedType.equals(MotpInfo.ID)) ? + Hex.decode(secretString) : GoogleAuthInfo.parseSecret(secretString); + if (secret.length == 0) { throw new ParseException("Secret cannot be empty"); } } catch (EncodingException e) { - throw new ParseException("Secret is not valid base32."); + String exceptionMessage = (lowerCasedType.equals(MotpInfo.ID)) ? + "Secret is not valid hexadecimal" : "Secret is not valid base32."; + + throw new ParseException(exceptionMessage); } OtpInfo info; @@ -664,6 +775,12 @@ private VaultEntry parseEntry() throws ParseException { } info = new HotpInfo(secret, algo, digits, counter); break; + case YandexInfo.ID: + info = new YandexInfo(secret, _textPin.getText().toString()); + break; + case MotpInfo.ID: + info = new MotpInfo(secret, _textPin.getText().toString()); + break; default: throw new RuntimeException(String.format("Unsupported OTP type: %s", type)); } @@ -680,23 +797,23 @@ private VaultEntry parseEntry() throws ParseException { entry.setName(_textName.getText().toString()); entry.setNote(_textNote.getText().toString()); - int groupPos = _dropdownGroupList.indexOf(_dropdownGroup.getText().toString()); - if (groupPos != 0) { - String group = _dropdownGroupList.get(groupPos); - entry.setGroup(group); + if (_selectedGroups.isEmpty()) { + entry.setGroups(new HashSet<>()); } else { - entry.setGroup(null); + entry.setGroups(new HashSet<>(_selectedGroups)); } if (_hasChangedIcon) { if (_hasCustomIcon) { + VaultEntryIcon icon; if (_selectedIcon == null) { Bitmap bitmap = ((BitmapDrawable) _iconView.getDrawable()).getBitmap(); - ByteArrayOutputStream stream = new ByteArrayOutputStream(); - // the quality parameter is ignored for PNG - bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); - byte[] data = stream.toByteArray(); - entry.setIcon(data, IconType.PNG); + IconType iconType = _pickedMimeType == null + ? IconType.INVALID : IconType.fromMimeType(_pickedMimeType); + if (iconType == IconType.INVALID) { + iconType = bitmap.hasAlpha() ? IconType.PNG : IconType.JPEG; + } + icon = BitmapHelper.toVaultEntryIcon(bitmap, iconType); } else { byte[] iconBytes; try (FileInputStream inStream = new FileInputStream(_selectedIcon.getFile())){ @@ -704,11 +821,12 @@ private VaultEntry parseEntry() throws ParseException { } catch (IOException e) { throw new ParseException(e.getMessage()); } - - entry.setIcon(iconBytes, _selectedIcon.getIconType()); + icon = new VaultEntryIcon(iconBytes, _selectedIcon.getIconType()); } + + entry.setIcon(icon); } else { - entry.setIcon(null, IconType.INVALID); + entry.setIcon(null); } } @@ -716,15 +834,16 @@ private VaultEntry parseEntry() throws ParseException { } private void onSaveError(String msg) { - Dialogs.showSecureDialog(new AlertDialog.Builder(this) + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Aegis_AlertDialog_Error) .setTitle(getString(R.string.saving_profile_error)) .setMessage(msg) + .setIconAttribute(android.R.attr.alertDialogIcon) .setPositiveButton(android.R.string.ok, null) .create()); } private boolean onSave() { - if (_isEditingIcon) { + if (_iconBackPressHandler.isEnabled()) { stopEditingIcon(true); } @@ -736,10 +855,92 @@ private boolean onSave() { return false; } + if (_isNew) { + for (VaultEntry existing : _vaultManager.getVault().getEntries()) { + if (entry.hasSameNameAndIssuer(existing)) { + showDuplicateBottomSheet(entry); + return false; + } + } + } + addAndFinish(entry); return true; } + private void showDuplicateBottomSheet(VaultEntry newEntry) { + BottomSheetDialog dialog = new BottomSheetDialog(this); + View view = getLayoutInflater().inflate(R.layout.dialog_duplicate_entry, null); + dialog.setContentView(view); + + dialog.setCancelable(false); + + View overwrite = view.findViewById(R.id.overwrite_entry); + View addSuffix = view.findViewById(R.id.create_new_entry); + View cancel = view.findViewById(R.id.cancel_save); + + TextView suffixSubtext = view.findViewById(R.id.duplicate_suffix_subtitle); + + String baseName = newEntry.getName(); + Set existingNames = new HashSet<>(); + for (VaultEntry e : _vaultManager.getVault().getEntries()) { + if (e.getIssuer().equals(newEntry.getIssuer())) { + existingNames.add(e.getName()); + } + } + + int counter = 2; + String newName; + do { + newName = baseName + " #" + counter++; + } while (existingNames.contains(newName)); + + suffixSubtext.setText(getString(R.string.dialog_duplicate_entry_suffix_subtitle, newName)); + + overwrite.setOnClickListener(v -> { + List duplicates = new ArrayList<>(); + for (VaultEntry existing : _vaultManager.getVault().getEntries()) { + if (existing.hasSameNameAndIssuer(newEntry)) { + duplicates.add(existing); + } + } + + Resources res = getResources(); + String message = res.getQuantityString( + R.plurals.dialog_duplicate_entry_overwrite_dialog_message, + duplicates.size(), + duplicates.size(), + newEntry.getIssuer(), + newEntry.getName() + ); + + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.dialog_duplicate_entry_overwrite_dialog_title) + .setMessage(message) + .setPositiveButton(R.string.action_delete, (d, which) -> { + for (VaultEntry dup : duplicates) { + _vaultManager.getVault().removeEntry(dup); + } + + dialog.dismiss(); + addAndFinish(newEntry); + }) + .setNegativeButton(android.R.string.no, null) + .show(); + }); + + String finalNewName = newName; + addSuffix.setOnClickListener(v -> { + newEntry.setName(finalNewName); + dialog.dismiss(); + addAndFinish(newEntry); + }); + + cancel.setOnClickListener(v -> dialog.dismiss()); + + Dialogs.showSecureDialog(dialog); + } + private static void setViewEnabled(View view, boolean enabled) { view.setEnabled(enabled); @@ -751,23 +952,50 @@ private static void setViewEnabled(View view, boolean enabled) { } } - private final TextWatcher _iconChangeListener = new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { + private final TextWatcher _validationListener = new SimpleTextWatcher((s) -> { + updateBackPressHandlerState(); + }); + + private final TextWatcher _nameChangeListener = new SimpleTextWatcher((s) -> { + if (!_hasCustomIcon) { + TextDrawable drawable = TextDrawableHelper.generate(_textIssuer.getText().toString(), _textName.getText().toString(), _iconView); + _iconView.setImageDrawable(drawable); + } + }); + + private void updateBackPressHandlerState() { + VaultEntry entry = null; + try { + entry = parseEntry(); + } catch (ParseException ignored) { + + } + + boolean backEnabled = hasUnsavedChanges(entry); + _backPressHandler.setEnabled(backEnabled); + } + + private class BackPressHandler extends OnBackPressedCallback { + public BackPressHandler() { + super(false); } @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { + public void handleOnBackPressed() { + discardAndFinish(); + } + } + + private class IconBackPressHandler extends OnBackPressedCallback { + public IconBackPressHandler() { + super(false); } @Override - public void afterTextChanged(Editable s) { - if (!_hasCustomIcon) { - TextDrawable drawable = TextDrawableHelper.generate(_textIssuer.getText().toString(), _textName.getText().toString(), _iconView); - _iconView.setImageDrawable(drawable); - } + public void handleOnBackPressed() { + stopEditingIcon(false); } - }; + } private static class ParseException extends Exception { public ParseException(String message) { @@ -779,11 +1007,12 @@ private static class CustomSvgIcon extends IconPack.Icon { private final File _file; protected CustomSvgIcon(File file) { - super(file.getAbsolutePath(), null, null); + super(file.getAbsolutePath(), null, null, null); _file = file; } @Nullable + @Override public File getFile() { return _file; } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/GroupManagerActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/GroupManagerActivity.java index 1e7b81f026..4c29dea2e4 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/GroupManagerActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/GroupManagerActivity.java @@ -1,101 +1,230 @@ package com.beemdevelopment.aegis.ui; -import android.content.Intent; import android.os.Bundle; +import android.view.Menu; import android.view.MenuItem; import android.view.View; -import androidx.appcompat.app.AlertDialog; +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; import com.beemdevelopment.aegis.ui.views.GroupAdapter; +import com.beemdevelopment.aegis.util.Cloner; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.beemdevelopment.aegis.vault.VaultGroup; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import java.text.Collator; import java.util.ArrayList; -import java.util.TreeSet; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; public class GroupManagerActivity extends AegisActivity implements GroupAdapter.Listener { private GroupAdapter _adapter; - private TreeSet _groups; - private RecyclerView _slotsView; + private HashSet _removedGroups; + private RecyclerView _groupsView; private View _emptyStateView; + private BackPressHandler _backPressHandler; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } setContentView(R.layout.activity_groups); setSupportActionBar(findViewById(R.id.toolbar)); - - Intent intent = getIntent(); - _groups = new TreeSet<>(Collator.getInstance()); - _groups.addAll(intent.getStringArrayListExtra("groups")); - + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); if (getSupportActionBar() != null) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); getSupportActionBar().setDisplayShowHomeEnabled(true); } + _backPressHandler = new BackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _backPressHandler); + + _removedGroups = new HashSet<>(); + if (savedInstanceState != null) { + List removedGroups = savedInstanceState.getStringArrayList("removedGroups"); + if (removedGroups != null) { + for (String uuid : removedGroups) { + _removedGroups.add(UUID.fromString(uuid)); + } + } + } + + ItemTouchHelper touchHelper = new ItemTouchHelper(new ItemTouchHelper.Callback() { + @Override + public int getMovementFlags( + @NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder) { + + return makeMovementFlags(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0); + } + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { + int draggedItemIndex = viewHolder.getBindingAdapterPosition(); + int targetIndex = target.getBindingAdapterPosition(); + + _adapter.onItemMove(draggedItemIndex, targetIndex); + + return true; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { } + }); - // set up the recycler view _adapter = new GroupAdapter(this); - _slotsView= findViewById(R.id.list_slots); + _groupsView = findViewById(R.id.list_groups); LinearLayoutManager layoutManager = new LinearLayoutManager(this); - _slotsView.setLayoutManager(layoutManager); - _slotsView.setAdapter(_adapter); - _slotsView.setNestedScrollingEnabled(false); - - for (String group : _groups) { - _adapter.addGroup(group); + _groupsView.setLayoutManager(layoutManager); + _groupsView.setAdapter(_adapter); + _groupsView.setNestedScrollingEnabled(false); + touchHelper.attachToRecyclerView(_groupsView); + + for (VaultGroup group : _vaultManager.getVault().getGroups()) { + if (!_removedGroups.contains(group.getUUID())) { + _adapter.addGroup(group); + } } _emptyStateView = findViewById(R.id.vEmptyList); updateEmptyState(); } - private void updateEmptyState() { - if (_adapter.getItemCount() > 0) { - _slotsView.setVisibility(View.VISIBLE); - _emptyStateView.setVisibility(View.GONE); - } else { - _slotsView.setVisibility(View.GONE); - _emptyStateView.setVisibility(View.VISIBLE); + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + ArrayList removed = new ArrayList<>(); + for (UUID uuid : _removedGroups) { + removed.add(uuid.toString()); } + + outState.putStringArrayList("removedGroups", removed); + } + + @Override + public void onEditGroup(VaultGroup group) { + Dialogs.TextInputListener onEditGroup = text -> { + String newGroupName = new String(text).trim(); + if (!newGroupName.isEmpty()) { + VaultGroup newGroup = Cloner.clone(group); + newGroup.setName(newGroupName); + _adapter.replaceGroup(group.getUUID(), newGroup); + _backPressHandler.setEnabled(true); + } + }; + + Dialogs.showTextInputDialog(GroupManagerActivity.this, R.string.rename_group, R.string.group_name_hint, onEditGroup, group.getName()); } @Override - public void onRemoveGroup(String group) { - Dialogs.showSecureDialog(new AlertDialog.Builder(this) + public void onRemoveGroup(VaultGroup group) { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) .setTitle(R.string.remove_group) .setMessage(R.string.remove_group_description) + .setIconAttribute(android.R.attr.alertDialogIcon) .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> { - _groups.remove(group); + _removedGroups.add(group.getUUID()); _adapter.removeGroup(group); + _backPressHandler.setEnabled(true); + updateEmptyState(); + }) + .setNegativeButton(android.R.string.no, null) + .create()); + } + + public void onRemoveUnusedGroups() { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(R.string.remove_unused_groups) + .setMessage(R.string.remove_unused_groups_description) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> { + Set unusedGroups = new HashSet<>(_vaultManager.getVault().getGroups()); + unusedGroups.removeAll(_vaultManager.getVault().getUsedGroups()); + + for (VaultGroup group : unusedGroups) { + _removedGroups.add(group.getUUID()); + _adapter.removeGroup(group); + } + _backPressHandler.setEnabled(true); updateEmptyState(); }) .setNegativeButton(android.R.string.no, null) .create()); } + private void saveAndFinish() { + if (!_removedGroups.isEmpty()) { + for (UUID uuid : _removedGroups) { + _vaultManager.getVault().removeGroup(uuid); + } + } + + _vaultManager.getVault().replaceGroups(_adapter.getGroups()); + saveAndBackupVault(); + + finish(); + } + + private void discardAndFinish() { + if (_removedGroups.isEmpty()) { + finish(); + return; + } + + Dialogs.showDiscardDialog(this, + (dialog, which) -> saveAndFinish(), + (dialog, which) -> finish()); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_groups, menu); + return true; + } + @Override public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - onBackPressed(); - break; - default: - return super.onOptionsItemSelected(item); + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + discardAndFinish(); + } else if (itemId == R.id.action_save) { + saveAndFinish(); + } else if (itemId == R.id.action_delete_unused_groups) { + onRemoveUnusedGroups(); + } else { + return super.onOptionsItemSelected(item); } return true; } - @Override - public void onBackPressed() { - Intent intent = new Intent(); - intent.putExtra("groups", new ArrayList<>(_groups)); - setResult(RESULT_OK, intent); - finish(); + private void updateEmptyState() { + if (_adapter.getItemCount() > 0) { + _groupsView.setVisibility(View.VISIBLE); + _emptyStateView.setVisibility(View.GONE); + } else { + _groupsView.setVisibility(View.GONE); + _emptyStateView.setVisibility(View.VISIBLE); + } + } + + private class BackPressHandler extends OnBackPressedCallback { + public BackPressHandler() { + super(false); + } + + @Override + public void handleOnBackPressed() { + discardAndFinish(); + } } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java index a4fc4ccf00..da04bc3ca6 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java @@ -1,35 +1,40 @@ package com.beemdevelopment.aegis.ui; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; +import android.content.Intent; import android.content.pm.PackageManager; import android.os.Bundle; -import android.text.TextUtils; import android.view.Menu; import android.view.MenuItem; +import android.view.View; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.BitmapHelper; import com.beemdevelopment.aegis.helpers.FabScrollHelper; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.beemdevelopment.aegis.icons.IconType; import com.beemdevelopment.aegis.importers.DatabaseImporter; import com.beemdevelopment.aegis.importers.DatabaseImporterEntryException; import com.beemdevelopment.aegis.importers.DatabaseImporterException; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; import com.beemdevelopment.aegis.ui.models.ImportEntry; +import com.beemdevelopment.aegis.ui.tasks.IconOptimizationTask; +import com.beemdevelopment.aegis.ui.tasks.RootShellTask; import com.beemdevelopment.aegis.ui.views.ImportEntriesAdapter; import com.beemdevelopment.aegis.util.UUIDMap; import com.beemdevelopment.aegis.vault.VaultEntry; -import com.beemdevelopment.aegis.vault.VaultManager; +import com.beemdevelopment.aegis.vault.VaultEntryIcon; +import com.beemdevelopment.aegis.vault.VaultGroup; +import com.beemdevelopment.aegis.vault.VaultRepository; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.topjohnwu.superuser.Shell; +import com.google.android.material.snackbar.Snackbar; import java.io.File; import java.io.FileInputStream; @@ -37,26 +42,41 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; public class ImportEntriesActivity extends AegisActivity { + private View _view; private Menu _menu; + private RecyclerView _entriesView; private ImportEntriesAdapter _adapter; private FabScrollHelper _fabScrollHelper; + private UUIDMap _importedGroups; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } setContentView(R.layout.activity_import_entries); setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); + + _view = findViewById(R.id.importEntriesRootView); ActionBar bar = getSupportActionBar(); - bar.setHomeAsUpIndicator(R.drawable.ic_close); + bar.setHomeAsUpIndicator(R.drawable.ic_outline_close_24); bar.setDisplayHomeAsUpEnabled(true); _adapter = new ImportEntriesAdapter(); - RecyclerView entriesView = findViewById(R.id.list_entries); - entriesView.addOnScrollListener(new RecyclerView.OnScrollListener() { + _entriesView = findViewById(R.id.list_entries); + _entriesView.addOnScrollListener(new RecyclerView.OnScrollListener() { @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { super.onScrolled(recyclerView, dx, dy); @@ -65,13 +85,13 @@ public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { }); LinearLayoutManager layoutManager = new LinearLayoutManager(this); - entriesView.setLayoutManager(layoutManager); - entriesView.setAdapter(_adapter); - entriesView.setNestedScrollingEnabled(false); + _entriesView.setLayoutManager(layoutManager); + _entriesView.setAdapter(_adapter); + _entriesView.setNestedScrollingEnabled(false); FloatingActionButton fab = findViewById(R.id.fab); fab.setOnClickListener(v -> { - if (getApp().getVaultManager().getEntries().size() > 0 + if (_vaultManager.getVault().getEntries().size() > 0 && _menu.findItem(R.id.toggle_wipe_vault).isChecked()) { showWipeEntriesDialog(); } else { @@ -80,21 +100,36 @@ public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { }); _fabScrollHelper = new FabScrollHelper(fab); - Class importerType = (Class) getIntent().getSerializableExtra("importerType"); - startImport(importerType, (File) getIntent().getSerializableExtra("file")); + DatabaseImporter.Definition importerDef = (DatabaseImporter.Definition) getIntent().getSerializableExtra("importerDef"); + startImport(importerDef, (File) getIntent().getSerializableExtra("file")); } - private void startImport(@NonNull Class importerType, @Nullable File file) { + private void startImport(DatabaseImporter.Definition importerDef, @Nullable File file) { + DatabaseImporter importer = DatabaseImporter.create(this, importerDef.getType()); if (file == null) { - startImportApp(importerType); + if (importer.isInstalledAppVersionSupported()) { + startImportApp(importer); + } else { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(R.string.warning) + .setMessage(getString(R.string.app_version_error, importerDef.getName())) + .setCancelable(false) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(R.string.yes, (dialog1, which) -> { + startImportApp(importer); + }) + .setNegativeButton(R.string.no, (dialog1, which) -> { + finish(); + }) + .create()); + } } else { - startImportFile(importerType, file); + startImportFile(importer, file); } } - private void startImportFile(@NonNull Class importerType, @NonNull File file) { + private void startImportFile(@NonNull DatabaseImporter importer, @NonNull File file) { try (InputStream stream = new FileInputStream(file)) { - DatabaseImporter importer = DatabaseImporter.create(this, importerType); DatabaseImporter.State state = importer.read(stream); processImporterState(state); } catch (FileNotFoundException e) { @@ -105,28 +140,37 @@ private void startImportFile(@NonNull Class importer } } - private void startImportApp(@NonNull Class importerType) { - DatabaseImporter importer = DatabaseImporter.create(this, importerType); + private void startImportApp(@NonNull DatabaseImporter importer) { + RootShellTask task = new RootShellTask(this, shell -> { + if (isFinishing()) { + return; + } - // obtain the global root shell and close it immediately after we're done - // TODO: find a way to use SuFileInputStream with Shell.newInstance() - try (Shell shell = Shell.getShell()) { - if (!shell.isRoot()) { + if (shell == null || !shell.isRoot()) { Toast.makeText(this, R.string.root_error, Toast.LENGTH_SHORT).show(); finish(); return; } - DatabaseImporter.State state = importer.readFromApp(); - processImporterState(state); - } catch (PackageManager.NameNotFoundException e) { - e.printStackTrace(); - Toast.makeText(this, R.string.app_lookup_error, Toast.LENGTH_SHORT).show(); - finish(); - } catch (IOException | DatabaseImporterException e) { - e.printStackTrace(); - Dialogs.showErrorDialog(this, R.string.reading_file_error, e, (dialog, which) -> finish()); - } + try { + DatabaseImporter.State state = importer.readFromApp(shell); + processImporterState(state); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + Toast.makeText(this, R.string.app_lookup_error, Toast.LENGTH_SHORT).show(); + finish(); + } catch (DatabaseImporterException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.reading_file_error, e, (dialog, which) -> finish()); + } finally { + try { + shell.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + }); + task.execute(this); } private void processImporterState(DatabaseImporter.State state) { @@ -135,7 +179,7 @@ private void processImporterState(DatabaseImporter.State state) { state.decrypt(this, new DatabaseImporter.DecryptListener() { @Override public void onStateDecrypted(DatabaseImporter.State state) { - importDatabase(state); + processDecryptedImporterState(state); } @Override @@ -150,7 +194,7 @@ public void onCanceled() { } }); } else { - importDatabase(state); + processDecryptedImporterState(state); } } catch (DatabaseImporterException e) { e.printStackTrace(); @@ -158,7 +202,7 @@ public void onCanceled() { } } - private void importDatabase(DatabaseImporter.State state) { + private void processDecryptedImporterState(DatabaseImporter.State state) { DatabaseImporter.Result result; try { result = state.convert(); @@ -168,44 +212,43 @@ private void importDatabase(DatabaseImporter.State state) { return; } - UUIDMap entries = result.getEntries(); - for (VaultEntry entry : entries.getValues()) { - _adapter.addEntry(new ImportEntry(entry)); + Map icons = result.getEntries().getValues().stream() + .filter(e -> e.getIcon() != null + && !e.getIcon().getType().equals(IconType.SVG) + && !BitmapHelper.isVaultEntryIconOptimized(e.getIcon())) + .collect(Collectors.toMap(VaultEntry::getUUID, VaultEntry::getIcon)); + if (!icons.isEmpty()) { + IconOptimizationTask task = new IconOptimizationTask(this, newIcons -> { + for (Map.Entry mapEntry : newIcons.entrySet()) { + VaultEntry entry = result.getEntries().getByUUID(mapEntry.getKey()); + entry.setIcon(mapEntry.getValue()); + } + + processImporterResult(result); + }); + task.execute(getLifecycle(), icons); + } else { + processImporterResult(result); } + } - List errors = result.getErrors(); - if (errors.size() > 0) { - showErrorDialog(errors); + private void processImporterResult(DatabaseImporter.Result result) { + List importEntries = new ArrayList<>(); + for (VaultEntry entry : result.getEntries().getValues()) { + ImportEntry importEntry = new ImportEntry(entry); + _adapter.addEntry(importEntry); + importEntries.add(importEntry); } - } - private void showErrorDialog(List errors) { - Dialogs.showSecureDialog(new AlertDialog.Builder(this) - .setTitle(R.string.import_error_title) - .setMessage(getResources().getQuantityString(R.plurals.import_error_dialog, errors.size(), errors.size())) - .setPositiveButton(android.R.string.ok, null) - .setNeutralButton(getString(R.string.details), (dialog, which) -> showDetailedErrorDialog(errors)) - .create()); - } + _importedGroups = result.getGroups(); - private void showDetailedErrorDialog(List errors) { - List messages = new ArrayList<>(); - for (DatabaseImporterEntryException e : errors) { - messages.add(e.getMessage()); + List errors = result.getErrors(); + if (errors.size() > 0) { + String message = getResources().getQuantityString(R.plurals.import_error_dialog, errors.size(), errors.size()); + Dialogs.showMultiExceptionDialog(this, R.string.import_error_title, message, errors, null); } - String message = TextUtils.join("\n\n", messages); - Dialogs.showSecureDialog(new AlertDialog.Builder(this) - .setTitle(R.string.import_error_title) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .setNeutralButton(android.R.string.copy, (dialog2, which2) -> { - ClipboardManager clipboard = (ClipboardManager) this.getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText("text/plain", message); - clipboard.setPrimaryClip(clip); - Toast.makeText(this, R.string.errors_copied, Toast.LENGTH_SHORT).show(); - }) - .create()); + findDuplicates(importEntries); } private void showWipeEntriesDialog() { @@ -217,12 +260,45 @@ private void showWipeEntriesDialog() { } private void saveAndFinish(boolean wipeEntries) { - VaultManager vault = getApp().getVaultManager(); + VaultRepository vault = _vaultManager.getVault(); if (wipeEntries) { - vault.wipeEntries(); + vault.wipeContents(); } + // Given the list of selected entries, collect the UUID's of all groups + // that we're actually going to import List selectedEntries = _adapter.getCheckedEntries(); + List selectedGroupUuids = new ArrayList<>(); + for (ImportEntry entry : selectedEntries) { + selectedGroupUuids.addAll(entry.getEntry().getGroups()); + } + + // Add all of the new groups to the vault. If a group with the same name already + // exists in the vault, rewrite all entries in that group to reference the existing group. + for (VaultGroup importedGroup : _importedGroups) { + if (!selectedGroupUuids.contains(importedGroup.getUUID())) { + continue; + } + + VaultGroup existingGroup = vault.findGroupByUUID(importedGroup.getUUID()); + if (existingGroup != null) { + continue; + } + + existingGroup = vault.findGroupByName(importedGroup.getName()); + if (existingGroup == null) { + vault.addGroup(importedGroup); + } else { + for (ImportEntry entry : selectedEntries) { + Set entryGroups = entry.getEntry().getGroups(); + if (entryGroups.contains(importedGroup.getUUID())) { + entryGroups.remove(importedGroup.getUUID()); + entryGroups.add(existingGroup.getUUID()); + } + } + } + } + for (ImportEntry selectedEntry : selectedEntries) { VaultEntry entry = selectedEntry.getEntry(); @@ -234,15 +310,89 @@ private void saveAndFinish(boolean wipeEntries) { vault.addEntry(entry); } - if (saveVault(true)) { + if (saveAndBackupVault()) { String toastMessage = getResources().getQuantityString(R.plurals.imported_entries_count, selectedEntries.size(), selectedEntries.size()); Toast.makeText(this, toastMessage, Toast.LENGTH_SHORT).show(); + setResult(RESULT_OK, null); - finish(); + + if (_iconPackManager.hasIconPack()) { + ArrayList assignIconEntriesIds = new ArrayList<>(); + Intent assignIconIntent = new Intent(getBaseContext(), AssignIconsActivity.class); + for (ImportEntry entry : selectedEntries) { + assignIconEntriesIds.add(entry.getEntry().getUUID()); + } + + assignIconIntent.putExtra("entries", assignIconEntriesIds); + + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this) + .setTitle(R.string.import_assign_icons_dialog_title) + .setMessage(R.string.import_assign_icons_dialog_text) + .setPositiveButton(android.R.string.yes, (dialog, which) -> { + startActivity(assignIconIntent); + finish(); + }) + .setNegativeButton(android.R.string.no, ((dialogInterface, i) -> finish())) + .create()); + } else { + finish(); + } } } + private void findDuplicates(List importEntries) { + List duplicateEntries = new ArrayList<>(); + for (ImportEntry importEntry: importEntries) { + boolean exists = _vaultManager.getVault().getEntries().stream().anyMatch(item -> + item.getIssuer().equals(importEntry.getEntry().getIssuer()) && + Arrays.equals(item.getInfo().getSecret(), importEntry.getEntry().getInfo().getSecret())); + + if (exists) { + duplicateEntries.add(importEntry.getEntry().getUUID()); + } + } + + if (duplicateEntries.size() == 0) { + return; + } + + _adapter.setCheckboxStates(duplicateEntries, false); + Snackbar snackbar = Snackbar.make(_view, getResources().getQuantityString(R.plurals.import_duplicate_toast, duplicateEntries.size(), duplicateEntries.size()), Snackbar.LENGTH_INDEFINITE); + snackbar.addCallback(new Snackbar.Callback() { + @Override + public void onShown(Snackbar sb) { + int snackbarHeight = sb.getView().getHeight(); + + _entriesView.setPadding( + _entriesView.getPaddingLeft(), + _entriesView.getPaddingTop(), + _entriesView.getPaddingRight(), + _entriesView.getPaddingBottom() + snackbarHeight * 2 + ); + } + + @Override + public void onDismissed(Snackbar sb, int event) { + int snackbarHeight = sb.getView().getHeight(); + + _entriesView.setPadding( + _entriesView.getPaddingLeft(), + _entriesView.getPaddingTop(), + _entriesView.getPaddingRight(), + _entriesView.getPaddingBottom() - snackbarHeight * 2 + ); + } + }); + snackbar.setAction(R.string.undo, new View.OnClickListener() { + @Override + public void onClick(View v) { + _adapter.setCheckboxStates(duplicateEntries, true); + } + }); + snackbar.show(); + } + @Override public boolean onCreateOptionsMenu(Menu menu) { _menu = menu; @@ -252,18 +402,15 @@ public boolean onCreateOptionsMenu(Menu menu) { @Override public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - onBackPressed(); - break; - case R.id.toggle_checkboxes: - _adapter.toggleCheckboxes(); - break; - case R.id.toggle_wipe_vault: - item.setChecked(!item.isChecked()); - break; - default: - return super.onOptionsItemSelected(item); + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finish(); + } else if (itemId == R.id.toggle_checkboxes) { + _adapter.toggleCheckboxes(); + } else if (itemId == R.id.toggle_wipe_vault) { + item.setChecked(!item.isChecked()); + } else { + return super.onOptionsItemSelected(item); } return true; diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/IntroActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/IntroActivity.java index eb9846da27..e7aecc1839 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/IntroActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/IntroActivity.java @@ -1,10 +1,18 @@ package com.beemdevelopment.aegis.ui; +import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_BIOMETRIC; +import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_INVALID; +import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_NONE; +import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_PASS; + import android.os.Bundle; +import android.view.WindowManager; import android.view.inputmethod.InputMethodManager; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.beemdevelopment.aegis.R; -import com.beemdevelopment.aegis.ThemeMap; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; import com.beemdevelopment.aegis.ui.intro.IntroBaseActivity; import com.beemdevelopment.aegis.ui.intro.SlideFragment; @@ -12,23 +20,17 @@ import com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide; import com.beemdevelopment.aegis.ui.slides.SecuritySetupSlide; import com.beemdevelopment.aegis.ui.slides.WelcomeSlide; -import com.beemdevelopment.aegis.vault.Vault; import com.beemdevelopment.aegis.vault.VaultFile; import com.beemdevelopment.aegis.vault.VaultFileCredentials; -import com.beemdevelopment.aegis.vault.VaultFileException; -import com.beemdevelopment.aegis.vault.VaultManager; -import com.beemdevelopment.aegis.vault.VaultManagerException; +import com.beemdevelopment.aegis.vault.VaultRepository; +import com.beemdevelopment.aegis.vault.VaultRepositoryException; import com.beemdevelopment.aegis.vault.slots.BiometricSlot; import com.beemdevelopment.aegis.vault.slots.PasswordSlot; -import org.json.JSONObject; - -import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_BIOMETRIC; -import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_INVALID; -import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_NONE; -import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_PASS; - public class IntroActivity extends IntroBaseActivity { + // Permission request codes + private static final int CODE_PERM_NOTIFICATIONS = 0; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -40,12 +42,7 @@ protected void onCreate(Bundle savedInstanceState) { } @Override - protected void onSetTheme() { - setTheme(ThemeMap.NO_ACTION_BAR); - } - - @Override - protected boolean onBeforeSlideChanged(Class oldSlide, Class newSlide) { + protected boolean onBeforeSlideChanged(Class oldSlide, @NonNull Class newSlide) { // hide the keyboard before every slide change InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE); imm.hideSoftInputFromWindow(findViewById(android.R.id.content).getWindowToken(), 0); @@ -57,47 +54,76 @@ && getState().getInt("cryptType", CRYPT_TYPE_INVALID) == CRYPT_TYPE_NONE) { return true; } + if (oldSlide == WelcomeSlide.class + && newSlide == SecurityPickerSlide.class + && getState().getBoolean("imported")) { + skipToSlide(DoneSlide.class); + return true; + } + + // on the welcome page, we don't want the keyboard to push any views up + getWindow().setSoftInputMode(newSlide == WelcomeSlide.class + ? WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING + : WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + return false; } + @Override + protected void onAfterSlideChanged(@Nullable Class oldSlide, @NonNull Class newSlide) { + // If the user has enabled encryption, we need to request permission to show notifications + // in order to be able to show the "Vault unlocked" notification. + // + // NOTE: Disabled for now. See issue: #1047 + /*if (newSlide == DoneSlide.class && getState().getSerializable("creds") != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + PermissionHelper.request(this, CODE_PERM_NOTIFICATIONS, Manifest.permission.POST_NOTIFICATIONS); + } + }*/ + } + @Override protected void onDonePressed() { Bundle state = getState(); - int cryptType = state.getInt("cryptType", CRYPT_TYPE_INVALID); VaultFileCredentials creds = (VaultFileCredentials) state.getSerializable("creds"); - if (cryptType == CRYPT_TYPE_INVALID - || (cryptType == CRYPT_TYPE_NONE && creds != null) - || (cryptType == CRYPT_TYPE_PASS && (creds == null || !creds.getSlots().has(PasswordSlot.class))) - || (cryptType == CRYPT_TYPE_BIOMETRIC && (creds == null || !creds.getSlots().has(PasswordSlot.class) || !creds.getSlots().has(BiometricSlot.class)))) { - throw new RuntimeException(String.format("State of SecuritySetupSlide not properly propagated, cryptType: %d, creds: %s", cryptType, creds)); - } - - Vault vault = new Vault(); - VaultFile vaultFile = new VaultFile(); - try { - JSONObject obj = vault.toJson(); - if (cryptType == CRYPT_TYPE_NONE) { - vaultFile.setContent(obj); - } else { - vaultFile.setContent(obj, creds); + if (!state.getBoolean("imported")) { + int cryptType = state.getInt("cryptType", CRYPT_TYPE_INVALID); + if (cryptType == CRYPT_TYPE_INVALID + || (cryptType == CRYPT_TYPE_NONE && creds != null) + || (cryptType == CRYPT_TYPE_PASS && (creds == null || !creds.getSlots().has(PasswordSlot.class))) + || (cryptType == CRYPT_TYPE_BIOMETRIC && (creds == null || !creds.getSlots().has(PasswordSlot.class) || !creds.getSlots().has(BiometricSlot.class)))) { + throw new RuntimeException(String.format("State of SecuritySetupSlide not properly propagated, cryptType: %d, creds: %s", cryptType, creds)); } - VaultManager.save(getApplicationContext(), vaultFile); - } catch (VaultManagerException | VaultFileException e) { - e.printStackTrace(); - Dialogs.showErrorDialog(this, R.string.vault_init_error, e); - return; - } - - if (cryptType == CRYPT_TYPE_NONE) { - getApp().initVaultManager(vault, null); + try { + _vaultManager.initNew(creds); + } catch (VaultRepositoryException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.vault_init_error, e); + return; + } } else { - getApp().initVaultManager(vault, creds); + VaultFile vaultFile; + try { + vaultFile = VaultRepository.readVaultFile(this); + } catch (VaultRepositoryException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.vault_load_error, e); + return; + } + + try { + _vaultManager.loadFrom(vaultFile, creds); + } catch (VaultRepositoryException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.vault_load_error, e); + return; + } } // skip the intro from now on - getPreferences().setIntroDone(true); + _prefs.setIntroDone(true); setResult(RESULT_OK); finish(); diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/LicensesActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/LicensesActivity.java new file mode 100644 index 0000000000..a06ca776cc --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/LicensesActivity.java @@ -0,0 +1,40 @@ +package com.beemdevelopment.aegis.ui; + +import android.os.Bundle; + +import com.beemdevelopment.aegis.Preferences; +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.ThemeMap; +import com.beemdevelopment.aegis.helpers.ThemeHelper; +import com.mikepenz.aboutlibraries.LibsBuilder; +import com.mikepenz.aboutlibraries.ui.LibsActivity; + +import org.jetbrains.annotations.Nullable; + +import dagger.hilt.InstallIn; +import dagger.hilt.android.EarlyEntryPoint; +import dagger.hilt.android.EarlyEntryPoints; +import dagger.hilt.components.SingletonComponent; + +public class LicensesActivity extends LibsActivity { + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + LibsBuilder builder = new LibsBuilder() + .withSearchEnabled(true) + .withAboutMinimalDesign(true) + .withActivityTitle(getString(R.string.title_activity_licenses)); + setIntent(builder.intent(this)); + + Preferences _prefs = EarlyEntryPoints.get(getApplicationContext(), PrefEntryPoint.class).getPreferences(); + ThemeHelper themeHelper = new ThemeHelper(this, _prefs); + themeHelper.setTheme(ThemeMap.DEFAULT); + + super.onCreate(savedInstanceState); + } + + @EarlyEntryPoint + @InstallIn(SingletonComponent.class) + public interface PrefEntryPoint { + Preferences getPreferences(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java index 2b0442b3c5..7ff23b2597 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java @@ -1,165 +1,395 @@ package com.beemdevelopment.aegis.ui; import android.Manifest; +import android.annotation.SuppressLint; import android.content.ClipData; +import android.content.ClipDescription; import android.content.ClipboardManager; import android.content.Context; +import android.content.DialogInterface; import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; +import android.graphics.Typeface; import android.net.Uri; +import android.os.Build; import android.os.Bundle; +import android.os.PersistableBundle; import android.provider.Settings; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.style.StyleSpan; +import android.util.Log; +import android.view.KeyEvent; +import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; -import android.widget.LinearLayout; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.AutoCompleteTextView; +import android.widget.Button; +import android.widget.CheckBox; import android.widget.Toast; +import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.view.ActionMode; import androidx.appcompat.widget.SearchView; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; -import com.beemdevelopment.aegis.AegisApplication; +import com.beemdevelopment.aegis.GroupPlaceholderType; import com.beemdevelopment.aegis.Preferences; import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.SortCategory; -import com.beemdevelopment.aegis.ViewMode; import com.beemdevelopment.aegis.helpers.BitmapHelper; +import com.beemdevelopment.aegis.helpers.DropdownHelper; +import com.beemdevelopment.aegis.helpers.FabMenuHelper; import com.beemdevelopment.aegis.helpers.FabScrollHelper; import com.beemdevelopment.aegis.helpers.PermissionHelper; -import com.beemdevelopment.aegis.helpers.QrCodeAnalyzer; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.beemdevelopment.aegis.icons.IconType; import com.beemdevelopment.aegis.otp.GoogleAuthInfo; import com.beemdevelopment.aegis.otp.GoogleAuthInfoException; +import com.beemdevelopment.aegis.otp.OtpInfoException; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; -import com.beemdevelopment.aegis.ui.fragments.BackupsPreferencesFragment; -import com.beemdevelopment.aegis.ui.fragments.PreferencesFragment; +import com.beemdevelopment.aegis.ui.fragments.preferences.BackupsPreferencesFragment; +import com.beemdevelopment.aegis.ui.fragments.preferences.PreferencesFragment; +import com.beemdevelopment.aegis.ui.models.ErrorCardInfo; +import com.beemdevelopment.aegis.ui.models.VaultGroupModel; +import com.beemdevelopment.aegis.ui.tasks.IconOptimizationTask; +import com.beemdevelopment.aegis.ui.tasks.QrDecodeTask; import com.beemdevelopment.aegis.ui.views.EntryListView; +import com.beemdevelopment.aegis.util.ClipboardUtils; +import com.beemdevelopment.aegis.util.TimeUtils; +import com.beemdevelopment.aegis.util.UUIDMap; import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultEntryIcon; import com.beemdevelopment.aegis.vault.VaultFile; -import com.beemdevelopment.aegis.vault.VaultManager; -import com.beemdevelopment.aegis.vault.VaultManagerException; -import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.beemdevelopment.aegis.vault.VaultGroup; +import com.beemdevelopment.aegis.vault.VaultRepository; +import com.beemdevelopment.aegis.vault.VaultRepositoryException; +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; +import com.google.android.material.color.MaterialColors; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.zxing.BinaryBitmap; -import com.google.zxing.ChecksumException; -import com.google.zxing.FormatException; -import com.google.zxing.LuminanceSource; -import com.google.zxing.NotFoundException; -import com.google.zxing.RGBLuminanceSource; -import com.google.zxing.Reader; -import com.google.zxing.Result; -import com.google.zxing.common.HybridBinarizer; -import com.google.zxing.qrcode.QRCodeReader; - -import java.io.IOException; -import java.io.InputStream; +import com.google.android.material.textfield.TextInputEditText; +import com.google.android.material.textfield.TextInputLayout; +import com.google.common.base.Strings; + import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; public class MainActivity extends AegisActivity implements EntryListView.Listener { - // activity request codes - private static final int CODE_SCAN = 0; - private static final int CODE_ADD_ENTRY = 1; - private static final int CODE_EDIT_ENTRY = 2; - private static final int CODE_DO_INTRO = 3; - private static final int CODE_DECRYPT = 4; - private static final int CODE_PREFERENCES = 5; - private static final int CODE_SCAN_IMAGE = 6; - - // permission request codes + // Permission request codes private static final int CODE_PERM_CAMERA = 0; - private static final int CODE_PERM_READ_STORAGE = 1; - private AegisApplication _app; - private VaultManager _vault; private boolean _loaded; - private boolean _searchSubmitted; - - private boolean _isAuthenticating; - private boolean _isDoingIntro; private boolean _isRecreated; + private boolean _isDPadPressed; + private boolean _isDoingIntro; + private boolean _isAuthenticating; + + private String _submittedSearchQuery; + private String _pendingSearchQuery; private List _selectedEntries; - private ActionMode _actionMode; private Menu _menu; private SearchView _searchView; private EntryListView _entryListView; - private LinearLayout _btnBackupError; + + private Collection _groups; + private ChipGroup _groupChip; + private Set _groupFilter; + private Set _prefGroupFilter; private FabScrollHelper _fabScrollHelper; + private FabMenuHelper _fabMenuHelper; + private ActionMode _actionMode; private ActionMode.Callback _actionModeCallbacks = new ActionModeCallbacks(); + private LockBackPressHandler _lockBackPressHandler; + private SearchViewBackPressHandler _searchViewBackPressHandler; + private ActionModeBackPressHandler _actionModeBackPressHandler; + private FabMenuBackPressHandler _fabMenuBackPressHandler; + + private final ActivityResultLauncher authResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + _isAuthenticating = false; + if (activityResult.getResultCode() == RESULT_OK) { + onDecryptResult(); + } + }); + + private final ActivityResultLauncher introResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + _isDoingIntro = false; + if (activityResult.getResultCode() == RESULT_OK) { + onIntroResult(); + } + }); + + private final ActivityResultLauncher scanResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { + return; + } + onScanResult(activityResult.getData()); + }); + + private final ActivityResultLauncher assignIconsResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { + return; + } + onAssignIconsResult(); + }); + + private final ActivityResultLauncher preferenceResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> onPreferencesResult()); + + private final ActivityResultLauncher editEntryResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { + return; + } + onEditEntryResult(); + }); + + private final ActivityResultLauncher addEntryResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { + return; + } + onAddEntryResult(activityResult.getData()); + }); + + private final ActivityResultLauncher codeScanResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + if (activityResult.getResultCode() == RESULT_OK && activityResult.getData() != null) { + onScanImageResult(activityResult.getData()); + } + }); + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); setSupportActionBar(findViewById(R.id.toolbar)); - - _app = (AegisApplication) getApplication(); - _vault = _app.getVaultManager(); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); _loaded = false; - + _isDPadPressed = false; + _isDoingIntro = false; + _isAuthenticating = false; if (savedInstanceState != null) { _isRecreated = true; - _isAuthenticating = savedInstanceState.getBoolean("isAuthenticating"); + _pendingSearchQuery = savedInstanceState.getString("pendingSearchQuery"); + _submittedSearchQuery = savedInstanceState.getString("submittedSearchQuery"); _isDoingIntro = savedInstanceState.getBoolean("isDoingIntro"); + _isAuthenticating = savedInstanceState.getBoolean("isAuthenticating"); } + _lockBackPressHandler = new LockBackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _lockBackPressHandler); + _searchViewBackPressHandler = new SearchViewBackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _searchViewBackPressHandler); + _actionModeBackPressHandler = new ActionModeBackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _actionModeBackPressHandler); + _fabMenuBackPressHandler = new FabMenuBackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _fabMenuBackPressHandler); + _entryListView = (EntryListView) getSupportFragmentManager().findFragmentById(R.id.key_profiles); _entryListView.setListener(this); - _entryListView.setCodeGroupSize(getPreferences().getCodeGroupSize()); - _entryListView.setShowAccountName(getPreferences().isAccountNameVisible()); - _entryListView.setHighlightEntry(getPreferences().isEntryHighlightEnabled()); - _entryListView.setPauseFocused(getPreferences().isPauseFocusedEnabled()); - _entryListView.setTapToReveal(getPreferences().isTapToRevealEnabled()); - _entryListView.setTapToRevealTime(getPreferences().getTapToRevealTime()); - _entryListView.setSortCategory(getPreferences().getCurrentSortCategory(), false); - _entryListView.setViewMode(getPreferences().getCurrentViewMode()); - _entryListView.setIsCopyOnTapEnabled(getPreferences().isCopyOnTapEnabled()); - _entryListView.setPrefGroupFilter(getPreferences().getGroupFilter()); - - FloatingActionButton fab = findViewById(R.id.fab); - fab.setOnClickListener(v -> { - View view = getLayoutInflater().inflate(R.layout.dialog_add_entry, null); - BottomSheetDialog dialog = new BottomSheetDialog(this); - dialog.setContentView(view); - - view.findViewById(R.id.fab_enter).setOnClickListener(v1 -> { - dialog.dismiss(); - startEditEntryActivityForManual(CODE_ADD_ENTRY); - }); - view.findViewById(R.id.fab_scan_image).setOnClickListener(v2 -> { - dialog.dismiss(); - startScanImageActivity(); - }); - view.findViewById(R.id.fab_scan).setOnClickListener(v3 -> { - dialog.dismiss(); - startScanActivity(); - }); - - Dialogs.showSecureDialog(dialog); - }); - - _btnBackupError = findViewById(R.id.btn_backup_error); - _btnBackupError.setOnClickListener(view -> { - startPreferencesActivity(BackupsPreferencesFragment.class, "pref_backups"); - }); - + _entryListView.setCodeGroupSize(_prefs.getCodeGroupSize()); + _entryListView.setAccountNamePosition(_prefs.getAccountNamePosition()); + _entryListView.setShowIcon(_prefs.isIconVisible()); + _entryListView.setShowExpirationState(_prefs.getShowExpirationState()); + _entryListView.setShowNextCode(_prefs.getShowNextCode()); + _entryListView.setOnlyShowNecessaryAccountNames(_prefs.onlyShowNecessaryAccountNames()); + _entryListView.setHighlightEntry(_prefs.isEntryHighlightEnabled()); + _entryListView.setPauseFocused(_prefs.isPauseFocusedEnabled()); + _entryListView.setTapToReveal(_prefs.isTapToRevealEnabled()); + _entryListView.setTapToRevealTime(_prefs.getTapToRevealTime()); + _entryListView.setViewMode(_prefs.getCurrentViewMode()); + _entryListView.setSortCategory(_prefs.getCurrentSortCategory(), false); + _entryListView.setCopyBehavior(_prefs.getCopyBehavior()); + _entryListView.setSearchBehaviorMask(_prefs.getSearchBehaviorMask()); + _prefGroupFilter = _prefs.getGroupFilter(); + + View scrimOverlayLayout = LayoutInflater.from(this).inflate(R.layout.scrim_layout, null); + View scrimOverlay = scrimOverlayLayout.findViewById(R.id.scrim); + addContentView(scrimOverlayLayout, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + + View fabMenuLayout = LayoutInflater.from(this).inflate(R.layout.fab_menu, null); + addContentView(fabMenuLayout, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + + ViewGroup menuItemsContainer = fabMenuLayout.findViewById(R.id.fab_menu_items_container); + FloatingActionButton fab = fabMenuLayout.findViewById(R.id.fab); + + LinkedHashMap actions = new LinkedHashMap<>(); + actions.put(fabMenuLayout.findViewById(R.id.fab_menu_item_scan), this::startScanActivity); + actions.put(fabMenuLayout.findViewById(R.id.fab_menu_item_scan_image), this::startScanImageActivity); + actions.put(fabMenuLayout.findViewById(R.id.fab_menu_item_enter), this::startEditEntryActivity); + + _fabMenuHelper = new FabMenuHelper(scrimOverlay, menuItemsContainer, fab, actions); + _fabMenuHelper.setOnFabMenuStateChangeListener(_fabMenuBackPressHandler::setEnabled); + + _groupChip = findViewById(R.id.groupChipGroup); _fabScrollHelper = new FabScrollHelper(fab); _selectedEntries = new ArrayList<>(); } - @Override - protected void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - outState.putBoolean("isAuthenticating", _isAuthenticating); - outState.putBoolean("isDoingIntro", _isDoingIntro); + public void setGroups(Collection groups) { + _groups = groups; + _groupChip.setVisibility(_groups.isEmpty() ? View.GONE : View.VISIBLE); + + if (_prefGroupFilter != null) { + Set groupFilter = cleanGroupFilter(_prefGroupFilter); + _prefGroupFilter = null; + if (!groupFilter.isEmpty()) { + _groupFilter = groupFilter; + _entryListView.setGroupFilter(groupFilter); + } + } else if (_groupFilter != null) { + Set groupFilter = cleanGroupFilter(_groupFilter); + if (!_groupFilter.equals(groupFilter)) { + _groupFilter = groupFilter; + _entryListView.setGroupFilter(groupFilter); + } + } + + _entryListView.setGroups(groups); + initializeGroups(); + } + + private void initializeGroups() { + _groupChip.removeAllViews(); + _groupChip.setSingleSelection(!_prefs.isGroupMultiselectEnabled()); + + for (VaultGroup group : _groups) { + addChipTo(_groupChip, new VaultGroupModel(group)); + } + + GroupPlaceholderType placeholderType = GroupPlaceholderType.NO_GROUP; + addChipTo(_groupChip, new VaultGroupModel(this, placeholderType)); + addSaveChip(_groupChip); + } + + private Set cleanGroupFilter(Set groupFilter) { + Set groupUuids = _groups.stream().map(UUIDMap.Value::getUUID).collect(Collectors.toSet()); + + return groupFilter.stream() + .filter(g -> g == null || groupUuids.contains(g)) + .collect(Collectors.toSet()); + } + + private void addChipTo(ChipGroup chipGroup, VaultGroupModel group) { + Chip chip = (Chip) getLayoutInflater().inflate(R.layout.chip_group_filter, null, false); + chip.setText(group.getName()); + chip.setCheckable(true); + chip.setCheckedIconVisible(false); + chip.setChecked(_groupFilter != null && _groupFilter.contains(group.getUUID())); + + if (group.isPlaceholder()) { + GroupPlaceholderType groupPlaceholderType = group.getPlaceholderType(); + chip.setTag(groupPlaceholderType); + + if (groupPlaceholderType == GroupPlaceholderType.ALL) { + chip.setChecked(_groupFilter == null); + } else if (groupPlaceholderType == GroupPlaceholderType.NO_GROUP) { + chip.setChecked(_groupFilter != null && _groupFilter.contains(null)); + } + } else { + chip.setTag(group); + } + + chip.setOnCheckedChangeListener((group1, isChecked) -> { + if (_actionMode != null) { + _actionMode.finish(); + } + + setSaveChipVisibility(true); + + // Reset group filter if last checked group gets unchecked + if (!isChecked && _groupFilter.size() == 1) { + Set groupFilter = new HashSet<>(); + + chipGroup.clearCheck(); + _groupFilter = groupFilter; + _entryListView.setGroupFilter(groupFilter); + return; + } + + _groupFilter = getGroupFilter(chipGroup); + _entryListView.setGroupFilter(_groupFilter); + }); + + chipGroup.addView(chip); + } + + private void addSaveChip(ChipGroup chipGroup) { + Chip chip = (Chip) getLayoutInflater().inflate(R.layout.chip_group_filter, null, false); + + chip.setText(getString(R.string.save)); + chip.setVisibility(View.GONE); + chip.setChipStrokeWidth(0); + chip.setCheckable(false); + chip.setChipBackgroundColorResource(android.R.color.transparent); + chip.setTextColor(MaterialColors.getColor(chip.getRootView(), com.google.android.material.R.attr.colorSecondary)); + chip.setClickable(true); + chip.setCheckedIconVisible(false); + chip.setOnClickListener(v -> { + onSaveGroupFilter(_groupFilter); + setSaveChipVisibility(false); + }); + + chipGroup.addView(chip); + } + + private void setSaveChipVisibility(boolean visible) { + Chip saveChip = (Chip) _groupChip.getChildAt(_groupChip.getChildCount() - 1); + saveChip.setChecked(false); + saveChip.setVisibility(visible ? View.VISIBLE : View.GONE); + } + + private static Set getGroupFilter(ChipGroup chipGroup) { + return chipGroup.getCheckedChipIds().stream() + .filter(Objects::nonNull) + .map(i -> { + Chip chip = chipGroup.findViewById(i); + + if (chip.getTag() instanceof VaultGroupModel) { + VaultGroupModel group = (VaultGroupModel) chip.getTag(); + return group.getUUID(); + } + + return null; + }) + .collect(Collectors.toSet()); } @Override @@ -172,46 +402,28 @@ protected void onDestroy() { protected void onPause() { Map usageMap = _entryListView.getUsageCounts(); if (usageMap != null) { - getPreferences().setUsageCount(usageMap); + _prefs.setUsageCount(usageMap); + } + + Map lastUsedMap = _entryListView.getLastUsedTimestamps(); + if (lastUsedMap != null) { + _prefs.setLastUsedTimestamps(lastUsedMap); } super.onPause(); } - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - _isAuthenticating = false; - _isDoingIntro = false; - - if (resultCode != RESULT_OK) { - return; - } - - switch (requestCode) { - case CODE_SCAN: - onScanResult(data); - break; - case CODE_ADD_ENTRY: - onAddEntryResult(data); - break; - case CODE_EDIT_ENTRY: - onEditEntryResult(data); - break; - case CODE_DO_INTRO: - onDoIntroResult(); - break; - case CODE_DECRYPT: - onDecryptResult(); - break; - case CODE_PREFERENCES: - onPreferencesResult(data); - break; - case CODE_SCAN_IMAGE: - onScanImageResult(data); + protected void onSaveInstanceState(@NonNull Bundle instance) { + super.onSaveInstanceState(instance); + instance.putString("pendingSearchQuery", _pendingSearchQuery); + instance.putString("submittedSearchQuery", _submittedSearchQuery); + instance.putBoolean("isDoingIntro", _isDoingIntro); + instance.putBoolean("isAuthenticating", _isAuthenticating); + + if (_groupFilter != null) { + instance.putSerializable("prefGroupFilter", new HashSet<>(_groupFilter)); } - - super.onActivityResult(requestCode, resultCode, data); } @Override @@ -221,150 +433,329 @@ public void onRequestPermissionsResult(int requestCode, String[] permissions, in return; } - switch (requestCode) { - case CODE_PERM_CAMERA: - startScanActivity(); - break; - case CODE_PERM_READ_STORAGE: - startScanImageActivity(); - break; + if (requestCode == CODE_PERM_CAMERA) { + startScanActivity(); } super.onRequestPermissionsResult(requestCode, permissions, grantResults); } - private void onPreferencesResult(Intent data) { + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + _isDPadPressed = isDPadKey(keyCode); + return super.onKeyDown(keyCode, event); + } + + private static boolean isDPadKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_DPAD_DOWN || keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT || keyCode == KeyEvent.KEYCODE_DPAD_LEFT; + } + + @Override + public void onEntryListTouch() { + _isDPadPressed = false; + + if (_searchView != null && !_searchView.isIconified()) { + if (ViewCompat.getRootWindowInsets(findViewById(android.R.id.content).getRootView()).isVisible(WindowInsetsCompat.Type.ime())) { + InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + if (inputMethodManager != null && getCurrentFocus() != null) { + inputMethodManager.hideSoftInputFromWindow(getCurrentFocus().getWindowToken(), 0); + } + } + } + } + + private void onPreferencesResult() { // refresh the entire entry list if needed if (_loaded) { - if (data.getBooleanExtra("needsRecreate", false)) { - recreate(); - } else if (data.getBooleanExtra("needsRefresh", false)) { - boolean showAccountName = getPreferences().isAccountNameVisible(); - int codeGroupSize = getPreferences().getCodeGroupSize(); - boolean highlightEntry = getPreferences().isEntryHighlightEnabled(); - boolean pauseFocused = getPreferences().isPauseFocusedEnabled(); - boolean tapToReveal = getPreferences().isTapToRevealEnabled(); - int tapToRevealTime = getPreferences().getTapToRevealTime(); - ViewMode viewMode = getPreferences().getCurrentViewMode(); - boolean copyOnTap = getPreferences().isCopyOnTapEnabled(); - _entryListView.setShowAccountName(showAccountName); - _entryListView.setCodeGroupSize(codeGroupSize); - _entryListView.setHighlightEntry(highlightEntry); - _entryListView.setPauseFocused(pauseFocused); - _entryListView.setTapToReveal(tapToReveal); - _entryListView.setTapToRevealTime(tapToRevealTime); - _entryListView.setViewMode(viewMode); - _entryListView.setIsCopyOnTapEnabled(copyOnTap); - _entryListView.refresh(true); + recreate(); + } + } + + private void startEditEntryActivity() { + String clip = ClipboardUtils.readText(this); + if (clip != null) { + GoogleAuthInfo parsed; + try { + parsed = GoogleAuthInfo.parseUri(clip.trim()); + String message = getString( + R.string.import_from_clipboard_message, + parsed.getAccountName(), + parsed.getIssuer() + ); + + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this) + .setTitle(R.string.import_from_clipboard_title) + .setMessage(message) + .setPositiveButton(R.string.yes, (dialog, which) -> startEditEntryActivityForNew(new VaultEntry(parsed))) + .setNegativeButton(R.string.no, (dialog, which) -> startEditEntryActivityForManual()) + .create()); + + return; + } catch (GoogleAuthInfoException e) { + Log.i("EntryActivity", "Clipboard did not contain a valid otpauth URI", e); } } + + startEditEntryActivityForManual(); } - private void startEditEntryActivityForNew(int requestCode, VaultEntry entry) { + private void startEditEntryActivityForNew(VaultEntry entry) { Intent intent = new Intent(this, EditEntryActivity.class); intent.putExtra("newEntry", entry); intent.putExtra("isManual", false); - startActivityForResult(intent, requestCode); + addEntryResultLauncher.launch(intent); } - private void startEditEntryActivityForManual(int requestCode) { + private void startEditEntryActivityForManual() { Intent intent = new Intent(this, EditEntryActivity.class); intent.putExtra("newEntry", VaultEntry.getDefault()); intent.putExtra("isManual", true); - startActivityForResult(intent, requestCode); + addEntryResultLauncher.launch(intent); } - private void startEditEntryActivity(int requestCode, VaultEntry entry) { + private void startEditEntryActivity(VaultEntry entry) { Intent intent = new Intent(this, EditEntryActivity.class); intent.putExtra("entryUUID", entry.getUUID()); - startActivityForResult(intent, requestCode); + editEntryResultLauncher.launch(intent); } - private void onScanResult(Intent data) { - List entries = (ArrayList) data.getSerializableExtra("entries"); - if (entries.size() == 1) { - startEditEntryActivityForNew(CODE_ADD_ENTRY, entries.get(0)); - } else { - for (VaultEntry entry : entries) { - _vault.addEntry(entry); - if (_loaded) { - _entryListView.addEntry(entry); - } + private void startAssignIconsActivity(List entries) { + ArrayList assignIconEntriesIds = new ArrayList<>(); + Intent assignIconIntent = new Intent(getBaseContext(), AssignIconsActivity.class); + for (VaultEntry entry : entries) { + assignIconEntriesIds.add(entry.getUUID()); + } + + assignIconIntent.putExtra("entries", assignIconEntriesIds); + assignIconsResultLauncher.launch(assignIconIntent); + } + + private void startAssignGroupsDialog() { + View view = LayoutInflater.from(this).inflate(R.layout.dialog_select_group, null); + TextInputLayout groupSelectionLayout = view.findViewById(R.id.group_selection_layout); + AutoCompleteTextView groupsSelection = view.findViewById(R.id.group_selection_dropdown); + TextInputLayout newGroupLayout = view.findViewById(R.id.text_group_name_layout); + TextInputEditText newGroupText = view.findViewById(R.id.text_group_name); + + Collection groups = _vaultManager.getVault().getUsedGroups(); + List groupModels = new ArrayList<>(); + groupModels.add(new VaultGroupModel(this, GroupPlaceholderType.NEW_GROUP)); + groupModels.addAll(groups.stream().map(VaultGroupModel::new).collect(Collectors.toList())); + DropdownHelper.fillDropdown(this, groupsSelection, groupModels); + + AtomicReference groupModelRef = new AtomicReference<>(); + groupsSelection.setOnItemClickListener((parent, view1, position, id) -> { + VaultGroupModel groupModel = (VaultGroupModel) parent.getItemAtPosition(position); + groupModelRef.set(groupModel); + + if (groupModel.isPlaceholder()) { + newGroupLayout.setVisibility(View.VISIBLE); + newGroupText.requestFocus(); + } else { + newGroupLayout.setVisibility(View.GONE); } - saveVault(true); + groupSelectionLayout.setError(null); + }); + + AlertDialog dialog = new MaterialAlertDialogBuilder(this) + .setTitle(R.string.assign_groups) + .setView(view) + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(android.R.string.cancel, null) + .create(); + + dialog.setOnShowListener(d -> { + Button btnPos = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + btnPos.setOnClickListener(v -> { + VaultGroupModel groupModel = groupModelRef.get(); + if (groupModel == null) { + groupSelectionLayout.setError(getString(R.string.error_required_field)); + return; + } + + if (groupModel.isPlaceholder()) { + String newGroupName = newGroupText.getText().toString().trim(); + if (newGroupName.isEmpty()) { + newGroupLayout.setError(getString(R.string.error_required_field)); + return; + } + + VaultGroup group = new VaultGroup(newGroupName); + _vaultManager.getVault().addGroup(group); + groupModel = new VaultGroupModel(group); + } + + for (VaultEntry selectedEntry : _selectedEntries) { + selectedEntry.addGroup(groupModel.getUUID()); + } + + dialog.dismiss(); + saveAndBackupVault(); + _actionMode.finish(); + setGroups(_vaultManager.getVault().getUsedGroups()); + }); + }); + + Dialogs.showSecureDialog(dialog); + } + + private void startIntroActivity() { + if (!_isDoingIntro) { + Intent intro = new Intent(this, IntroActivity.class); + introResultLauncher.launch(intro); + _isDoingIntro = true; + } + } + + private void onScanResult(Intent data) { + List entries = (ArrayList) data.getSerializableExtra("entries"); + if (entries != null) { + importScannedEntries(entries); } } private void onAddEntryResult(Intent data) { if (_loaded) { UUID entryUUID = (UUID) data.getSerializableExtra("entryUUID"); - VaultEntry entry = _vault.getEntryByUUID(entryUUID); - _entryListView.addEntry(entry, true); + VaultEntry entry = _vaultManager.getVault().getEntryByUUID(entryUUID); + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + _entryListView.onEntryAdded(entry); } } - private void onEditEntryResult(Intent data) { + private void onEditEntryResult() { if (_loaded) { - UUID entryUUID = (UUID) data.getSerializableExtra("entryUUID"); + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + } + } - if (data.getBooleanExtra("delete", false)) { - _entryListView.removeEntry(entryUUID); - } else { - VaultEntry entry = _vault.getEntryByUUID(entryUUID); - _entryListView.replaceEntry(entryUUID, entry); - } + private void onAssignIconsResult() { + if (_loaded) { + _entryListView.setEntries(_vaultManager.getVault().getEntries()); } } private void onScanImageResult(Intent intent) { - decodeQrCodeImage(intent.getData()); + if (intent.getData() != null) { + startDecodeQrCodeImages(Collections.singletonList(intent.getData())); + return; + } + + if (intent.getClipData() != null) { + ClipData data = intent.getClipData(); + + List uris = new ArrayList<>(); + for (int i = 0; i < data.getItemCount(); i++) { + ClipData.Item item = data.getItemAt(i); + if (item.getUri() != null) { + uris.add(item.getUri()); + } + } + + if (uris.size() > 0) { + startDecodeQrCodeImages(uris); + } + } } - private void decodeQrCodeImage(Uri inputFile) { - Bitmap bitmap; + private static CharSequence buildImportError(String fileName, Throwable e) { + SpannableStringBuilder builder = new SpannableStringBuilder(String.format("%s:\n%s", fileName, e)); + builder.setSpan(new StyleSpan(Typeface.BOLD), 0, fileName.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return builder; + } - try { - BitmapFactory.Options bmOptions = new BitmapFactory.Options(); + private void startDecodeQrCodeImages(List uris) { + QrDecodeTask task = new QrDecodeTask(this, (results) -> { + List errors = new ArrayList<>(); + List entries = new ArrayList<>(); + List googleAuthExports = new ArrayList<>(); - try (InputStream inputStream = getContentResolver().openInputStream(inputFile)) { - bitmap = BitmapFactory.decodeStream(inputStream, null, bmOptions); - bitmap = BitmapHelper.resize(bitmap, QrCodeAnalyzer.RESOLUTION.getWidth(), QrCodeAnalyzer.RESOLUTION.getHeight()); + for (QrDecodeTask.Result res : results) { + if (res.getException() != null) { + errors.add(buildImportError(res.getFileName(), res.getException())); + continue; + } + + try { + Uri scanned = Uri.parse(res.getResult().getText()); + if (Objects.equals(scanned.getScheme(), GoogleAuthInfo.SCHEME_EXPORT)) { + GoogleAuthInfo.Export export = GoogleAuthInfo.parseExportUri(scanned); + for (GoogleAuthInfo info: export.getEntries()) { + VaultEntry entry = new VaultEntry(info); + entries.add(entry); + } + googleAuthExports.add(export); + } else { + GoogleAuthInfo info = GoogleAuthInfo.parseUri(res.getResult().getText()); + VaultEntry entry = new VaultEntry(info); + entries.add(entry); + } + } catch (GoogleAuthInfoException e) { + errors.add(buildImportError(res.getFileName(), e)); + } } - int[] intArray = new int[bitmap.getWidth() * bitmap.getHeight()]; - bitmap.getPixels(intArray, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight()); + final DialogInterface.OnClickListener dialogDismissHandler = (dialog, which) -> importScannedEntries(entries); + if (!googleAuthExports.isEmpty()) { + boolean isSingleBatch = GoogleAuthInfo.Export.isSingleBatch(googleAuthExports); + if (!isSingleBatch && errors.size() > 0) { + errors.add(getString(R.string.unrelated_google_auth_batches_error)); + Dialogs.showMultiErrorDialog(this, R.string.import_error_title, getString(R.string.no_tokens_can_be_imported), errors, null); + return; + } else if (!isSingleBatch) { + Dialogs.showErrorDialog(this, R.string.import_google_auth_failure, getString(R.string.unrelated_google_auth_batches_error)); + return; + } else { + List missingIndices = GoogleAuthInfo.Export.getMissingIndices(googleAuthExports); + if (missingIndices.size() != 0) { + Dialogs.showPartialGoogleAuthImportWarningDialog(this, missingIndices, entries.size(), errors, dialogDismissHandler); + return; + } + } + } - LuminanceSource source = new RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), intArray); - BinaryBitmap binaryBitmap = new BinaryBitmap(new HybridBinarizer(source)); + if ((errors.size() > 0 && results.size() > 1) || errors.size() > 1) { + Dialogs.showMultiErrorDialog(this, R.string.import_error_title, getString(R.string.unable_to_read_qrcode_files, uris.size() - errors.size(), uris.size()), errors, dialogDismissHandler); + } else if (errors.size() > 0) { + Dialogs.showErrorDialog(this, getString(R.string.unable_to_read_qrcode_file, results.get(0).getFileName()), errors.get(0), dialogDismissHandler); + } else { + importScannedEntries(entries); + } + }); + task.execute(getLifecycle(), uris); + } - Reader reader = new QRCodeReader(); - Result result = reader.decode(binaryBitmap); + private void importScannedEntries(List entries) { + if (entries.size() == 1) { + startEditEntryActivityForNew(entries.get(0)); + } else if (entries.size() > 1) { + for (VaultEntry entry: entries) { + _vaultManager.getVault().addEntry(entry); + } - GoogleAuthInfo info = GoogleAuthInfo.parseUri(result.getText()); - VaultEntry entry = new VaultEntry(info); + if (saveAndBackupVault()) { + Toast.makeText(this, getResources().getQuantityString(R.plurals.added_new_entries, entries.size(), entries.size()), Toast.LENGTH_LONG).show(); + } - startEditEntryActivityForNew(CODE_ADD_ENTRY, entry); - } catch (NotFoundException | IOException | ChecksumException | FormatException | GoogleAuthInfoException e) { - e.printStackTrace(); - Dialogs.showErrorDialog(this, R.string.unable_to_read_qrcode, e); + _entryListView.setEntries(_vaultManager.getVault().getEntries()); } } private void updateSortCategoryMenu() { - SortCategory category = getPreferences().getCurrentSortCategory(); - _menu.findItem(category.getMenuItem()).setChecked(true); + if (_menu != null) { + SortCategory category = _prefs.getCurrentSortCategory(); + _menu.findItem(category.getMenuItem()).setChecked(true); + } } - private void onDoIntroResult() { - _vault = _app.getVaultManager(); + private void onIntroResult() { loadEntries(); - checkTimeSyncSetting(); } private void checkTimeSyncSetting() { boolean autoTime = Settings.Global.getInt(getContentResolver(), Settings.Global.AUTO_TIME, 1) == 1; - if (!autoTime && getPreferences().isTimeSyncWarningEnabled()) { + if (!autoTime && _prefs.isTimeSyncWarningEnabled()) { Dialogs.showTimeSyncWarningDialog(this, (dialog, which) -> { Intent intent = new Intent(Settings.ACTION_DATE_SETTINGS); startActivity(intent); @@ -372,10 +763,41 @@ private void checkTimeSyncSetting() { } } + private void checkIconOptimization() { + if (!_vaultManager.getVault().areIconsOptimized()) { + Map oldIcons = _vaultManager.getVault().getEntries().stream() + .filter(e -> e.getIcon() != null + && !e.getIcon().getType().equals(IconType.SVG) + && !BitmapHelper.isVaultEntryIconOptimized(e.getIcon())) + .collect(Collectors.toMap(VaultEntry::getUUID, VaultEntry::getIcon)); + + if (!oldIcons.isEmpty()) { + IconOptimizationTask task = new IconOptimizationTask(this, this::onIconsOptimized); + task.execute(getLifecycle(), oldIcons); + } else { + onIconsOptimized(Collections.emptyMap()); + } + } + } + + private void onIconsOptimized(Map newIcons) { + for (Map.Entry mapEntry : newIcons.entrySet()) { + VaultEntry entry = _vaultManager.getVault().getEntryByUUID(mapEntry.getKey()); + entry.setIcon(mapEntry.getValue()); + } + + _vaultManager.getVault().setIconsOptimized(true); + saveAndBackupVault(); + + if (!newIcons.isEmpty()) { + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + } + } + private void onDecryptResult() { - _vault = _app.getVaultManager(); + _auditLogRepository.addVaultUnlockedEvent(); + loadEntries(); - checkTimeSyncSetting(); } private void startScanActivity() { @@ -384,19 +806,21 @@ private void startScanActivity() { } Intent scannerActivity = new Intent(getApplicationContext(), ScannerActivity.class); - startActivityForResult(scannerActivity, CODE_SCAN); + scanResultLauncher.launch(scannerActivity); } private void startScanImageActivity() { Intent galleryIntent = new Intent(Intent.ACTION_PICK); + galleryIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); galleryIntent.setDataAndType(android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI, "image/*"); Intent fileIntent = new Intent(Intent.ACTION_GET_CONTENT); + fileIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); fileIntent.setType("image/*"); Intent chooserIntent = Intent.createChooser(galleryIntent, getString(R.string.select_picture)); chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[] { fileIntent }); - AegisActivity.Helper.startExtActivityForResult(this, chooserIntent, CODE_SCAN_IMAGE); + _vaultManager.fireIntentLauncher(this, chooserIntent, codeScanResultLauncher); } private void startPreferencesActivity() { @@ -407,13 +831,13 @@ private void startPreferencesActivity(Class fragm Intent intent = new Intent(this, PreferencesActivity.class); intent.putExtra("fragment", fragmentType); intent.putExtra("pref", preference); - startActivityForResult(intent, CODE_PREFERENCES); + preferenceResultLauncher.launch(intent); } private void doShortcutActions() { Intent intent = getIntent(); String action = intent.getStringExtra("action"); - if (action == null || _app.isVaultLocked()) { + if (action == null || !_vaultManager.isVaultLoaded()) { return; } @@ -426,238 +850,295 @@ private void doShortcutActions() { intent.removeExtra("action"); } - private void handleDeeplink() { - if (_app.isVaultLocked()) { + private void handleIncomingIntent() { + if (!_vaultManager.isVaultLoaded()) { return; } Intent intent = getIntent(); - Uri uri = intent.getData(); - if (Intent.ACTION_VIEW.equals(intent.getAction()) && uri != null) { - intent.setData(null); - intent.setAction(null); - - GoogleAuthInfo info = null; - try { - info = GoogleAuthInfo.parseUri(uri); - } catch (GoogleAuthInfoException e) { - e.printStackTrace(); - Dialogs.showErrorDialog(this, R.string.unable_to_read_qrcode, e); - } - - if (info != null) { - VaultEntry entry = new VaultEntry(info); - startEditEntryActivityForNew(CODE_ADD_ENTRY, entry); - } - } - } - - private void handleSharedImage() { - if (_app.isVaultLocked()) { + if (intent.getAction() == null) { return; } - Intent intent = getIntent(); - String action = intent.getAction(); - Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + Uri uri; + switch (intent.getAction()) { + case Intent.ACTION_VIEW: + uri = intent.getData(); + if (uri != null) { + intent.setData(null); + intent.setAction(null); + + GoogleAuthInfo info; + try { + info = GoogleAuthInfo.parseUri(uri); + } catch (GoogleAuthInfoException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.unable_to_process_deeplink, e); + break; + } - if (Intent.ACTION_SEND.equals(action) && uri != null) { - intent.setAction(null); - intent.removeExtra(Intent.EXTRA_STREAM); + VaultEntry entry = new VaultEntry(info); + startEditEntryActivityForNew(entry); + } + break; + case Intent.ACTION_SEND: + if (intent.hasExtra(Intent.EXTRA_STREAM)) { + uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + intent.setAction(null); + intent.removeExtra(Intent.EXTRA_STREAM); + + if (uri != null) { + startDecodeQrCodeImages(Collections.singletonList(uri)); + } + } + if (intent.hasExtra(Intent.EXTRA_TEXT)) { + String stringExtra = intent.getStringExtra(Intent.EXTRA_TEXT); + intent.setAction(null); + intent.removeExtra(Intent.EXTRA_TEXT); + + if (stringExtra != null) { + GoogleAuthInfo info; + try { + info = GoogleAuthInfo.parseUri(stringExtra); + } catch (GoogleAuthInfoException e) { + Dialogs.showErrorDialog(this, R.string.unable_to_process_shared_text, e); + break; + } - decodeQrCodeImage(uri); + VaultEntry entry = new VaultEntry(info); + startEditEntryActivityForNew(entry); + } + } + break; + case Intent.ACTION_SEND_MULTIPLE: + if (intent.hasExtra(Intent.EXTRA_STREAM)) { + List uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + intent.setAction(null); + intent.removeExtra(Intent.EXTRA_STREAM); + + if (uris != null) { + uris = uris.stream() + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + startDecodeQrCodeImages(uris); + } + } + break; } } @Override - protected void onResume() { - super.onResume(); - - if (_vault == null) { - // start the intro if the vault file doesn't exist - if (!_isDoingIntro && !VaultManager.fileExists(this)) { - if (getPreferences().isIntroDone()) { - Toast.makeText(this, getString(R.string.vault_not_found), Toast.LENGTH_SHORT).show(); - } - Intent intro = new Intent(this, IntroActivity.class); - startActivityForResult(intro, CODE_DO_INTRO); - _isDoingIntro = true; - return; + protected void onStop() { + super.onStop(); + + _entryListView.onRefreshStop(); + } + + @Override + protected void onStart() { + super.onStart(); + + if (_vaultManager.isVaultInitNeeded()) { + if (_prefs.isIntroDone()) { + Toast.makeText(this, getString(R.string.vault_not_found), Toast.LENGTH_SHORT).show(); } + startIntroActivity(); + return; + } - // read the vault from disk - // if this fails, show the error to the user and close the app + // If the vault is not loaded yet, try to load it now in case it's plain text + if (!_vaultManager.isVaultLoaded()) { + VaultFile vaultFile; try { - VaultFile vaultFile = _app.loadVaultFile(); - if (!vaultFile.isEncrypted()) { - _vault = _app.initVaultManager(vaultFile, null); - } - } catch (VaultManagerException e) { + vaultFile = VaultRepository.readVaultFile(this); + } catch (VaultRepositoryException e) { e.printStackTrace(); - Dialogs.showErrorDialog(this, R.string.vault_load_error, e, (dialog1, which) -> finish()); + Dialogs.showErrorDialog(this, R.string.vault_load_error, e, (dialog, which) -> { + finish(); + }); return; } + + if (!vaultFile.isEncrypted()) { + try { + _vaultManager.loadFrom(vaultFile); + } catch (VaultRepositoryException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.vault_load_error, e, (dialog, which) -> { + finish(); + }); + return; + } + } } - if (_app.isVaultLocked()) { + if (!_vaultManager.isVaultLoaded()) { startAuthActivity(false); } else if (_loaded) { // update the list of groups in the entry list view so that the chip gets updated - _entryListView.setGroups(_vault.getGroups()); + setGroups(_vaultManager.getVault().getUsedGroups()); + + // update the usage counts in case they are edited outside of the EntryListView + _entryListView.setUsageCounts(_prefs.getUsageCounts()); - // update the usage counts in case they are edited outside of the entrylistview - _entryListView.setUsageCounts(getPreferences().getUsageCounts()); + _entryListView.setLastUsedTimestamps(_prefs.getLastUsedTimestamps()); // refresh all codes to prevent showing old ones _entryListView.refresh(false); + + _entryListView.onRefreshStart(); } else { loadEntries(); checkTimeSyncSetting(); - } - - handleDeeplink(); - handleSharedImage(); - updateLockIcon(); - doShortcutActions(); - updateBackupErrorBar(); - } - - @Override - public void onBackPressed() { - if (!_searchView.isIconified() || _searchSubmitted) { - _searchSubmitted = false; - _entryListView.setSearchFilter(null); - - collapseSearchView(); - setTitle(R.string.app_name); - return; - } + checkIconOptimization(); - if (_app.isAutoLockEnabled(Preferences.AUTO_LOCK_ON_BACK_BUTTON)) { - _app.lock(false); - return; + _entryListView.onRefreshStart(); } - super.onBackPressed(); - } - - private void deleteEntries(List entries) { - for (VaultEntry entry: entries) { - VaultEntry oldEntry = _vault.removeEntry(entry); - _entryListView.removeEntry(oldEntry); - } + _lockBackPressHandler.setEnabled( + _vaultManager.isAutoLockEnabled(Preferences.AUTO_LOCK_ON_BACK_BUTTON) + ); - saveVault(true); + handleIncomingIntent(); + updateLockIcon(); + updateSortCategoryMenu(); + doShortcutActions(); + updateErrorCard(); } @Override public boolean onCreateOptionsMenu(Menu menu) { _menu = menu; getMenuInflater().inflate(R.menu.menu_main, menu); + updateLockIcon(); - if (_loaded) { - _entryListView.setGroups(_vault.getGroups()); - updateSortCategoryMenu(); - } + updateSortCategoryMenu(); MenuItem searchViewMenuItem = menu.findItem(R.id.mi_search); - _searchView = (SearchView) searchViewMenuItem.getActionView(); + _searchView.setMaxWidth(Integer.MAX_VALUE); + _searchView.setOnQueryTextFocusChangeListener((v, hasFocus) -> { + boolean enabled = _submittedSearchQuery != null || hasFocus; + _searchViewBackPressHandler.setEnabled(enabled); + }); + _searchView.setOnCloseListener(() -> { + boolean enabled = _submittedSearchQuery != null; + _searchViewBackPressHandler.setEnabled(enabled); + _groupChip.setVisibility(_groups.isEmpty() ? View.GONE : View.VISIBLE); + return false; + }); _searchView.setQueryHint(getString(R.string.search)); - if (getPreferences().getFocusSearchEnabled() && !_isRecreated) { - _searchView.setIconified(false); - _searchView.setFocusable(true); - _searchView.requestFocus(); - _searchView.requestFocusFromTouch(); - } _searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { @Override public boolean onQueryTextSubmit(String s) { setTitle(getString(R.string.search)); getSupportActionBar().setSubtitle(s); - _searchSubmitted = true; + _entryListView.setSearchFilter(s); + _pendingSearchQuery = null; + _submittedSearchQuery = s; collapseSearchView(); + _searchViewBackPressHandler.setEnabled(true); return false; } @Override public boolean onQueryTextChange(String s) { - if (!_searchSubmitted) { + if (_submittedSearchQuery == null) { _entryListView.setSearchFilter(s); } + + _pendingSearchQuery = Strings.isNullOrEmpty(s) && !_searchView.isIconified() ? null : s; + if (_pendingSearchQuery != null) { + _entryListView.setSearchFilter(_pendingSearchQuery); + } + return false; } }); _searchView.setOnSearchClickListener(v -> { - if (_searchSubmitted) { - _searchSubmitted = false; - _entryListView.setSearchFilter(null); - } + String query = _submittedSearchQuery != null ? _submittedSearchQuery : _pendingSearchQuery; + _groupChip.setVisibility(View.GONE); + _searchView.setQuery(query, false); }); + if (_pendingSearchQuery != null) { + _searchView.setIconified(false); + _searchView.setQuery(_pendingSearchQuery, false); + _searchViewBackPressHandler.setEnabled(true); + } else if (_submittedSearchQuery != null) { + setTitle(getString(R.string.search)); + getSupportActionBar().setSubtitle(_submittedSearchQuery); + _entryListView.setSearchFilter(_submittedSearchQuery); + _searchViewBackPressHandler.setEnabled(true); + } else if (_prefs.getFocusSearchEnabled() && !_isRecreated) { + _searchView.setIconified(false); + _searchView.setFocusable(true); + _searchView.requestFocus(); + _searchView.requestFocusFromTouch(); + } + return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.action_settings: { - startPreferencesActivity(); - return true; - } - case R.id.action_about: { - Intent intent = new Intent(this, AboutActivity.class); - startActivity(intent); - return true; + int itemId = item.getItemId(); + if (itemId == R.id.action_settings) { + startPreferencesActivity(); + } else if (itemId == R.id.action_about) { + Intent intent = new Intent(this, AboutActivity.class); + startActivity(intent); + } else if (itemId == R.id.action_lock) { + _vaultManager.lock(true); + } else { + if (item.getGroupId() == R.id.action_sort_category) { + item.setChecked(true); + + SortCategory sortCategory; + int subItemId = item.getItemId(); + if (subItemId == R.id.menu_sort_alphabetically) { + sortCategory = SortCategory.ISSUER; + } else if (subItemId == R.id.menu_sort_alphabetically_reverse) { + sortCategory = SortCategory.ISSUER_REVERSED; + } else if (subItemId == R.id.menu_sort_alphabetically_name) { + sortCategory = SortCategory.ACCOUNT; + } else if (subItemId == R.id.menu_sort_alphabetically_name_reverse) { + sortCategory = SortCategory.ACCOUNT_REVERSED; + } else if (subItemId == R.id.menu_sort_usage_count) { + sortCategory = SortCategory.USAGE_COUNT; + } else if (subItemId == R.id.menu_sort_last_used) { + sortCategory = SortCategory.LAST_USED; + } else { + sortCategory = SortCategory.CUSTOM; + } + + _entryListView.setSortCategory(sortCategory, true); + _prefs.setCurrentSortCategory(sortCategory); } - case R.id.action_lock: - _app.lock(true); - return true; - default: - if (item.getGroupId() == R.id.action_sort_category) { - item.setChecked(true); - - SortCategory sortCategory; - switch (item.getItemId()) { - case R.id.menu_sort_alphabetically: - sortCategory = SortCategory.ISSUER; - break; - case R.id.menu_sort_alphabetically_reverse: - sortCategory = SortCategory.ISSUER_REVERSED; - break; - case R.id.menu_sort_alphabetically_name: - sortCategory = SortCategory.ACCOUNT; - break; - case R.id.menu_sort_alphabetically_name_reverse: - sortCategory = SortCategory.ACCOUNT_REVERSED; - break; - case R.id.menu_sort_usage_count: - sortCategory = SortCategory.USAGE_COUNT; - break; - case R.id.menu_sort_custom: - default: - sortCategory = SortCategory.CUSTOM; - break; - } - _entryListView.setSortCategory(sortCategory, true); - getPreferences().setCurrentSortCategory(sortCategory); - } - return super.onOptionsItemSelected(item); + return super.onOptionsItemSelected(item); } + + return true; } private void collapseSearchView() { + _groupChip.setVisibility(_groups.isEmpty() ? View.GONE : View.VISIBLE); _searchView.setQuery(null, false); _searchView.setIconified(true); } private void loadEntries() { if (!_loaded) { - _entryListView.setUsageCounts(getPreferences().getUsageCounts()); - _entryListView.addEntries(_vault.getEntries()); - _entryListView.runEntriesAnimation(); + setGroups(_vaultManager.getVault().getUsedGroups()); + _entryListView.setUsageCounts(_prefs.getUsageCounts()); + _entryListView.setLastUsedTimestamps(_prefs.getLastUsedTimestamps()); + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + if (!_isRecreated) { + _entryListView.runEntriesAnimation(); + } _loaded = true; } } @@ -666,26 +1147,96 @@ private void startAuthActivity(boolean inhibitBioPrompt) { if (!_isAuthenticating) { Intent intent = new Intent(this, AuthActivity.class); intent.putExtra("inhibitBioPrompt", inhibitBioPrompt); - startActivityForResult(intent, CODE_DECRYPT); + authResultLauncher.launch(intent); _isAuthenticating = true; } } private void updateLockIcon() { // hide the lock icon if the vault is not unlocked - if (_menu != null && !_app.isVaultLocked()) { + if (_menu != null && _vaultManager.isVaultLoaded()) { MenuItem item = _menu.findItem(R.id.action_lock); - item.setVisible(_vault.isEncryptionEnabled()); + item.setVisible(_vaultManager.getVault().isEncryptionEnabled()); } } - private void updateBackupErrorBar() { - String error = null; - if (_app.getPreferences().isBackupsEnabled()) { - error = _app.getPreferences().getBackupsError(); + private void updateErrorCard() { + ErrorCardInfo info = null; + + Preferences.BackupResult backupRes = _prefs.getErroredBackupResult(); + if (backupRes != null) { + info = new ErrorCardInfo(getString(R.string.backup_error_bar_message), view -> { + Dialogs.showBackupErrorDialog(this, backupRes, (dialog, which) -> { + startPreferencesActivity(BackupsPreferencesFragment.class, "pref_backups"); + }); + }); + } else if (_prefs.isBackupsReminderNeeded() && _prefs.isBackupReminderEnabled()) { + String text; + Date date = _prefs.getLatestBackupOrExportTime(); + if (date != null) { + text = getString(R.string.backup_reminder_bar_message_with_latest, TimeUtils.getElapsedSince(this, date)); + } else { + text = getString(R.string.backup_reminder_bar_message); + } + info = new ErrorCardInfo(text, view -> { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Aegis_AlertDialog_Error) + .setTitle(R.string.backup_reminder_bar_dialog_title) + .setMessage(R.string.backup_reminder_bar_dialog_summary) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(R.string.backup_reminder_bar_dialog_accept, (dialog, whichButton) -> { + startPreferencesActivity(BackupsPreferencesFragment.class, "pref_backups"); + }) + .setNegativeButton(android.R.string.cancel, null) + .create()); + }); + } else if (_prefs.isPlaintextBackupWarningNeeded()) { + info = new ErrorCardInfo(getString(R.string.backup_plaintext_export_warning), view -> showPlaintextExportWarningOptions()); + } + + _entryListView.setErrorCardInfo(info); + } + + private void showPlaintextExportWarningOptions() { + View view = LayoutInflater.from(this).inflate(R.layout.dialog_plaintext_warning, null); + + AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(R.string.backup_plaintext_export_warning) + .setView(view) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(android.R.string.cancel, null) + .create(); + + CheckBox checkBox = view.findViewById(R.id.checkbox_plaintext_warning); + checkBox.setChecked(false); + + dialog.setOnShowListener(d -> { + Button btnPos = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + + btnPos.setOnClickListener(l -> { + dialog.dismiss(); + + _prefs.setIsPlaintextBackupWarningDisabled(checkBox.isChecked()); + _prefs.setIsPlaintextBackupWarningNeeded(false); + + updateErrorCard(); + }); + }); + + Dialogs.showSecureDialog(dialog); + } + + @Override + public void onRestoreInstanceState(@Nullable Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + if (savedInstanceState == null) { + return; } - _btnBackupError.setVisibility(error == null ? View.GONE : View.VISIBLE); + HashSet filter = (HashSet) savedInstanceState.getSerializable("prefGroupFilter"); + if (filter != null) { + _prefGroupFilter = filter; + } } @Override @@ -694,10 +1245,9 @@ public void onEntryClick(VaultEntry entry) { if (_selectedEntries.isEmpty()) { _actionMode.finish(); } else { + setFavoriteMenuItemVisiblity(); setIsMultipleSelected(_selectedEntries.size() > 1); } - - return; } } @@ -717,6 +1267,28 @@ private void setIsMultipleSelected(boolean multipleSelected) { _actionMode.getMenu().findItem(R.id.action_copy).setVisible(!multipleSelected); } + private void setAssignIconsMenuItemVisibility() { + MenuItem assignIconsMenuItem = _actionMode.getMenu().findItem(R.id.action_assign_icons); + assignIconsMenuItem.setVisible(_iconPackManager.hasIconPack()); + } + + private void setFavoriteMenuItemVisiblity() { + MenuItem toggleFavoriteMenuItem = _actionMode.getMenu().findItem(R.id.action_toggle_favorite); + + if (_selectedEntries.size() == 1){ + if (_selectedEntries.get(0).isFavorite()) { + toggleFavoriteMenuItem.setIcon(R.drawable.ic_filled_star_24); + toggleFavoriteMenuItem.setTitle(R.string.unfavorite); + } else { + toggleFavoriteMenuItem.setIcon(R.drawable.ic_outline_star_24); + toggleFavoriteMenuItem.setTitle(R.string.favorite); + } + } else { + toggleFavoriteMenuItem.setIcon(R.drawable.ic_outline_star_24); + toggleFavoriteMenuItem.setTitle(String.format("%s / %s", getString(R.string.favorite), getString(R.string.unfavorite))); + } + } + @Override public void onLongEntryClick(VaultEntry entry) { if (!_selectedEntries.isEmpty()) { @@ -725,22 +1297,29 @@ public void onLongEntryClick(VaultEntry entry) { _selectedEntries.add(entry); _entryListView.setActionModeState(true, entry); - _actionMode = this.startSupportActionMode(_actionModeCallbacks); + startActionMode(); + } + + private void startActionMode() { + _actionMode = startSupportActionMode(_actionModeCallbacks); + _actionModeBackPressHandler.setEnabled(true); + setFavoriteMenuItemVisiblity(); + setAssignIconsMenuItemVisibility(); } @Override public void onEntryMove(VaultEntry entry1, VaultEntry entry2) { - _vault.swapEntries(entry1, entry2); + _vaultManager.getVault().moveEntry(entry1, entry2); } @Override public void onEntryDrop(VaultEntry entry) { - saveVault(false); + saveVault(); } @Override public void onEntryChange(VaultEntry entry) { - saveVault(true); + saveAndBackupVault(); } public void onEntryCopy(VaultEntry entry) { @@ -749,15 +1328,20 @@ public void onEntryCopy(VaultEntry entry) { @Override public void onScroll(int dx, int dy) { - _fabScrollHelper.onScroll(dx, dy); + if (!_isDPadPressed) { + _fabScrollHelper.onScroll(dx, dy); + } } @Override public void onListChange() { _fabScrollHelper.setVisible(true); } @Override - public void onSaveGroupFilter(List groupFilter) { - getPreferences().setGroupFilter(groupFilter); + public void onSaveGroupFilter(Set groupFilter) { + if (_vaultManager.getVault().isGroupsMigrationFresh()) { + saveAndBackupVault(); + } + _prefs.setGroupFilter(groupFilter); } @Override @@ -768,11 +1352,13 @@ public void onLocked(boolean userInitiated) { if (_searchView != null && !_searchView.isIconified()) { collapseSearchView(); } + if (_fabMenuHelper != null && _fabMenuHelper.isOpen()) { + _fabMenuHelper.close(); + } _entryListView.clearEntries(); _loaded = false; - if (userInitiated) { startAuthActivity(true); } else { @@ -780,78 +1366,176 @@ public void onLocked(boolean userInitiated) { } } + @Override + protected boolean saveAndBackupVault() { + boolean res = super.saveAndBackupVault(); + updateErrorCard(); + return res; + } + + @SuppressLint("InlinedApi") private void copyEntryCode(VaultEntry entry) { + String otp; + try { + otp = entry.getInfo().getOtp(); + } catch (OtpInfoException e) { + return; + } + ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText("text/plain", entry.getInfo().getOtp()); + ClipData clip = ClipData.newPlainText("text/plain", otp); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PersistableBundle extras = new PersistableBundle(); + extras.putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true); + clip.getDescription().setExtras(extras); + } clipboard.setPrimaryClip(clip); + if (_prefs.isMinimizeOnCopyEnabled()) { + moveTaskToBack(true); + } } - private class ActionModeCallbacks implements ActionMode.Callback { - @Override - public boolean onCreateActionMode(ActionMode mode, Menu menu) { - MenuInflater inflater = mode.getMenuInflater(); - inflater.inflate(R.menu.menu_action_mode, menu); - return true; + private class SearchViewBackPressHandler extends OnBackPressedCallback { + public SearchViewBackPressHandler() { + super(false); + } + + @Override + public void handleOnBackPressed() { + if (!_searchView.isIconified() || _submittedSearchQuery != null) { + _submittedSearchQuery = null; + _pendingSearchQuery = null; + _entryListView.setSearchFilter(null); + + collapseSearchView(); + setTitle(R.string.app_name); + getSupportActionBar().setSubtitle(null); } + } + } - @Override - public boolean onPrepareActionMode(ActionMode mode, Menu menu) { - return false; + private class LockBackPressHandler extends OnBackPressedCallback { + public LockBackPressHandler() { + super(false); + } + + @Override + public void handleOnBackPressed() { + if (_vaultManager.isAutoLockEnabled(Preferences.AUTO_LOCK_ON_BACK_BUTTON)) { + _vaultManager.lock(false); } + } + } - @Override - public boolean onActionItemClicked(ActionMode mode, MenuItem item) { - switch (item.getItemId()) { - case R.id.action_copy: - copyEntryCode(_selectedEntries.get(0)); - mode.finish(); - return true; - - case R.id.action_edit: - startEditEntryActivity(CODE_EDIT_ENTRY, _selectedEntries.get(0)); - mode.finish(); - return true; - - case R.id.action_share_qr: - Intent intent = new Intent(getBaseContext(), TransferEntriesActivity.class); - ArrayList authInfos = new ArrayList<>(); - for (VaultEntry entry : _selectedEntries) { - GoogleAuthInfo authInfo = new GoogleAuthInfo(entry.getInfo(), entry.getName(), entry.getIssuer()); - authInfos.add(authInfo); - } + private class ActionModeBackPressHandler extends OnBackPressedCallback { + public ActionModeBackPressHandler() { + super(false); + } - intent.putExtra("authInfos", authInfos); - startActivity(intent); - - mode.finish(); - return true; - - case R.id.action_delete: - Dialogs.showDeleteEntriesDialog(MainActivity.this, _selectedEntries, (d, which) -> { - deleteEntries(_selectedEntries); - - for (VaultEntry entry : _selectedEntries) { - if (entry.getGroup() != null) { - if (!_vault.getGroups().contains(entry.getGroup())) { - _entryListView.setGroups(_vault.getGroups()); - break; - } - } - } - - mode.finish(); - }); - return true; - default: - return false; - } + @Override + public void handleOnBackPressed() { + if (_actionMode != null) { + _actionMode.finish(); } + } + } - @Override - public void onDestroyActionMode(ActionMode mode) { - _entryListView.setActionModeState(false, null); - _selectedEntries.clear(); - _actionMode = null; + private class FabMenuBackPressHandler extends OnBackPressedCallback { + public FabMenuBackPressHandler() { + super(false); + } + + @Override + public void handleOnBackPressed() { + if (_fabMenuHelper != null && _fabMenuHelper.isOpen()) { + _fabMenuHelper.close(); } + } + } + + private class ActionModeCallbacks implements ActionMode.Callback { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_action_mode, menu); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + if (_selectedEntries.size() == 0) { + mode.finish(); + return true; + } + + int itemId = item.getItemId(); + if (itemId == R.id.action_copy) { + copyEntryCode(_selectedEntries.get(0)); + mode.finish(); + } else if (itemId == R.id.action_edit) { + startEditEntryActivity(_selectedEntries.get(0)); + mode.finish(); + } else if (itemId == R.id.action_toggle_favorite) { + for (VaultEntry entry : _selectedEntries) { + _vaultManager.getVault().editEntry(entry, newEntry -> { + newEntry.setIsFavorite(!newEntry.isFavorite()); + }); + } + + saveAndBackupVault(); + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + mode.finish(); + } else if (itemId == R.id.action_share_qr) { + Intent intent = new Intent(getBaseContext(), TransferEntriesActivity.class); + ArrayList authInfos = new ArrayList<>(); + for (VaultEntry entry : _selectedEntries) { + GoogleAuthInfo authInfo = new GoogleAuthInfo(entry.getInfo(), entry.getName(), entry.getIssuer()); + authInfos.add(authInfo); + + _auditLogRepository.addEntrySharedEvent(entry.getUUID().toString()); + } + + intent.putExtra("authInfos", authInfos); + startActivity(intent); + + mode.finish(); + } else if (itemId == R.id.action_delete) { + Dialogs.showDeleteEntriesDialog(MainActivity.this, _selectedEntries, (d, which) -> { + for (VaultEntry entry : _selectedEntries) { + _vaultManager.getVault().removeEntry(entry); + } + saveAndBackupVault(); + _entryListView.setGroups(_vaultManager.getVault().getUsedGroups()); + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + mode.finish(); + }); + } else if (itemId == R.id.action_select_all) { + _selectedEntries = _entryListView.selectAllEntries(); + setFavoriteMenuItemVisiblity(); + setIsMultipleSelected(_selectedEntries.size() > 1); + } else if (itemId == R.id.action_assign_icons) { + startAssignIconsActivity(_selectedEntries); + mode.finish(); + } else if (itemId == R.id.action_assign_groups) { + startAssignGroupsDialog(); + } else { + return false; + } + + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + _entryListView.setActionModeState(false, null); + _actionModeBackPressHandler.setEnabled(false); + _selectedEntries.clear(); + _actionMode = null; + } } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/PanicResponderActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/PanicResponderActivity.java index abb7d879af..8453e385f4 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/PanicResponderActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/PanicResponderActivity.java @@ -4,10 +4,10 @@ import android.os.Bundle; import android.widget.Toast; -import com.beemdevelopment.aegis.Preferences; +import com.beemdevelopment.aegis.BuildConfig; import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.crypto.pins.GuardianProjectFDroidRSA2048; -import com.beemdevelopment.aegis.vault.VaultManager; +import com.beemdevelopment.aegis.vault.VaultRepository; import info.guardianproject.GuardianProjectRSA4096; import info.guardianproject.trustedintents.TrustedIntents; @@ -18,22 +18,29 @@ public class PanicResponderActivity extends AegisActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - Preferences prefs = getPreferences(); - if(!prefs.isPanicTriggerEnabled()) { + if (!_prefs.isPanicTriggerEnabled()) { Toast.makeText(this, R.string.panic_trigger_ignore_toast, Toast.LENGTH_SHORT).show(); finish(); + return; } - TrustedIntents trustedIntents = TrustedIntents.get(this); - trustedIntents.addTrustedSigner(GuardianProjectRSA4096.class); - trustedIntents.addTrustedSigner(GuardianProjectFDroidRSA2048.class); + Intent intent; + if (!BuildConfig.TEST.get()) { + TrustedIntents trustedIntents = TrustedIntents.get(this); + trustedIntents.addTrustedSigner(GuardianProjectRSA4096.class); + trustedIntents.addTrustedSigner(GuardianProjectFDroidRSA2048.class); + + intent = trustedIntents.getIntentFromTrustedSender(this); + } else { + intent = getIntent(); + } - Intent intent = trustedIntents.getIntentFromTrustedSender(this); if (intent != null && PANIC_TRIGGER_ACTION.equals(intent.getAction())) { - getApp().lock(false); - VaultManager.deleteFile(this); + VaultRepository.deleteFile(this); + _vaultManager.lock(false); finishApp(); + return; } finish(); @@ -41,7 +48,6 @@ protected void onCreate(Bundle savedInstanceState) { private void finishApp() { ExitActivity.exitAppAndRemoveFromRecents(this); - finishAndRemoveTask(); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/PreferencesActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/PreferencesActivity.java index 2300dcc534..1e7dff6740 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/PreferencesActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/PreferencesActivity.java @@ -3,23 +3,34 @@ import android.os.Bundle; import android.view.MenuItem; +import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import com.beemdevelopment.aegis.R; -import com.beemdevelopment.aegis.ui.fragments.MainPreferencesFragment; -import com.beemdevelopment.aegis.ui.fragments.PreferencesFragment; +import com.beemdevelopment.aegis.ui.fragments.preferences.AppearancePreferencesFragment; +import com.beemdevelopment.aegis.ui.fragments.preferences.MainPreferencesFragment; +import com.beemdevelopment.aegis.ui.fragments.preferences.PreferencesFragment; +import com.beemdevelopment.aegis.helpers.ViewHelper; public class PreferencesActivity extends AegisActivity implements PreferenceFragmentCompat.OnPreferenceStartFragmentCallback { private Fragment _fragment; + private CharSequence _prefTitle; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } setContentView(R.layout.activity_preferences); setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); + getSupportFragmentManager() + .registerFragmentLifecycleCallbacks(new FragmentResumeListener(), true); if (getSupportActionBar() != null) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); @@ -30,7 +41,7 @@ protected void onCreate(Bundle savedInstanceState) { _fragment = new MainPreferencesFragment(); _fragment.setArguments(getIntent().getExtras()); - getSupportFragmentManager().beginTransaction() + getSupportFragmentManager().beginTransaction() .replace(R.id.content, _fragment) .commit(); @@ -41,40 +52,23 @@ protected void onCreate(Bundle savedInstanceState) { } } else { _fragment = getSupportFragmentManager().findFragmentById(R.id.content); - } - } - - @Override - public void onBackPressed() { - super.onBackPressed(); - setTitle(R.string.action_settings); - } - - @Override - protected void onRestoreInstanceState(final Bundle inState) { - if (_fragment instanceof PreferencesFragment) { - // pass the stored result intent back to the fragment - if (inState.containsKey("result")) { - ((PreferencesFragment) _fragment).setResult(inState.getParcelable("result")); + _prefTitle = savedInstanceState.getCharSequence("prefTitle"); + if (_prefTitle != null) { + setTitle(_prefTitle); } } - super.onRestoreInstanceState(inState); } @Override - protected void onSaveInstanceState(final Bundle outState) { - if (_fragment instanceof PreferencesFragment) { - // save the result intent of the fragment - // this is done so we don't lose anything if the fragment calls recreate on this activity - outState.putParcelable("result", ((PreferencesFragment) _fragment).getResult()); - } + protected void onSaveInstanceState(@NonNull final Bundle outState) { + outState.putCharSequence("prefTitle", _prefTitle); super.onSaveInstanceState(outState); } @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == android.R.id.home) { - onBackPressed(); + getOnBackPressedDispatcher().onBackPressed(); } else { return super.onOptionsItemSelected(item); } @@ -83,13 +77,14 @@ public boolean onOptionsItemSelected(MenuItem item) { } @Override - public boolean onPreferenceStartFragment(PreferenceFragmentCompat caller, Preference pref) { + public boolean onPreferenceStartFragment(@NonNull PreferenceFragmentCompat caller, Preference pref) { _fragment = getSupportFragmentManager().getFragmentFactory().instantiate(getClassLoader(), pref.getFragment()); _fragment.setArguments(pref.getExtras()); _fragment.setTargetFragment(caller, 0); showFragment(_fragment); - setTitle(pref.getTitle()); + _prefTitle = pref.getTitle(); + setTitle(_prefTitle); return true; } @@ -114,4 +109,16 @@ private PreferencesFragment getRequestedFragment() { throw new RuntimeException(e); } } + + private class FragmentResumeListener extends FragmentManager.FragmentLifecycleCallbacks { + @Override + public void onFragmentStarted(@NonNull FragmentManager fm, @NonNull Fragment f) { + if (f instanceof MainPreferencesFragment) { + setTitle(R.string.action_settings); + } else if (f instanceof AppearancePreferencesFragment) { + _prefTitle = getString(R.string.pref_section_appearance_title); + setTitle(_prefTitle); + } + } + } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/ScannerActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/ScannerActivity.java index 3de6e87e31..cc0d6095c4 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/ScannerActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/ScannerActivity.java @@ -3,6 +3,7 @@ import android.content.Intent; import android.net.Uri; import android.os.Bundle; +import android.os.Handler; import android.view.Menu; import android.view.MenuItem; import android.widget.Toast; @@ -17,11 +18,11 @@ import androidx.core.content.ContextCompat; import com.beemdevelopment.aegis.R; -import com.beemdevelopment.aegis.ThemeMap; import com.beemdevelopment.aegis.helpers.QrCodeAnalyzer; import com.beemdevelopment.aegis.otp.GoogleAuthInfo; import com.beemdevelopment.aegis.otp.GoogleAuthInfoException; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.helpers.ViewHelper; import com.beemdevelopment.aegis.vault.VaultEntry; import com.google.common.util.concurrent.ListenableFuture; import com.google.zxing.Result; @@ -51,8 +52,12 @@ public class ScannerActivity extends AegisActivity implements QrCodeAnalyzer.Lis @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } setContentView(R.layout.activity_scanner); setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); _entries = new ArrayList<>(); _lenses = new ArrayList<>(); @@ -85,15 +90,12 @@ public void onCreate(Bundle savedInstanceState) { @Override protected void onDestroy() { - _executor.shutdownNow(); + if (_executor != null) { + _executor.shutdownNow(); + } super.onDestroy(); } - @Override - protected void onSetTheme() { - setTheme(ThemeMap.FULLSCREEN); - } - @Override public boolean onCreateOptionsMenu(Menu menu) { _menu = menu; @@ -103,6 +105,10 @@ public boolean onCreateOptionsMenu(Menu menu) { @Override public boolean onOptionsItemSelected(MenuItem item) { + if (_cameraProvider == null) { + return false; + } + if (item.getItemId() == R.id.action_camera) { unbindPreview(_cameraProvider); _currentLens = _currentLens == CameraSelector.LENS_FACING_BACK ? CameraSelector.LENS_FACING_FRONT : CameraSelector.LENS_FACING_BACK; @@ -132,10 +138,10 @@ private void updateCameraIcon() { if (dual) { switch (_currentLens) { case CameraSelector.LENS_FACING_BACK: - item.setIcon(R.drawable.ic_camera_front_24dp); + item.setIcon(R.drawable.ic_outline_camera_front_24); break; case CameraSelector.LENS_FACING_FRONT: - item.setIcon(R.drawable.ic_camera_rear_24dp); + item.setIcon(R.drawable.ic_outline_camera_rear_24); break; } } @@ -167,23 +173,30 @@ private void unbindPreview(@NonNull ProcessCameraProvider cameraProvider) { @Override public void onQrCodeDetected(Result result) { - if (_analysis != null) { - try { - Uri uri = Uri.parse(result.getText().trim()); - if (uri.getScheme() != null && uri.getScheme().equals(GoogleAuthInfo.SCHEME_EXPORT)) { - handleExportUri(uri); - } else { - handleUri(uri); - } - } catch (GoogleAuthInfoException e) { - e.printStackTrace(); + new Handler(getMainLooper()).post(() -> { + if (isFinishing()) { + return; + } - unbindPreview(_cameraProvider); - Dialogs.showErrorDialog(this, - e.isPhoneFactor() ? R.string.read_qr_error_phonefactor : R.string.read_qr_error, - e, ((dialog, which) -> bindPreview(_cameraProvider))); + if (_analysis != null) { + try { + Uri uri = Uri.parse(result.getText().trim()); + if (uri.getScheme() != null && uri.getScheme().equals(GoogleAuthInfo.SCHEME_EXPORT)) { + handleExportUri(uri); + } else { + handleUri(uri); + } + } catch (GoogleAuthInfoException e) { + e.printStackTrace(); + + unbindPreview(_cameraProvider); + + Dialogs.showErrorDialog(this, + e.isPhoneFactor() ? R.string.read_qr_error_phonefactor : R.string.read_qr_error, + e, ((dialog, which) -> bindPreview(_cameraProvider))); + } } - } + }); } private void handleUri(Uri uri) throws GoogleAuthInfoException { diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/SlotManagerActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/SlotManagerActivity.java deleted file mode 100644 index f44d55e012..0000000000 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/SlotManagerActivity.java +++ /dev/null @@ -1,233 +0,0 @@ -package com.beemdevelopment.aegis.ui; - -import android.content.Intent; -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; -import androidx.biometric.BiometricPrompt; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.beemdevelopment.aegis.R; -import com.beemdevelopment.aegis.crypto.KeyStoreHandle; -import com.beemdevelopment.aegis.crypto.KeyStoreHandleException; -import com.beemdevelopment.aegis.ui.dialogs.Dialogs; -import com.beemdevelopment.aegis.vault.VaultFileCredentials; -import com.beemdevelopment.aegis.vault.slots.BiometricSlot; -import com.beemdevelopment.aegis.vault.slots.PasswordSlot; -import com.beemdevelopment.aegis.vault.slots.Slot; -import com.beemdevelopment.aegis.vault.slots.SlotException; -import com.beemdevelopment.aegis.vault.slots.SlotList; -import com.beemdevelopment.aegis.helpers.BiometricSlotInitializer; -import com.beemdevelopment.aegis.helpers.BiometricsHelper; -import com.beemdevelopment.aegis.ui.views.SlotAdapter; - -import javax.crypto.Cipher; - -public class SlotManagerActivity extends AegisActivity implements SlotAdapter.Listener { - private VaultFileCredentials _creds; - private SlotAdapter _adapter; - - private boolean _edited; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_slots); - setSupportActionBar(findViewById(R.id.toolbar)); - _edited = false; - - ActionBar bar = getSupportActionBar(); - bar.setHomeAsUpIndicator(R.drawable.ic_close); - bar.setDisplayHomeAsUpEnabled(true); - - findViewById(R.id.button_add_biometric).setOnClickListener(view -> { - if (BiometricsHelper.isAvailable(this)) { - BiometricSlotInitializer initializer = new BiometricSlotInitializer(SlotManagerActivity.this, new RegisterBiometricsListener()); - BiometricPrompt.PromptInfo info = new BiometricPrompt.PromptInfo.Builder() - .setTitle(getString(R.string.add_biometric_slot)) - .setNegativeButtonText(getString(android.R.string.cancel)) - .build(); - initializer.authenticate(info); - } - }); - - findViewById(R.id.button_add_password).setOnClickListener(view -> { - Dialogs.showSetPasswordDialog(this, new PasswordListener()); - }); - - // set up the recycler view - _adapter = new SlotAdapter(this); - RecyclerView slotsView = findViewById(R.id.list_slots); - LinearLayoutManager layoutManager = new LinearLayoutManager(this); - slotsView.setLayoutManager(layoutManager); - slotsView.setAdapter(_adapter); - slotsView.setNestedScrollingEnabled(false); - - // load the slots and masterKey - _creds = (VaultFileCredentials) getIntent().getSerializableExtra("creds"); - for (Slot slot : _creds.getSlots()) { - _adapter.addSlot(slot); - } - - updateBiometricsButton(); - } - - private void updateBiometricsButton() { - // only show the biometrics option if we can get an instance of the biometrics manager - // and if none of the slots in the collection has a matching alias in the keystore - int visibility = View.VISIBLE; - if (BiometricsHelper.isAvailable(this)) { - try { - KeyStoreHandle keyStore = new KeyStoreHandle(); - for (BiometricSlot slot : _creds.getSlots().findAll(BiometricSlot.class)) { - if (keyStore.containsKey(slot.getUUID().toString())) { - visibility = View.GONE; - break; - } - } - } catch (KeyStoreHandleException e) { - visibility = View.GONE; - } - } else { - visibility = View.GONE; - } - findViewById(R.id.button_add_biometric).setVisibility(visibility); - } - - private void onSave() { - Intent intent = new Intent(); - intent.putExtra("creds", _creds); - setResult(RESULT_OK, intent); - finish(); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - onBackPressed(); - break; - case R.id.action_save: - onSave(); - break; - default: - return super.onOptionsItemSelected(item); - } - - return true; - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.menu_slots, menu); - return true; - } - - @Override - public void onBackPressed() { - if (!_edited) { - super.onBackPressed(); - return; - } - - Dialogs.showDiscardDialog(this, - (dialog, which) -> onSave(), - (dialog, which) -> super.onBackPressed() - ); - } - - @Override - public void onEditSlot(Slot slot) { - /*EditText textName = new EditText(this); - textName.setHint("Name"); - - new AlertDialog.Builder(this) - .setTitle("Edit slot name") - .setView(textName) - .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> { - String name = textName.getText().toString(); - _edited = true; - }) - .setNegativeButton(android.R.string.cancel, null) - .show();*/ - } - - @Override - public void onRemoveSlot(Slot slot) { - SlotList slots = _creds.getSlots(); - if (slot instanceof PasswordSlot && slots.findAll(PasswordSlot.class).size() <= 1) { - Toast.makeText(this, R.string.password_slot_error, Toast.LENGTH_SHORT).show(); - return; - } - - Dialogs.showSecureDialog(new AlertDialog.Builder(this) - .setTitle(R.string.remove_slot) - .setMessage(R.string.remove_slot_description) - .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> { - slots.remove(slot); - _adapter.removeSlot(slot); - _edited = true; - updateBiometricsButton(); - }) - .setNegativeButton(android.R.string.no, null) - .create()); - } - - private void addSlot(Slot slot) { - _creds.getSlots().add(slot); - _adapter.addSlot(slot); - _edited = true; - updateBiometricsButton(); - } - - private void showSlotError(String error) { - Toast.makeText(SlotManagerActivity.this, getString(R.string.adding_new_slot_error) + error, Toast.LENGTH_SHORT).show(); - } - - private class RegisterBiometricsListener implements BiometricSlotInitializer.Listener { - - @Override - public void onInitializeSlot(BiometricSlot slot, Cipher cipher) { - try { - slot.setKey(_creds.getKey(), cipher); - addSlot(slot); - } catch (SlotException e) { - onSlotInitializationFailed(0, e.toString()); - } - } - - @Override - public void onSlotInitializationFailed(int errorCode, @NonNull CharSequence errString) { - if (!BiometricsHelper.isCanceled(errorCode)) { - showSlotError(errString.toString()); - } - } - } - - private class PasswordListener implements Dialogs.SlotListener { - - @Override - public void onSlotResult(Slot slot, Cipher cipher) { - try { - slot.setKey(_creds.getKey(), cipher); - } catch (SlotException e) { - onException(e); - return; - } - - addSlot(slot); - } - - @Override - public void onException(Exception e) { - showSlotError(e.toString()); - } - } -} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/TransferEntriesActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/TransferEntriesActivity.java index cf11d1e716..a0559d7afb 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/TransferEntriesActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/TransferEntriesActivity.java @@ -1,64 +1,82 @@ package com.beemdevelopment.aegis.ui; +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ClipboardManager; +import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.Color; +import android.os.Build; import android.os.Bundle; -import android.util.TypedValue; +import android.os.PersistableBundle; +import android.provider.Settings; import android.view.MenuItem; import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.WindowManager; import android.widget.Button; -import android.widget.ImageView; import android.widget.TextView; +import android.widget.Toast; -import androidx.annotation.ColorInt; +import androidx.constraintlayout.widget.ConstraintLayout; import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.Theme; +import com.beemdevelopment.aegis.helpers.QrCodeHelper; import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.otp.GoogleAuthInfoException; +import com.beemdevelopment.aegis.otp.Transferable; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; -import com.beemdevelopment.aegis.vault.VaultManager; -import com.google.zxing.BarcodeFormat; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.google.android.material.color.MaterialColors; +import com.google.android.material.imageview.ShapeableImageView; import com.google.zxing.WriterException; -import com.google.zxing.common.BitMatrix; -import com.google.zxing.qrcode.QRCodeWriter; import java.util.ArrayList; import java.util.List; public class TransferEntriesActivity extends AegisActivity { - private List _authInfos; - private ImageView _qrImage; + private List _authInfos; + private ShapeableImageView _qrImage; + private TextView _description; private TextView _issuer; private TextView _accountName; private TextView _entriesCount; private Button _nextButton; private Button _previousButton; - - private VaultManager _vault; + private Button _copyButton; private int _currentEntryCount = 1; + private float _deviceBrightness; + private boolean _isMaxBrightnessSet = false; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } setContentView(R.layout.activity_share_entry); setSupportActionBar(findViewById(R.id.toolbar)); - _vault = getApp().getVaultManager(); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); _qrImage = findViewById(R.id.ivQrCode); + _description = findViewById(R.id.tvDescription); _issuer = findViewById(R.id.tvIssuer); _accountName = findViewById(R.id.tvAccountName); _entriesCount = findViewById(R.id.tvEntriesCount); _nextButton = findViewById(R.id.btnNext); _previousButton = findViewById(R.id.btnPrevious); + _copyButton = findViewById(R.id.btnCopyClipboard); - if (getSupportActionBar() != null){ + if (getSupportActionBar() != null) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); getSupportActionBar().setDisplayShowHomeEnabled(true); } Intent intent = getIntent(); - _authInfos = (ArrayList) intent.getSerializableExtra("authInfos"); + _authInfos = (ArrayList) intent.getSerializableExtra("authInfos"); int controlVisibility = _authInfos.size() != 1 ? View.VISIBLE : View.INVISIBLE; _nextButton.setVisibility(controlVisibility); @@ -78,7 +96,7 @@ protected void onCreate(Bundle savedInstanceState) { }); _previousButton.setOnClickListener(v -> { - if (_currentEntryCount > 1 ) { + if (_currentEntryCount > 1) { _nextButton.setText(R.string.next); _currentEntryCount--; generateQR(); @@ -89,14 +107,83 @@ protected void onCreate(Bundle savedInstanceState) { } }); - generateQR(); + if (_authInfos.get(0) instanceof GoogleAuthInfo) { + _copyButton.setVisibility(View.VISIBLE); + } + + _copyButton.setOnClickListener(v -> { + Transferable selectedEntry = _authInfos.get(_currentEntryCount - 1); + try { + ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText("text/plain", selectedEntry.getUri().toString()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PersistableBundle extras = new PersistableBundle(); + extras.putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true); + clip.getDescription().setExtras(extras); + } + if (clipboard != null) { + clipboard.setPrimaryClip(clip); + } + Toast.makeText(this, R.string.uri_copied_to_clipboard, Toast.LENGTH_SHORT).show(); + + } catch (GoogleAuthInfoException e) { + Dialogs.showErrorDialog(this, R.string.unable_to_copy_uri_to_clipboard, e); + } + }); + + // Calculate sensible dimensions for the QR code depending on whether we're in landscape + _qrImage.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + ConstraintLayout layout = findViewById(R.id.layoutShareEntry); + if (layout.getWidth() > layout.getHeight()) { + int squareSize = (int) (0.5 * layout.getHeight()); + ViewGroup.LayoutParams params = _qrImage.getLayoutParams(); + params.width = squareSize; + params.height = squareSize; + _qrImage.setLayoutParams(params); + } + + generateQR(); + + _qrImage.getViewTreeObserver().removeOnGlobalLayoutListener(this); + } + }); + + _deviceBrightness = getSystemBrightness(); + _qrImage.setOnClickListener(v -> { + if (!_isMaxBrightnessSet) { + setBrightness(1f); + _isMaxBrightnessSet = true; + } else { + setBrightness(_deviceBrightness); + _isMaxBrightnessSet = false; + } + }); + } + + private float getSystemBrightness() { + int brightness = 0; + try { + brightness = Settings.System.getInt(getContentResolver(), Settings.System.SCREEN_BRIGHTNESS); + } catch (Settings.SettingNotFoundException e) { + e.printStackTrace(); + } + + return brightness / 255f; + } + + private void setBrightness(float brightnessAmount) { + WindowManager.LayoutParams attrs = getWindow().getAttributes(); + attrs.screenBrightness = brightnessAmount; + getWindow().setAttributes(attrs); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case android.R.id.home: - onBackPressed(); + finish(); break; default: return super.onOptionsItemSelected(item); @@ -105,41 +192,30 @@ public boolean onOptionsItemSelected(MenuItem item) { return true; } - private void generateQR() { - GoogleAuthInfo selectedEntry = _authInfos.get(_currentEntryCount - 1); - _issuer.setText(selectedEntry.getIssuer()); - _accountName.setText(selectedEntry.getAccountName()); - _entriesCount.setText(getResources().getQuantityString(R.plurals.entries_count, _authInfos.size(), _currentEntryCount, _authInfos.size())); + Transferable selectedEntry = _authInfos.get(_currentEntryCount - 1); + if (selectedEntry instanceof GoogleAuthInfo) { + GoogleAuthInfo entry = (GoogleAuthInfo) selectedEntry; + _issuer.setText(entry.getIssuer()); + _accountName.setText(entry.getAccountName()); + } else if (selectedEntry instanceof GoogleAuthInfo.Export) { + _description.setText(R.string.google_auth_compatible_transfer_description); + } + + _entriesCount.setText(getResources().getQuantityString(R.plurals.qr_count, _authInfos.size(), _currentEntryCount, _authInfos.size())); - QRCodeWriter writer = new QRCodeWriter(); - BitMatrix bitMatrix; + int backgroundColor = _themeHelper.getConfiguredTheme() == Theme.LIGHT + ? MaterialColors.getColor(_qrImage, com.google.android.material.R.attr.colorSurfaceContainer) + : Color.WHITE; + + Bitmap bitmap; try { - bitMatrix = writer.encode(selectedEntry.getUri().toString(), BarcodeFormat.QR_CODE, 512, 512); - } catch (WriterException e) { + bitmap = QrCodeHelper.encodeToBitmap(selectedEntry.getUri().toString(), _qrImage.getWidth(), _qrImage.getWidth(), backgroundColor); + } catch (WriterException | GoogleAuthInfoException e) { Dialogs.showErrorDialog(this, R.string.unable_to_generate_qrcode, e); return; } - @ColorInt int backgroundColor = Color.WHITE; - if (getConfiguredTheme() == Theme.LIGHT) { - TypedValue typedValue = new TypedValue(); - getTheme().resolveAttribute(R.attr.background, typedValue, true); - backgroundColor = typedValue.data; - } - - int width = bitMatrix.getWidth(); - int height = bitMatrix.getHeight(); - int[] pixels = new int[width * height]; - for (int y = 0; y < height; y++) { - int offset = y * width; - for (int x = 0; x < width; x++) { - pixels[offset + x] = bitMatrix.get(x, y) ? Color.BLACK : backgroundColor; - } - } - - Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - bitmap.setPixels(pixels, 0, width, 0, 0, width, height); _qrImage.setImageBitmap(bitmap); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/components/DropdownCheckBoxes.java b/app/src/main/java/com/beemdevelopment/aegis/ui/components/DropdownCheckBoxes.java new file mode 100644 index 0000000000..06249985ff --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/components/DropdownCheckBoxes.java @@ -0,0 +1,182 @@ +package com.beemdevelopment.aegis.ui.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.text.InputType; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.CheckBox; +import android.widget.Filter; +import android.widget.Filterable; + +import androidx.annotation.PluralsRes; +import androidx.appcompat.widget.AppCompatAutoCompleteTextView; + +import com.beemdevelopment.aegis.R; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public class DropdownCheckBoxes extends AppCompatAutoCompleteTextView { + private @PluralsRes int _selectedCountPlural = R.plurals.dropdown_checkboxes_default_count; + + private boolean _allowFiltering = false; + + private final List _items = new ArrayList<>(); + private List _visibleItems = new ArrayList<>(); + private final Set _checkedItems = new HashSet<>(); + + private CheckboxAdapter _adapter; + + public DropdownCheckBoxes(Context context) { + super(context); + initialise(context, null); + } + + public DropdownCheckBoxes(Context context, AttributeSet attrs) { + super(context, attrs); + initialise(context, attrs); + } + + public DropdownCheckBoxes(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialise(context, attrs); + } + + private void initialise(Context context, AttributeSet attrs) { + _adapter = new CheckboxAdapter(); + setAdapter(_adapter); + + if (attrs != null) { + TypedArray a = context.obtainStyledAttributes( + attrs, + R.styleable.DropdownCheckBoxes, + 0, 0); + + _allowFiltering = a.getBoolean(R.styleable.DropdownCheckBoxes_allow_filtering, false); + a.recycle(); + } + + if (!_allowFiltering) { + setInputType(0); + } else { + setInputType(InputType.TYPE_CLASS_TEXT); + } + } + + /** + * Add parameterized items to be displayed as a checkbox in the dropdown view + * the label for the checkbox is determined by the toString() method of the items + * you add. + * + * @param items a list of the items you want to show in the dropdown + * @param startChecked whether the checkbox should be checked initially + */ + public void addItems(List items, boolean startChecked) { + _items.addAll(items); + _visibleItems.addAll(items); + + if (startChecked) { + _checkedItems.addAll(items); + } + + updateCheckedItemsCountText(); + _adapter.notifyDataSetChanged(); + } + + private void updateCheckedItemsCountText() { + if (_allowFiltering) { + return; + } + + int count = _checkedItems.size(); + String countString = getResources().getQuantityString(_selectedCountPlural, count, count); + + setText(countString, false); + } + + public void setCheckedItemsCountTextRes(@PluralsRes int resId) { + _selectedCountPlural = resId; + } + + public Set getCheckedItems() { + return _checkedItems; + } + + private class CheckboxAdapter extends BaseAdapter implements Filterable { + + @Override + public int getCount() { + return _visibleItems.size(); + } + + @Override + public T getItem(int i) { + return _visibleItems.get(i); + } + + @Override + public long getItemId(int i) { + return i; + } + + @Override + public View getView(int i, View convertView, ViewGroup viewGroup) { + if (convertView == null) { + convertView = LayoutInflater.from(getContext()).inflate(R.layout.dropdown_checkbox, viewGroup, false); + } + + T item = _visibleItems.get(i); + + CheckBox checkBox = convertView.findViewById(R.id.checkbox_in_dropdown); + checkBox.setText(item.toString()); + checkBox.setTag(item); + checkBox.setChecked(_checkedItems.contains(item)); + + checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + _checkedItems.add((T) buttonView.getTag()); + } else { + _checkedItems.remove((T) buttonView.getTag()); + } + + updateCheckedItemsCountText(); + }); + + return convertView; + } + + @Override + public Filter getFilter() { + return new Filter() { + @Override + protected FilterResults performFiltering(CharSequence query) { + FilterResults results = new FilterResults(); + results.values = (query == null || query.toString().isEmpty()) + ? _items + : _items.stream().filter(item -> { + String q = query.toString().toLowerCase(); + String strLower = item.toString().toLowerCase(); + + return strLower.contains(q); + }) + .collect(Collectors.toList()); + + return results; + } + + @Override + protected void publishResults(CharSequence charSequence, FilterResults filterResults) { + _visibleItems = (List) filterResults.values; + notifyDataSetChanged(); + } + }; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/components/NoAutofillEditText.java b/app/src/main/java/com/beemdevelopment/aegis/ui/components/NoAutofillEditText.java new file mode 100644 index 0000000000..818981d95a --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/components/NoAutofillEditText.java @@ -0,0 +1,35 @@ +package com.beemdevelopment.aegis.ui.components; + +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.material.textfield.TextInputEditText; + +public class NoAutofillEditText extends TextInputEditText { + + public NoAutofillEditText(@NonNull Context context) { + super(context); + } + + public NoAutofillEditText(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public NoAutofillEditText(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public int getAutofillType() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return View.AUTOFILL_TYPE_NONE; + } else { + return super.getAutofillType(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/ChangelogDialog.java b/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/ChangelogDialog.java index 1d6f6ee5d8..d26d24b8b9 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/ChangelogDialog.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/ChangelogDialog.java @@ -5,7 +5,7 @@ import com.beemdevelopment.aegis.R; public class ChangelogDialog extends SimpleWebViewDialog { - private ChangelogDialog() { + public ChangelogDialog() { super(R.string.changelog); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java b/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java index 2209d19e4b..692ba3a546 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java @@ -1,15 +1,12 @@ package com.beemdevelopment.aegis.ui.dialogs; -import android.app.Activity; import android.app.Dialog; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.content.DialogInterface; -import android.content.res.ColorStateList; -import android.graphics.Color; -import android.text.Editable; import android.text.InputType; +import android.text.SpannableStringBuilder; import android.text.TextWatcher; import android.text.method.PasswordTransformationMethod; import android.view.LayoutInflater; @@ -18,32 +15,37 @@ import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.CheckBox; -import android.widget.CompoundButton; import android.widget.EditText; import android.widget.ListView; import android.widget.NumberPicker; import android.widget.ProgressBar; +import android.widget.RadioButton; +import android.widget.RadioGroup; import android.widget.TextView; +import android.widget.Toast; import androidx.activity.ComponentActivity; +import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; +import com.beemdevelopment.aegis.BackupsVersioningStrategy; import com.beemdevelopment.aegis.Preferences; import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.helpers.EditTextHelper; import com.beemdevelopment.aegis.helpers.PasswordStrengthHelper; +import com.beemdevelopment.aegis.helpers.SimpleTextWatcher; import com.beemdevelopment.aegis.importers.DatabaseImporter; import com.beemdevelopment.aegis.ui.tasks.KeyDerivationTask; import com.beemdevelopment.aegis.vault.VaultEntry; import com.beemdevelopment.aegis.vault.slots.PasswordSlot; import com.beemdevelopment.aegis.vault.slots.Slot; import com.beemdevelopment.aegis.vault.slots.SlotException; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; -import com.nulabinc.zxcvbn.Strength; -import com.nulabinc.zxcvbn.Zxcvbn; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; @@ -56,57 +58,67 @@ private Dialogs() { } public static void secureDialog(Dialog dialog) { - dialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE); + if (new Preferences(dialog.getContext()).isSecureScreenEnabled()) { + dialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE); + } } public static void showSecureDialog(Dialog dialog) { - if (new Preferences(dialog.getContext()).isSecureScreenEnabled()) { - secureDialog(dialog); - } + secureDialog(dialog); dialog.show(); } - public static void showDeleteEntriesDialog(Activity activity, List services, DialogInterface.OnClickListener onDelete) { - View view = activity.getLayoutInflater().inflate(R.layout.dialog_delete_entry, null); + public static void showDeleteEntriesDialog(Context context, List services, DialogInterface.OnClickListener onDelete) { + View view = LayoutInflater.from(context).inflate(R.layout.dialog_delete_entry, null); TextView textMessage = view.findViewById(R.id.text_message); TextView textExplanation = view.findViewById(R.id.text_explanation); String entries = services.stream() - .map(entry -> !entry.getIssuer().isEmpty() ? entry.getIssuer() - : !entry.getName().isEmpty() ? entry.getName() - : activity.getString(R.string.unknown_issuer) - ) - .collect(Collectors.joining(", ")); - textExplanation.setText(activity.getString(R.string.delete_entry_explanation, entries)); + .map(entry -> String.format("• %s", getVaultEntryName(context, entry))) + .collect(Collectors.joining("\n")); + textExplanation.setText(context.getString(R.string.delete_entry_explanation, entries)); String title, message; if (services.size() > 1) { - title = activity.getString(R.string.delete_entries); - message = activity.getResources().getQuantityString(R.plurals.delete_entries_description, services.size(), services.size()); + title = context.getString(R.string.delete_entries); + message = context.getResources().getQuantityString(R.plurals.delete_entries_description, services.size(), services.size()); } else { - title = activity.getString(R.string.delete_entry); - message = activity.getString(R.string.delete_entry_description); + title = context.getString(R.string.delete_entry); + message = context.getString(R.string.delete_entry_description); } textMessage.setText(message); - showSecureDialog(new AlertDialog.Builder(activity) + showSecureDialog(new MaterialAlertDialogBuilder(context, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) .setTitle(title) .setView(view) + .setIconAttribute(android.R.attr.alertDialogIcon) .setPositiveButton(android.R.string.yes, onDelete) .setNegativeButton(android.R.string.no, null) .create()); } - public static void showDiscardDialog(Activity activity, DialogInterface.OnClickListener onSave, DialogInterface.OnClickListener onDiscard) { - showSecureDialog(new AlertDialog.Builder(activity) - .setTitle(activity.getString(R.string.discard_changes)) - .setMessage(activity.getString(R.string.discard_changes_description)) + private static String getVaultEntryName(Context context, VaultEntry entry) { + if (!entry.getIssuer().isEmpty() && !entry.getName().isEmpty()) { + return String.format("%s (%s)", entry.getIssuer(), entry.getName()); + } else if (entry.getIssuer().isEmpty() && entry.getName().isEmpty()) { + return context.getString(R.string.unknown_issuer); + } else if (entry.getIssuer().isEmpty()) { + return entry.getName(); + } else { + return entry.getIssuer(); + } + } + + public static void showDiscardDialog(Context context, DialogInterface.OnClickListener onSave, DialogInterface.OnClickListener onDiscard) { + showSecureDialog(new MaterialAlertDialogBuilder(context, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(context.getString(R.string.discard_changes)) + .setMessage(context.getString(R.string.discard_changes_description)) + .setIconAttribute(android.R.attr.alertDialogIcon) .setPositiveButton(R.string.save, onSave) .setNegativeButton(R.string.discard, onDiscard) .create()); } - public static void showSetPasswordDialog(ComponentActivity activity, Dialogs.SlotListener listener) { - Zxcvbn zxcvbn = new Zxcvbn(); + public static void showSetPasswordDialog(ComponentActivity activity, PasswordSlotListener listener) { View view = activity.getLayoutInflater().inflate(R.layout.dialog_password, null); EditText textPassword = view.findViewById(R.id.text_password); EditText textPasswordConfirm = view.findViewById(R.id.text_password_confirm); @@ -114,8 +126,10 @@ public static void showSetPasswordDialog(ComponentActivity activity, Dialogs.Slo TextView textPasswordStrength = view.findViewById(R.id.text_password_strength); TextInputLayout textPasswordWrapper = view.findViewById(R.id.text_password_wrapper); CheckBox switchToggleVisibility = view.findViewById(R.id.check_toggle_visibility); + PasswordStrengthHelper passStrength = new PasswordStrengthHelper( + textPassword, barPasswordStrength, textPasswordStrength, textPasswordWrapper); - switchToggleVisibility.setOnCheckedChangeListener((CompoundButton.OnCheckedChangeListener) (buttonView, isChecked) -> { + switchToggleVisibility.setOnCheckedChangeListener((buttonView, isChecked) -> { if (isChecked) { textPassword.setTransformationMethod(null); textPasswordConfirm.setTransformationMethod(null); @@ -127,7 +141,7 @@ public static void showSetPasswordDialog(ComponentActivity activity, Dialogs.Slo } }); - AlertDialog dialog = new AlertDialog.Builder(activity) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity) .setTitle(R.string.set_password) .setView(view) .setPositiveButton(android.R.string.ok, null) @@ -166,50 +180,39 @@ public static void showSetPasswordDialog(ComponentActivity activity, Dialogs.Slo }); }); - TextWatcher watcher = new TextWatcher() { - @Override - public void onTextChanged(CharSequence c, int start, int before, int count) { - boolean equal = EditTextHelper.areEditTextsEqual(textPassword, textPasswordConfirm); - buttonOK.get().setEnabled(equal); - - Strength strength = zxcvbn.measure(textPassword.getText()); - barPasswordStrength.setProgress(strength.getScore()); - barPasswordStrength.setProgressTintList(ColorStateList.valueOf(Color.parseColor(PasswordStrengthHelper.getColor(strength.getScore())))); - textPasswordStrength.setText((textPassword.getText().length() != 0) ? PasswordStrengthHelper.getString(strength.getScore(), activity) : ""); - textPasswordWrapper.setError(strength.getFeedback().getWarning()); - strength.wipe(); - } - - @Override - public void beforeTextChanged(CharSequence c, int start, int count, int after) { - } - - @Override - public void afterTextChanged(Editable c) { - } - }; + TextWatcher watcher = new SimpleTextWatcher(text -> { + boolean equal = EditTextHelper.areEditTextsEqual(textPassword, textPasswordConfirm); + buttonOK.get().setEnabled(equal); + passStrength.measure(activity); + }); textPassword.addTextChangedListener(watcher); textPasswordConfirm.addTextChangedListener(watcher); showSecureDialog(dialog); } - private static void showTextInputDialog(Context context, @StringRes int titleId, @StringRes int messageId, @StringRes int hintId, TextInputListener listener, DialogInterface.OnCancelListener cancelListener, boolean isSecret) { + private static void showTextInputDialog(Context context, @StringRes int titleId, @StringRes int messageId, @StringRes int hintId, TextInputListener listener, DialogInterface.OnCancelListener cancelListener, boolean isSecret,@Nullable String hint) { + final AtomicReference