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 @@
-# Aegis Authenticator [](https://github.com/beemdevelopment/Aegis/actions?query=workflow%3Abuild) [](https://crowdin.com/project/aegis-authenticator) [](https://www.buymeacoffee.com/beemdevelopment) [](https://matrix.to/#/#aegis:matrix.org)
+# Aegis Authenticator
-__Aegis Authenticator__ is a free, secure and open source 2FA app for Android.
+
+
+[](https://github.com/beemdevelopment/Aegis/actions/workflows/build-app-workflow.yaml?query=branch%3Amaster) [](https://crowdin.com/project/aegis-authenticator) [](https://www.buymeacoffee.com/beemdevelopment) [](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
[](metadata/en-US/images/phoneScreenshots/screenshot1.png?raw=true)
[](/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)
[](/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)
[](metadata/en-US/images/phoneScreenshots/screenshot4.png?raw=true)
+
[](metadata/en-US/images/phoneScreenshots/screenshot5.png?raw=true)
[](metadata/en-US/images/phoneScreenshots/screenshot6.png?raw=true)
-
[](metadata/en-US/images/phoneScreenshots/screenshot7.png?raw=true)
+[](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"
[](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.
[](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)
+ [](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/).
+
+ [](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.
+
+ [](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 extends OtpInfo> 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.
+ *