Compare commits
239
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0f9653ace | ||
|
|
e2df97514c | ||
|
|
ed5ac4730f | ||
|
|
61d41a26fa | ||
|
|
fd42c5c893 | ||
|
|
0eccc875b0 | ||
|
|
d3e5472ef2 | ||
|
|
bf825df221 | ||
|
|
6de007e3b1 | ||
|
|
121c1dada9 | ||
|
|
1c9931b1c8 | ||
|
|
51698947aa | ||
|
|
ceaf52e238 | ||
|
|
cad2ecfe1b | ||
|
|
36b1f91140 | ||
|
|
4bd12f5abe | ||
|
|
780a215a6b | ||
|
|
f6f05dee6e | ||
|
|
7a1e4e1d77 | ||
|
|
2c36149a3d | ||
|
|
f3731c23a3 | ||
|
|
bebda569de | ||
|
|
0112431269 | ||
|
|
59c887e6a4 | ||
|
|
ee6a020f4d | ||
|
|
ee15a61403 | ||
|
|
927a27943b | ||
|
|
69f0bb4fbc | ||
|
|
ac51996896 | ||
|
|
8cf48a94aa | ||
|
|
d7e2114811 | ||
|
|
caad516a6e | ||
|
|
12683e3ff0 | ||
|
|
74ecdec637 | ||
|
|
b98a5c55bc | ||
|
|
96852528f4 | ||
|
|
bcbe5f33d4 | ||
|
|
5cf8f73193 | ||
|
|
6ea9502ad1 | ||
|
|
65790166b6 | ||
|
|
bb4450a00b | ||
|
|
c9a27b830d | ||
|
|
aff441a7ee | ||
|
|
9d318a0d54 | ||
|
|
ef759eb15e | ||
|
|
f8d89d5754 | ||
|
|
542a8c6f8c | ||
|
|
282f85fb3b | ||
|
|
fd5a0390f0 | ||
|
|
01e59d79a1 | ||
|
|
506d9efab3 | ||
|
|
5ab36d72a4 | ||
|
|
ac75c346ec | ||
|
|
5dfdbabf30 | ||
|
|
e7cc3e6ca3 | ||
|
|
f8d60fb1b7 | ||
|
|
f081cfa77b | ||
|
|
55dc4b22d5 | ||
|
|
9f55d4f659 | ||
|
|
715c5112ab | ||
|
|
e77df1eee4 | ||
|
|
f6f549aaeb | ||
|
|
2f8d66843f | ||
|
|
12dad56fe3 | ||
|
|
ffbea9ea32 | ||
|
|
286d74b69a | ||
|
|
10ecd513fc | ||
|
|
c45564d852 | ||
|
|
98e802a534 | ||
|
|
6a1e6db486 | ||
|
|
237e6744a5 | ||
|
|
599be44369 | ||
|
|
b2a98d9799 | ||
|
|
bee490d091 | ||
|
|
1f69bf558e | ||
|
|
dd88f5bb0c | ||
|
|
af9be15ae0 | ||
|
|
8562fafda7 | ||
|
|
708a5f4a19 | ||
|
|
fcc7b7baa2 | ||
|
|
7c0b22920d | ||
|
|
c27d080a11 | ||
|
|
0ee74bfeb2 | ||
|
|
289b8775d3 | ||
|
|
c12397a509 | ||
|
|
0aef57e610 | ||
|
|
7993142cc5 | ||
|
|
031a11250a | ||
|
|
5ec1e33a31 | ||
|
|
c58b773878 | ||
|
|
681326839c | ||
|
|
3d124d225e | ||
|
|
8292173297 | ||
|
|
84e179f9d3 | ||
|
|
41eba76b05 | ||
|
|
ad77bd687f | ||
|
|
c6888b11df | ||
|
|
427be4d56b | ||
|
|
c206d4bee8 | ||
|
|
7de7fba2c8 | ||
|
|
8ea28327db | ||
|
|
80c37556c7 | ||
|
|
35d3675561 | ||
|
|
7517cfc975 | ||
|
|
4198ca3bb7 | ||
|
|
3927ddec3e | ||
|
|
8c9ab38153 | ||
|
|
3c10b234ea | ||
|
|
8a9a0856fb | ||
|
|
adc9179364 | ||
|
|
8ae8130b71 | ||
|
|
4427498d5e | ||
|
|
aad6d78750 | ||
|
|
ef2e803e04 | ||
|
|
c718ea79c5 | ||
|
|
acc6b9a3de | ||
|
|
4bf807c46a | ||
|
|
e4798703f0 | ||
|
|
80624f844c | ||
|
|
714459c528 | ||
|
|
78feb25543 | ||
|
|
3bc10bd4b8 | ||
|
|
66b7fd38d6 | ||
|
|
bc665b6980 | ||
|
|
06405d10ad | ||
|
|
1e3ceefeec | ||
|
|
642864fca1 | ||
|
|
3ec6e35011 | ||
|
|
e19ceb0761 | ||
|
|
30b8e35de8 | ||
|
|
012b66fe9b | ||
|
|
4cd0d2de04 | ||
|
|
bb3f4d733f | ||
|
|
05a8c7c51f | ||
|
|
029ca81bbf | ||
|
|
70ceca6a7b | ||
|
|
2767727ad4 | ||
|
|
2dc01adc48 | ||
|
|
79022be3b6 | ||
|
|
dcda668671 | ||
|
|
ff0cd99170 | ||
|
|
5bb32a982b | ||
|
|
85e230c46a | ||
|
|
045b8280bf | ||
|
|
6fd8a3b6b4 | ||
|
|
e46857a26e | ||
|
|
b875baacef | ||
|
|
3441deee64 | ||
|
|
bd3697659f | ||
|
|
5f12eae678 | ||
|
|
3b715d58cf | ||
|
|
76245677b0 | ||
|
|
f73a64acd6 | ||
|
|
b4f12af23a | ||
|
|
043b17909c | ||
|
|
5226def653 | ||
|
|
1dcf56a0fa | ||
|
|
fff81b0ee3 | ||
|
|
f32ac0e8ab | ||
|
|
8d3b2e2c9d | ||
|
|
0cfd78ace1 | ||
|
|
5db365a999 | ||
|
|
a492bcbde3 | ||
|
|
d40ebf0f67 | ||
|
|
1373a67ea0 | ||
|
|
11d7493245 | ||
|
|
d2f0f71be5 | ||
|
|
49b334ff70 | ||
|
|
ac123918c2 | ||
|
|
4cf0465a32 | ||
|
|
98de86b53e | ||
|
|
dfa3ae4d4e | ||
|
|
ffcbaffcfc | ||
|
|
ce9a15b7bc | ||
|
|
e73de453c6 | ||
|
|
3b1035b5e1 | ||
|
|
239d8740be | ||
|
|
49130cee73 | ||
|
|
0909341caa | ||
|
|
81d5915969 | ||
|
|
8d4a687817 | ||
|
|
b0e8d041d0 | ||
|
|
ca49cd4df5 | ||
|
|
beadf4db8b | ||
|
|
f7b4cceb81 | ||
|
|
86b86fef10 | ||
|
|
927ea50c33 | ||
|
|
339a31b0f3 | ||
|
|
cf9fbf081c | ||
|
|
d4557065ea | ||
|
|
d0c8c5fae4 | ||
|
|
b8fa67304a | ||
|
|
903948d57c | ||
|
|
71eb6b133c | ||
|
|
6f66706685 | ||
|
|
6d8a8c066c | ||
|
|
baa3cac5a4 | ||
|
|
71f2b54deb | ||
|
|
927f5f2bd5 | ||
|
|
c0020684de | ||
|
|
b8066c1925 | ||
|
|
0f3e5c537c | ||
|
|
5ce21a94ea | ||
|
|
d1d5df8051 | ||
|
|
43c064bb26 | ||
|
|
b9a81bdd75 | ||
|
|
0938d3688a | ||
|
|
fcb7bf032b | ||
|
|
acfb70c267 | ||
|
|
119c3838e1 | ||
|
|
39a5dc3ee8 | ||
|
|
473cb7bfeb | ||
|
|
3d13117752 | ||
|
|
76c89f0fcc | ||
|
|
185e22a82e | ||
|
|
e88c3ea6db | ||
|
|
a04020aed0 | ||
|
|
af2bf6f683 | ||
|
|
72c165749f | ||
|
|
f14bd99f63 | ||
|
|
e54ac9aba4 | ||
|
|
f9b4e8fa3e | ||
|
|
86580e38af | ||
|
|
6955eb10da | ||
|
|
153f5e09d8 | ||
|
|
cf63f5bef2 | ||
|
|
aec639cc53 | ||
|
|
5c196f1ca5 | ||
|
|
56ddefa246 | ||
|
|
4c977a6371 | ||
|
|
07b0b26caf | ||
|
|
0652eaa5cd | ||
|
|
adfc472a39 | ||
|
|
9ca4de8b60 | ||
|
|
0b104cf285 | ||
|
|
d65982aaa1 | ||
|
|
fbc390b714 | ||
|
|
a14dd382bf | ||
|
|
a302f3daac |
+4
-1
@@ -1 +1,4 @@
|
||||
custom: ['https://www.buymeacoffee.com/beemdevelopment']
|
||||
custom:
|
||||
- "https://www.buymeacoffee.com/beemdevelopment"
|
||||
- "https://www.blockchain.com/btc/address/bc1q26kyxqjkc6tu477pzy0whagwhs4ypv93qls22n"
|
||||
- "https://nanocrawler.cc/explorer/account/nano_1aegisc559b1x4p3839egnu579jkd4htpidy14eo9e31gzqmwuafypnj4q94"
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
name: "Bug report"
|
||||
about: "Create a report to help us fix a bug"
|
||||
labels: bug
|
||||
---
|
||||
|
||||
<!-- Please read the [bug reports section of the contribution
|
||||
guidelines](/CONTRIBUTING.md#bug-reports) before submitting an issue. -->
|
||||
|
||||
###### 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.
|
||||
```
|
||||
@@ -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
|
||||
@@ -5,8 +5,53 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/wrapper-validation-action@e2c57acffb2c9aa5a8dc6eda2bbae0b6e495bd4c
|
||||
uses: gradle/wrapper-validation-action@55e685c48d84285a5b0418cd094606e199cca3b6
|
||||
- name: Build the app
|
||||
run: ./gradlew build
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: apk
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
test:
|
||||
runs-on: macos-latest
|
||||
# This is probably pretty expensive for GitHub, so restrict the repositories that this job runs on
|
||||
if: github.repository == 'beemdevelopment/Aegis' || github.repository == 'alexbakker/Aegis'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '11'
|
||||
distribution: 'temurin'
|
||||
cache: 'gradle'
|
||||
- name: Install HAXM
|
||||
run: brew install --cask intel-haxm
|
||||
- name: Tests
|
||||
uses: reactivecircus/android-emulator-runner@50986b1464923454c95e261820bc626f38490ec0
|
||||
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@v3
|
||||
if: always()
|
||||
with:
|
||||
name: instrumented-test-report
|
||||
path: |
|
||||
artifacts/*
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- 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
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: java
|
||||
- name: Build
|
||||
run: ./gradlew assembleDebug
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -0,0 +1,31 @@
|
||||
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@v3
|
||||
- name: Install crowdin-cli
|
||||
run: |
|
||||
wget https://github.com/crowdin/crowdin-cli/releases/download/3.7.2/crowdin-cli.zip
|
||||
echo "ee9f838b819ccedc33c9b2537055e5ba7d7934561b24df1e1a6274cbd6e27f2d crowdin-cli.zip" | sha256sum -c
|
||||
unzip crowdin-cli.zip -d crowdin-cli
|
||||
- name: Upload to Crowdin
|
||||
env:
|
||||
CROWDIN_TOKEN: "${{ secrets.CROWDIN_TOKEN }}"
|
||||
run: |
|
||||
java -jar ./crowdin-cli/3.7.2/crowdin-cli.jar upload sources \
|
||||
--no-progress \
|
||||
--token "$CROWDIN_TOKEN" \
|
||||
--project-id 372633 \
|
||||
--base-path app/src/main \
|
||||
--source res/values/strings.xml \
|
||||
--translation "res/values-%android_code%/%original_file_name%" \
|
||||
--dest strings.xml \
|
||||
--branch master
|
||||
+6
-4
@@ -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
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# 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 of 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.
|
||||
|
||||
## 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.
|
||||
@@ -1,7 +1,11 @@
|
||||
<img align="left" width="80" height="80" src="metadata/en-US/images/icon.png"
|
||||
alt="App icon">
|
||||
|
||||
# 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
|
||||
|
||||
<br>
|
||||
|
||||
[](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
|
||||
@@ -9,6 +13,8 @@ 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).
|
||||
|
||||
@@ -109,7 +115,7 @@ 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)
|
||||
|
||||
|
||||
+63
-53
@@ -1,5 +1,6 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'com.google.protobuf'
|
||||
apply plugin: 'dagger.hilt.android.plugin'
|
||||
|
||||
def getCmdOutput = { cmd ->
|
||||
def stdout = new ByteArrayOutputStream()
|
||||
@@ -18,27 +19,25 @@ def fileProviderAuthority = "${packageName}.fileprovider"
|
||||
def fileProviderAuthorityDebug = "${packageName}.debug.fileprovider"
|
||||
|
||||
android {
|
||||
compileSdkVersion 30
|
||||
compileSdkVersion 33
|
||||
|
||||
namespace packageName
|
||||
|
||||
defaultConfig {
|
||||
applicationId "${packageName}"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 30
|
||||
versionCode 51
|
||||
versionName "2.0.3"
|
||||
targetSdkVersion 33
|
||||
versionCode 58
|
||||
versionName "2.1.3"
|
||||
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)"
|
||||
|
||||
testInstrumentationRunner "com.beemdevelopment.aegis.AegisTestRunner"
|
||||
testInstrumentationRunnerArguments clearPackageData: 'true'
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
abortOnError true
|
||||
disable "MissingTranslation"
|
||||
}
|
||||
|
||||
testOptions {
|
||||
execution 'ANDROIDX_TEST_ORCHESTRATOR'
|
||||
|
||||
@@ -71,13 +70,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 = [
|
||||
@@ -87,13 +79,17 @@ 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'
|
||||
}
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
// R8 doesn't remove these resources, so exclude them manually. This reduces APK size by 4MB.
|
||||
resources {
|
||||
excludes += ['/org/bouncycastle/pqc/**/*.properties']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +98,11 @@ android {
|
||||
sourceCompatibility 1.8
|
||||
coreLibraryDesugaringEnabled true
|
||||
}
|
||||
lint {
|
||||
abortOnError true
|
||||
checkDependencies true
|
||||
disable 'MissingQuantity', 'MissingTranslation'
|
||||
}
|
||||
}
|
||||
|
||||
protobuf {
|
||||
@@ -120,32 +121,39 @@ protobuf {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
def androidTestVersion = '1.4.0'
|
||||
def cameraxVersion = '1.0.1'
|
||||
def glideVersion = '4.12.0'
|
||||
def guavaVersion = '30.1.1'
|
||||
def cameraxVersion = '1.2.0'
|
||||
def glideVersion = '4.14.2'
|
||||
def guavaVersion = '31.1'
|
||||
def hiltVersion = '2.44.2'
|
||||
def junitVersion = '4.13.2'
|
||||
def libsuVersion = '3.1.2'
|
||||
def libsuVersion = '5.0.3'
|
||||
|
||||
annotationProcessor 'androidx.annotation:annotation:1.2.0'
|
||||
annotationProcessor 'androidx.annotation:annotation:1.5.0'
|
||||
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.6.1'
|
||||
implementation 'androidx.appcompat:appcompat:1.5.1'
|
||||
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-alpha27"
|
||||
implementation "androidx.camera:camera-view:1.1.0"
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation "androidx.core:core:1.3.2"
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||
implementation "androidx.core:core:1.9.0"
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
implementation "androidx.lifecycle:lifecycle-process:2.3.1"
|
||||
implementation 'androidx.preference:preference:1.1.1'
|
||||
implementation "androidx.lifecycle:lifecycle-process:2.5.1"
|
||||
implementation ('androidx.preference:preference:1.2.0') {
|
||||
// See: https://issuetracker.google.com/issues/238425626
|
||||
exclude group: 'androidx.lifecycle', module:'lifecycle-viewmodel'
|
||||
exclude group: 'androidx.lifecycle', module:'lifecycle-viewmodel-ktx'
|
||||
}
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation "androidx.viewpager2:viewpager2:1.0.0"
|
||||
implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1'
|
||||
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}"
|
||||
@@ -155,34 +163,36 @@ 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.google.android.material:material:1.7.0'
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.21.4'
|
||||
implementation 'com.google.zxing:core:3.5.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 'com.nulab-inc:zxcvbn:1.7.0'
|
||||
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 'net.lingala.zip4j:zip4j:2.11.2'
|
||||
implementation 'info.guardianproject.trustedintents:trustedintents:0.2'
|
||||
implementation 'org.bouncycastle:bcprov-jdk15on:1.69'
|
||||
implementation 'org.bouncycastle:bcprov-jdk18on:1.72'
|
||||
implementation "org.simpleflatmapper:sfm-csv:8.2.3"
|
||||
|
||||
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'
|
||||
androidTestAnnotationProcessor "com.google.dagger:hilt-android-compiler:$hiltVersion"
|
||||
androidTestImplementation "com.google.dagger:hilt-android-testing:$hiltVersion"
|
||||
androidTestImplementation 'androidx.test:core:1.5.0'
|
||||
androidTestImplementation 'androidx.test:runner:1.5.1'
|
||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.4'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.0'
|
||||
androidTestImplementation "junit:junit:${junitVersion}"
|
||||
androidTestUtil 'androidx.test:orchestrator:1.4.0'
|
||||
androidTestUtil 'androidx.test:orchestrator:1.4.2'
|
||||
|
||||
testImplementation "androidx.test:core:${androidTestVersion}"
|
||||
testImplementation 'androidx.test:core:1.5.0'
|
||||
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:20220924'
|
||||
testImplementation 'org.robolectric:robolectric:4.9'
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
<issue id="InvalidPackage">
|
||||
<ignore regexp="X509LDAPCertStoreSpi" />
|
||||
</issue>
|
||||
<issue id="ResourceType" severity="Warning" />
|
||||
</lint>
|
||||
|
||||
Vendored
+4
-22
@@ -1,25 +1,7 @@
|
||||
# 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 *;
|
||||
#}
|
||||
|
||||
-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 { *; }
|
||||
|
||||
-dontobfuscate
|
||||
-dontwarn javax.naming.**
|
||||
|
||||
@@ -2,52 +2,116 @@ package com.beemdevelopment.aegis;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.test.espresso.UiController;
|
||||
import androidx.test.espresso.ViewAction;
|
||||
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.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.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";
|
||||
|
||||
protected AegisApplication getApp() {
|
||||
return (AegisApplication) InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext();
|
||||
@Rule
|
||||
public HiltAndroidRule hiltRule = new HiltAndroidRule(this);
|
||||
|
||||
@Rule
|
||||
public final GrantPermissionRule permRule = getGrantPermissionRule();
|
||||
|
||||
@Inject
|
||||
protected VaultManager _vaultManager;
|
||||
|
||||
@Inject
|
||||
protected Preferences _prefs;
|
||||
|
||||
@Before
|
||||
public void init() {
|
||||
hiltRule.inject();
|
||||
}
|
||||
|
||||
protected VaultManager getVault() {
|
||||
return getApp().getVaultManager();
|
||||
private static GrantPermissionRule getGrantPermissionRule() {
|
||||
List<String> 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<VaultEntry> 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 +142,11 @@ public abstract class AegisTest {
|
||||
}
|
||||
|
||||
protected static <T extends OtpInfo> VaultEntry generateEntry(Class<T> type, String name, String issuer) {
|
||||
byte[] secret = CryptoUtils.generateRandomBytes(20);
|
||||
return generateEntry(type, name, issuer, 20);
|
||||
}
|
||||
|
||||
protected static <T extends OtpInfo> VaultEntry generateEntry(Class<T> type, String name, String issuer, int secretLength) {
|
||||
byte[] secret = CryptoUtils.generateRandomBytes(secretLength);
|
||||
|
||||
OtpInfo info;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.beemdevelopment.aegis;
|
||||
|
||||
import dagger.hilt.android.testing.CustomTestApplication;
|
||||
|
||||
@CustomTestApplication(AegisApplicationBase.class)
|
||||
public interface AegisTestApplication {
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.beemdevelopment.aegis;
|
||||
|
||||
import android.app.Application;
|
||||
import android.app.Instrumentation;
|
||||
import android.content.Context;
|
||||
import android.preference.PreferenceManager;
|
||||
|
||||
@@ -10,6 +11,16 @@ 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();
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
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 java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
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<PreferencesActivity> _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());
|
||||
doExport();
|
||||
}
|
||||
|
||||
@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());
|
||||
doExport();
|
||||
}
|
||||
|
||||
@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<VaultEntry> 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 checkReadEntries(Collection<VaultEntry> entries) {
|
||||
List<VaultEntry> 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-10
@@ -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.Before;
|
||||
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());
|
||||
}
|
||||
@@ -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<MainActivity> _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()));
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,88 @@
|
||||
package com.beemdevelopment.aegis;
|
||||
|
||||
import androidx.test.espresso.ViewInteraction;
|
||||
import androidx.test.ext.junit.rules.ActivityScenarioRule;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.filters.LargeTest;
|
||||
|
||||
import com.beemdevelopment.aegis.ui.IntroActivity;
|
||||
import com.beemdevelopment.aegis.vault.VaultManager;
|
||||
import com.beemdevelopment.aegis.vault.slots.BiometricSlot;
|
||||
import com.beemdevelopment.aegis.vault.slots.PasswordSlot;
|
||||
import com.beemdevelopment.aegis.vault.slots.SlotList;
|
||||
|
||||
import org.junit.Rule;
|
||||
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.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.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 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<IntroActivity> _activityRule = new ActivityScenarioRule<>(IntroActivity.class);
|
||||
|
||||
private ViewPager2IdlingResource _viewPager2IdlingResource;
|
||||
|
||||
@Rule
|
||||
public final ActivityScenarioRule<IntroActivity> 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 class IntroTest extends AegisTest {
|
||||
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 class IntroTest extends AegisTest {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,7 @@
|
||||
package com.beemdevelopment.aegis;
|
||||
|
||||
import androidx.annotation.IdRes;
|
||||
import androidx.test.espresso.ViewInteraction;
|
||||
import androidx.test.espresso.contrib.RecyclerViewActions;
|
||||
import androidx.test.espresso.matcher.RootMatchers;
|
||||
import androidx.test.ext.junit.rules.ActivityScenarioRule;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.filters.LargeTest;
|
||||
|
||||
import com.beemdevelopment.aegis.encoding.Base32;
|
||||
import com.beemdevelopment.aegis.otp.HotpInfo;
|
||||
import com.beemdevelopment.aegis.otp.SteamInfo;
|
||||
import com.beemdevelopment.aegis.otp.TotpInfo;
|
||||
import com.beemdevelopment.aegis.ui.MainActivity;
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
import com.beemdevelopment.aegis.vault.VaultManager;
|
||||
import com.beemdevelopment.aegis.vault.slots.PasswordSlot;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static androidx.test.espresso.Espresso.onView;
|
||||
import static androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu;
|
||||
import static androidx.test.espresso.Espresso.openContextualActionModeOverflowMenu;
|
||||
import static androidx.test.espresso.action.ViewActions.clearText;
|
||||
import static androidx.test.espresso.action.ViewActions.click;
|
||||
@@ -34,41 +10,88 @@ 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.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.test.core.app.ApplicationProvider;
|
||||
import androidx.test.espresso.ViewInteraction;
|
||||
import androidx.test.espresso.contrib.RecyclerViewActions;
|
||||
import androidx.test.espresso.matcher.RootMatchers;
|
||||
import androidx.test.ext.junit.rules.ActivityScenarioRule;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.filters.LargeTest;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
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.vault.VaultEntry;
|
||||
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 dagger.hilt.android.testing.HiltAndroidTest;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
@HiltAndroidTest
|
||||
@LargeTest
|
||||
public class OverallTest extends AegisTest {
|
||||
private static final String _groupName = "Test";
|
||||
|
||||
private final ActivityScenarioRule<MainActivity> _activityRule = new ActivityScenarioRule<>(MainActivity.class);
|
||||
|
||||
@Rule
|
||||
public final ActivityScenarioRule<MainActivity> 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<VaultEntry> 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);
|
||||
@@ -106,21 +129,22 @@ public class OverallTest extends AegisTest {
|
||||
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(2, longClick()));
|
||||
onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(3, click()));
|
||||
onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(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(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());
|
||||
@@ -169,6 +193,10 @@ public class OverallTest extends AegisTest {
|
||||
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 {
|
||||
@@ -179,9 +207,25 @@ public class OverallTest extends AegisTest {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.beemdevelopment.aegis;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
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());
|
||||
launchPanic();
|
||||
assertTrue(_vaultManager.isVaultLoaded());
|
||||
_vaultManager.getVault();
|
||||
assertFalse(_vaultManager.isVaultFileLoaded());
|
||||
assertNull(_vaultManager.getVaultFileError());
|
||||
assertTrue(VaultRepository.fileExists(getApp()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPanicTriggerEnabled() {
|
||||
_prefs.setIsPanicTriggerEnabled(true);
|
||||
assertTrue(_prefs.isPanicTriggerEnabled());
|
||||
launchPanic();
|
||||
assertFalse(_vaultManager.isVaultLoaded());
|
||||
assertThrows(IllegalStateException.class, () -> _vaultManager.getVault());
|
||||
assertFalse(_vaultManager.isVaultFileLoaded());
|
||||
assertNull(_vaultManager.getVaultFileError());
|
||||
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<PanicResponderActivity> rule = new ActivityTestRule<>(PanicResponderActivity.class);
|
||||
rule.launchActivity(intent);
|
||||
}
|
||||
}
|
||||
@@ -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<ScreenCaptureProcessor> processors = new HashSet<>();
|
||||
processors.add(new BasicScreenCaptureProcessor());
|
||||
|
||||
try {
|
||||
capture.process(processors);
|
||||
} catch (IOException e2) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-15
@@ -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.Before;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../test/java/com/beemdevelopment/aegis/vectors/VaultEntries.java
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../test/resources/com/beemdevelopment/aegis/importers/aegis_encrypted.json
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../test/resources/com/beemdevelopment/aegis/importers/aegis_plain.json
|
||||
@@ -1,11 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.beemdevelopment.aegis">
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
|
||||
<!-- NOTE: Disabled for now. See issue: #1047
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
-->
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
@@ -17,13 +20,16 @@
|
||||
android:name=".AegisApplication"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupOnly="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:fullBackupContent="@xml/backup_rules_old"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:backupAgent=".AegisBackupAgent"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/${iconName}"
|
||||
android:label="Aegis"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Aegis.Launch"
|
||||
tools:replace="android:theme">
|
||||
tools:replace="android:theme"
|
||||
tools:targetApi="tiramisu">
|
||||
<activity android:name=".ui.TransferEntriesActivity"
|
||||
android:label="@string/title_activity_transfer" />
|
||||
<activity
|
||||
@@ -34,6 +40,7 @@
|
||||
android:label="@string/title_activity_import_entries" />
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:label="${title}">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -50,10 +57,10 @@
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
@@ -72,14 +79,12 @@
|
||||
<activity
|
||||
android:name=".ui.PreferencesActivity"
|
||||
android:label="@string/title_activity_preferences" />
|
||||
<activity
|
||||
android:name=".ui.SlotManagerActivity"
|
||||
android:label="@string/title_activity_manage_slots" />
|
||||
<activity
|
||||
android:name=".ui.GroupManagerActivity"
|
||||
android:label="@string/title_activity_manage_groups" />
|
||||
<activity
|
||||
android:name=".ui.PanicResponderActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:noHistory="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
@@ -90,7 +95,41 @@
|
||||
</activity>
|
||||
<activity android:name=".ui.ExitActivity" />
|
||||
|
||||
<!-- NOTE: Disabled for now. See issue: #1047
|
||||
<service android:name=".services.NotificationService" />
|
||||
-->
|
||||
|
||||
<service
|
||||
android:name=".services.LaunchAppTileService"
|
||||
android:label="@string/tile_open_vault"
|
||||
android:icon="@drawable/ic_aegis_quicksettings"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.service.quicksettings.ACTIVE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".services.LaunchScannerTileService"
|
||||
android:label="@string/tile_open_scanner"
|
||||
android:icon="@drawable/ic_aegis_quicksettings"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.service.quicksettings.ACTIVE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<receiver android:name=".receivers.VaultLockReceiver" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="${applicationId}.LOCK_VAULT" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
@@ -114,6 +153,8 @@
|
||||
<package android:name="com.azure.authenticator" />
|
||||
<package android:name="com.valvesoftware.android.steam.community" />
|
||||
<package android:name="com.authenticator.authservice2" />
|
||||
<package android:name="com.duosecurity.duomobile" />
|
||||
<package android:name="com.blizzard.bma" />
|
||||
</queries>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -31,6 +31,91 @@
|
||||
</head>
|
||||
<body>
|
||||
<div></div>
|
||||
<h3>Version 2.1.3</h3>
|
||||
<h4>New</h4>
|
||||
<ul>
|
||||
<li>Option to disable the backup reminder</li>
|
||||
<li>Improved group selection dropdown during vault export</li>
|
||||
<li>New translation: Hebrew</li>
|
||||
<li>Updated translations</li>
|
||||
</ul>
|
||||
<h4>Fixes</h4>
|
||||
<ul>
|
||||
<li>A crash could occur because a Toast was incorrectly created</li>
|
||||
</ul>
|
||||
<h3>Version 2.1.2</h3>
|
||||
<h4>Fixes</h4>
|
||||
<ul>
|
||||
<li>A crash could occur when changing an entry in such a way that it is filtered out from the entry list</li>
|
||||
</ul>
|
||||
<h3>Version 2.1.1</h3>
|
||||
<h4>New</h4>
|
||||
<ul>
|
||||
<li>An option to export the vault as an HTML file</li>
|
||||
<li>Support for importing from Battle.net Authenticator (root required)</li>
|
||||
<li>An option to hide entry icons</li>
|
||||
<li>An option to only include certain groups in an export</li>
|
||||
<li>Copying a token now takes a second tap if tap to reveal is enabled</li>
|
||||
<li>The ability to copy the URI when transferring entries through QR codes</li>
|
||||
<li>Updated translations</li>
|
||||
</ul>
|
||||
<h4>Fixes</h4>
|
||||
<ul>
|
||||
<li>The lock notification would remain after locking the vault in certain cases. For now, we've disabled the notification entirely.</li>
|
||||
<li>Making changes to an entry while having one or more favorited entries in the vault could result in buggy ordering</li>
|
||||
<li>Tapping to the reveal a token could increase the height of the entry in certain view modes on recent Android versions</li>
|
||||
<li>The backup reminder was unclear about when the last successful backup took place</li>
|
||||
<li>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.</li>
|
||||
<li>Importing from certain apps would cause a crash if an empty password was entered</li>
|
||||
<li>The andOTP importer could hang indefinitely if the user accidentally selected a non-andOTP file.</li>
|
||||
<li>Various other stability improvements</li>
|
||||
</ul>
|
||||
<h3>Version 2.1</h3>
|
||||
<h4>New</h4>
|
||||
<ul>
|
||||
<li>Support for mOTP</li>
|
||||
<li>Support for Yandex OTP (Experimental)</li>
|
||||
<li>An Adaptive Icon for Material You</li>
|
||||
<li>Ability to favorite certain entries and pin them to the top of the entry list</li>
|
||||
<li>Ability to filter by entries that are not in a group</li>
|
||||
<li>Ability to set a separate password that is used for encrypting backups and exports</li>
|
||||
<li>Support for predictive back gesture</li>
|
||||
<li>Improved overview of backup status in preferences</li>
|
||||
<li>Additional options for code digit grouping</li>
|
||||
<li>Support for importing from Duo</li>
|
||||
<li>Support for importing from Bitwarden</li>
|
||||
<li>Support for importing multiple QR code images in one go</li>
|
||||
<li>Support for scanning Google Authenticator export QR codes from image files</li>
|
||||
<li>Display some extra information in the dialog displayed when deleting an entry</li>
|
||||
<li>An option to export through Google Authenticator export QR code images</li>
|
||||
<li>An option to import an existing vault file from the first page in the intro</li>
|
||||
<li>An option to minimize the app after copying a token</li>
|
||||
<li>A count of the total number of entries is displayed at the bottom of the entry list</li>
|
||||
<li>A backup reminder is shown if changes were made to the vault, but no backup or export has been created yet since then</li>
|
||||
<li>A warning is shown after a plaintext export has been made</li>
|
||||
<li>An option to focus search immediately after the app starts</li>
|
||||
<li>Allow customization of the frequency of the password reminder</li>
|
||||
<li>Allow sharing text to Aegis in the format of a Google Authenticator URI to add as a new entry</li>
|
||||
<li>Always allow D2D (device-to-device) Android backups regardless of backup settings</li>
|
||||
<li>Mark clipboard data as sensitive when copying tokens so that Android will mask them in the UI</li>
|
||||
<li>Updated translations for almost all languages</li>
|
||||
<li>New languages: Asturian, Catalan, Galician</li>
|
||||
</ul>
|
||||
<h4>Fixes</h4>
|
||||
<ul>
|
||||
<li>Various reliability improvements for the QR code scanner</li>
|
||||
<li>The floating action button was glitchy when making small entry list scroll movements</li>
|
||||
<li>The vault unlocked notification was never shown and was still using the old app icon</li>
|
||||
<li>The automatically generated entry icon was broken if the entry name/issuer is a multi-codepoint character (certain emoji's, for example)</li>
|
||||
<li>The PIN keyboard was not disabled after enabling encryption</li>
|
||||
<li>The password prompt message was unclear when importing from a file</li>
|
||||
<li>The entry list was not sorted correctly if a change to an entry caused its location to change</li>
|
||||
<li>Quickly double-tapping on the copy button would cause a crash</li>
|
||||
<li>Importing an entry with an empty secret would cause a crash loop</li>
|
||||
<li>On certain devices, it was not possible to import icon packs because the .ZIP files would be grayed out</li>
|
||||
<li>An unclear error message was shown when trying to import from Steam and Google Authenticator</li>
|
||||
<li>Various other minor UI and stability improvements</li>
|
||||
</ul>
|
||||
<h3>Version 2.0.3</h3>
|
||||
<h4>New</h4>
|
||||
<ul>
|
||||
|
||||
@@ -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<LockListener> _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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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.mikepenz.iconics.Iconics;
|
||||
import com.mikepenz.material_design_iconic_typeface_library.MaterialDesignIconic;
|
||||
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();
|
||||
|
||||
Iconics.init(this);
|
||||
Iconics.registerFont(new MaterialDesignIconic());
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -9,22 +9,27 @@ import android.os.ParcelFileDescriptor;
|
||||
import android.util.Log;
|
||||
|
||||
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;
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@@ -34,36 +39,51 @@ public class AegisBackupAgent extends BackupAgent {
|
||||
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
|
||||
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));
|
||||
deleteBackupDir();
|
||||
throw e;
|
||||
}
|
||||
|
||||
// then call the original implementation so that fullBackupContent specified in AndroidManifest is read
|
||||
// We perform a catch of any Exception here to make sure we also
|
||||
// report any runtime exceptions, in addition to the expected IOExceptions.
|
||||
try {
|
||||
super.onFullBackup(data);
|
||||
} catch (IOException e) {
|
||||
fullBackup(data);
|
||||
_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;
|
||||
} finally {
|
||||
deleteBackupDir();
|
||||
}
|
||||
|
||||
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 (OutputStream outputStream = new FileOutputStream(vaultBackupFile)) {
|
||||
VaultFile vaultFile = VaultRepository.readVaultFile(this);
|
||||
byte[] bytes = vaultFile.exportable().toBytes();
|
||||
outputStream.write(bytes);
|
||||
} catch (VaultRepositoryException | IOException e) {
|
||||
deleteBackupDir();
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
// Then call the original implementation so that fullBackupContent specified in AndroidManifest is read
|
||||
try {
|
||||
super.onFullBackup(data);
|
||||
} finally {
|
||||
deleteBackupDir();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void onRestoreFile(ParcelFileDescriptor data, long size, File destination, int type, long mode, long mtime) throws IOException {
|
||||
Log.i(TAG, String.format("onRestoreFile() called: dest=%s", destination));
|
||||
@@ -72,7 +92,7 @@ public class AegisBackupAgent extends BackupAgent {
|
||||
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 +122,19 @@ public class AegisBackupAgent extends BackupAgent {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.beemdevelopment.aegis;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
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 VaultManager provideVaultManager(@ApplicationContext Context context) {
|
||||
return new VaultManager(context);
|
||||
}
|
||||
|
||||
@Provides
|
||||
public static Preferences providePreferences(@ApplicationContext Context context) {
|
||||
return new Preferences(context);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -7,21 +7,23 @@ import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.preference.PreferenceManager;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.beemdevelopment.aegis.util.JsonUtils;
|
||||
import com.beemdevelopment.aegis.util.TimeUtils;
|
||||
|
||||
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.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class Preferences {
|
||||
public static final int AUTO_LOCK_OFF = 1 << 0;
|
||||
@@ -63,39 +65,67 @@ public class Preferences {
|
||||
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 int getCodeGroupSize() {
|
||||
if (_prefs.getBoolean("pref_code_group_size", false)) {
|
||||
return 2;
|
||||
} else {
|
||||
return 3;
|
||||
}
|
||||
public boolean isIconVisible() {
|
||||
return _prefs.getBoolean("pref_show_icons", true);
|
||||
}
|
||||
|
||||
public CodeGrouping getCodeGroupSize() {
|
||||
String value = _prefs.getString("pref_code_group_size_string", "GROUPING_THREES");
|
||||
|
||||
return CodeGrouping.valueOf(value);
|
||||
}
|
||||
|
||||
public boolean isIntroDone() {
|
||||
@@ -181,13 +211,11 @@ public class Preferences {
|
||||
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;
|
||||
@@ -238,6 +266,7 @@ public class Preferences {
|
||||
|
||||
public void setIsAndroidBackupsEnabled(boolean enabled) {
|
||||
_prefs.edit().putBoolean("pref_android_backups", enabled).apply();
|
||||
setAndroidBackupResult(null);
|
||||
}
|
||||
|
||||
public boolean isBackupsEnabled() {
|
||||
@@ -246,6 +275,15 @@ public class Preferences {
|
||||
|
||||
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() {
|
||||
@@ -257,6 +295,44 @@ public class Preferences {
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean getFocusSearchEnabled() {
|
||||
return _prefs.getBoolean("pref_focus_search", false);
|
||||
}
|
||||
|
||||
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<Date> 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();
|
||||
}
|
||||
@@ -269,12 +345,91 @@ public class Preferences {
|
||||
_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 String getBackupsError() {
|
||||
return _prefs.getString("pref_backups_error", null);
|
||||
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 boolean isPlaintextBackupWarningDisabled() {
|
||||
return _prefs.getBoolean("pref_plaintext_backup_warning_disabled", false);
|
||||
}
|
||||
|
||||
public boolean isPinKeyboardEnabled() {
|
||||
@@ -293,6 +448,10 @@ public class Preferences {
|
||||
return _prefs.getBoolean("pref_copy_on_tap", false);
|
||||
}
|
||||
|
||||
public boolean isMinimizeOnCopyEnabled() {
|
||||
return _prefs.getBoolean("pref_minimize_on_copy", false);
|
||||
}
|
||||
|
||||
public void setGroupFilter(List<String> groupFilter) {
|
||||
JSONArray json = new JSONArray(groupFilter);
|
||||
_prefs.edit().putString("pref_group_filter", json.toString()).apply();
|
||||
@@ -308,11 +467,88 @@ public class Preferences {
|
||||
JSONArray json = new JSONArray(raw);
|
||||
List<String> filter = new ArrayList<>();
|
||||
for (int i = 0; i < json.length(); i++) {
|
||||
filter.add(json.getString(i));
|
||||
filter.add(json.isNull(i) ? null : json.optString(i));
|
||||
}
|
||||
return filter;
|
||||
} catch (JSONException e) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
public static class BackupResult {
|
||||
private final Date _time;
|
||||
private boolean _isBuiltIn;
|
||||
private final String _error;
|
||||
|
||||
public BackupResult(@Nullable Exception e) {
|
||||
this(new Date(), e == null ? null : e.toString());
|
||||
}
|
||||
|
||||
private BackupResult(Date time, @Nullable String error) {
|
||||
_time = time;
|
||||
_error = error;
|
||||
}
|
||||
|
||||
@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 String toJson() {
|
||||
JSONObject obj = new JSONObject();
|
||||
|
||||
try {
|
||||
obj.put("time", _time.getTime());
|
||||
obj.put("error", _error == null ? JSONObject.NULL : _error);
|
||||
} 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");
|
||||
return new BackupResult(new Date(time), error);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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;
|
||||
|
||||
@@ -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.
|
||||
* <p>
|
||||
* Scrypt was created by Colin Percival and is specified in <a
|
||||
* href="https://tools.ietf.org/html/rfc7914">RFC 7914 - The scrypt Password-Based Key Derivation Function</a>
|
||||
*/
|
||||
public class SCrypt
|
||||
{
|
||||
private SCrypt()
|
||||
{
|
||||
// not used.
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a key using the scrypt key derivation function.
|
||||
*
|
||||
* @param P the bytes of the pass phrase.
|
||||
* @param S the salt to use for this invocation.
|
||||
* @param N CPU/Memory cost parameter. Must be larger than 1, a power of 2 and less than
|
||||
* <code>2^(128 * r / 8)</code>.
|
||||
* @param r the block size, must be >= 1.
|
||||
* @param p Parallelization parameter. Must be a positive integer less than or equal to
|
||||
* <code>Integer.MAX_VALUE / (128 * r * 8)</code>.
|
||||
* @param dkLen the length of the key to generate.
|
||||
* @return the generated key.
|
||||
*/
|
||||
public static byte[] generate(byte[] P, byte[] S, int N, int r, int p, int dkLen)
|
||||
{
|
||||
if (P == null)
|
||||
{
|
||||
throw new IllegalArgumentException("Passphrase P must be provided.");
|
||||
}
|
||||
if (S == null)
|
||||
{
|
||||
throw new IllegalArgumentException("Salt S must be provided.");
|
||||
}
|
||||
if (N <= 1 || !isPowerOf2(N))
|
||||
{
|
||||
throw new IllegalArgumentException("Cost parameter N must be > 1 and a power of 2");
|
||||
}
|
||||
// Only value of r that cost (as an int) could be exceeded for is 1
|
||||
if (r == 1 && N >= 65536)
|
||||
{
|
||||
throw new IllegalArgumentException("Cost parameter N must be > 1 and < 65536.");
|
||||
}
|
||||
if (r < 1)
|
||||
{
|
||||
throw new IllegalArgumentException("Block size r must be >= 1.");
|
||||
}
|
||||
int maxParallel = Integer.MAX_VALUE / (128 * r * 8);
|
||||
if (p < 1 || p > maxParallel)
|
||||
{
|
||||
throw new IllegalArgumentException("Parallelisation parameter p must be >= 1 and <= " + maxParallel
|
||||
+ " (based on block size r of " + r + ")");
|
||||
}
|
||||
if (dkLen < 1)
|
||||
{
|
||||
throw new IllegalArgumentException("Generated key length dkLen must be >= 1.");
|
||||
}
|
||||
return MFcrypt(P, S, N, r, p, dkLen);
|
||||
}
|
||||
|
||||
private static byte[] MFcrypt(byte[] P, byte[] S, int N, int r, int p, int dkLen)
|
||||
{
|
||||
int MFLenBytes = r * 128;
|
||||
byte[] bytes = SingleIterationPBKDF2(P, S, p * MFLenBytes);
|
||||
|
||||
int[] B = null;
|
||||
|
||||
try
|
||||
{
|
||||
int BLen = bytes.length >>> 2;
|
||||
B = new int[BLen];
|
||||
|
||||
Pack.littleEndianToInt(bytes, 0, B);
|
||||
|
||||
/*
|
||||
* Chunk memory allocations; We choose 'd' so that there will be 2**d chunks, each not
|
||||
* larger than 32KiB, except that the minimum chunk size is 2 * r * 32.
|
||||
*/
|
||||
int d = 0, total = N * r;
|
||||
while ((N - d) > 2 && total > (1 << 10))
|
||||
{
|
||||
++d;
|
||||
total >>>= 1;
|
||||
}
|
||||
|
||||
int MFLenWords = MFLenBytes >>> 2;
|
||||
for (int BOff = 0; BOff < BLen; BOff += MFLenWords)
|
||||
{
|
||||
// TODO These can be done in parallel threads
|
||||
SMix(B, BOff, N, d, r);
|
||||
}
|
||||
|
||||
Pack.intToLittleEndian(B, bytes, 0);
|
||||
|
||||
return SingleIterationPBKDF2(P, bytes, dkLen);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(bytes);
|
||||
Clear(B);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] SingleIterationPBKDF2(byte[] P, byte[] S, int dkLen)
|
||||
{
|
||||
PBEParametersGenerator pGen = new PKCS5S2ParametersGenerator(new SHA256Digest());
|
||||
pGen.init(P, S, 1);
|
||||
KeyParameter key = (KeyParameter)pGen.generateDerivedMacParameters(dkLen * 8);
|
||||
return key.getKey();
|
||||
}
|
||||
|
||||
private static void SMix(int[] B, int BOff, int N, int d, int r)
|
||||
{
|
||||
int powN = Integers.numberOfTrailingZeros(N);
|
||||
int blocksPerChunk = N >>> d;
|
||||
int chunkCount = 1 << d, chunkMask = blocksPerChunk - 1, chunkPow = powN - d;
|
||||
|
||||
int BCount = r * 32;
|
||||
|
||||
int[] blockX1 = new int[16];
|
||||
int[] blockX2 = new int[16];
|
||||
int[] blockY = new int[BCount];
|
||||
|
||||
int[] X = new int[BCount];
|
||||
int[][] VV = new int[chunkCount][];
|
||||
|
||||
try
|
||||
{
|
||||
System.arraycopy(B, BOff, X, 0, BCount);
|
||||
|
||||
for (int c = 0; c < chunkCount; ++c)
|
||||
{
|
||||
int[] V = new int[blocksPerChunk * BCount];
|
||||
VV[c] = V;
|
||||
|
||||
int off = 0;
|
||||
for (int i = 0; i < blocksPerChunk; i += 2)
|
||||
{
|
||||
System.arraycopy(X, 0, V, off, BCount);
|
||||
off += BCount;
|
||||
BlockMix(X, blockX1, blockX2, blockY, r);
|
||||
System.arraycopy(blockY, 0, V, off, BCount);
|
||||
off += BCount;
|
||||
BlockMix(blockY, blockX1, blockX2, X, r);
|
||||
}
|
||||
}
|
||||
|
||||
int mask = N - 1;
|
||||
for (int i = 0; i < N; ++i)
|
||||
{
|
||||
int j = X[BCount - 16] & mask;
|
||||
int[] V = VV[j >>> chunkPow];
|
||||
int VOff = (j & chunkMask) * BCount;
|
||||
System.arraycopy(V, VOff, blockY, 0, BCount);
|
||||
Xor(blockY, X, 0, blockY);
|
||||
BlockMix(blockY, blockX1, blockX2, X, r);
|
||||
}
|
||||
|
||||
System.arraycopy(X, 0, B, BOff, BCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearAll(VV);
|
||||
ClearAll(new int[][]{X, blockX1, blockX2, blockY});
|
||||
}
|
||||
}
|
||||
|
||||
private static void BlockMix(int[] B, int[] X1, int[] X2, int[] Y, int r)
|
||||
{
|
||||
System.arraycopy(B, B.length - 16, X1, 0, 16);
|
||||
|
||||
int BOff = 0, YOff = 0, halfLen = B.length >>> 1;
|
||||
|
||||
for (int i = 2 * r; i > 0; --i)
|
||||
{
|
||||
Xor(X1, B, BOff, X2);
|
||||
|
||||
Salsa20Engine.salsaCore(8, X2, X1);
|
||||
System.arraycopy(X1, 0, Y, YOff, 16);
|
||||
|
||||
YOff = halfLen + BOff - YOff;
|
||||
BOff += 16;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Xor(int[] a, int[] b, int bOff, int[] output)
|
||||
{
|
||||
for (int i = output.length - 1; i >= 0; --i)
|
||||
{
|
||||
output[i] = a[i] ^ b[bOff + i];
|
||||
}
|
||||
}
|
||||
|
||||
private static void Clear(byte[] array)
|
||||
{
|
||||
if (array != null)
|
||||
{
|
||||
Arrays.fill(array, (byte)0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Clear(int[] array)
|
||||
{
|
||||
if (array != null)
|
||||
{
|
||||
Arrays.fill(array, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ClearAll(int[][] arrays)
|
||||
{
|
||||
for (int i = 0; i < arrays.length; ++i)
|
||||
{
|
||||
Clear(arrays[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// note: we know X is non-zero
|
||||
private static boolean isPowerOf2(int x)
|
||||
{
|
||||
return ((x & (x - 1)) == 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
Copyright (c) 2000-2021 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.beemdevelopment.aegis.crypto.bc;
|
||||
|
||||
/**
|
||||
* Implementation of Daniel J. Bernstein's Salsa20 stream cipher, Snuffle 2005
|
||||
*/
|
||||
public class Salsa20Engine {
|
||||
private Salsa20Engine()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public static void salsaCore(int rounds, int[] input, int[] x)
|
||||
{
|
||||
if (input.length != 16)
|
||||
{
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
if (x.length != 16)
|
||||
{
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
if (rounds % 2 != 0)
|
||||
{
|
||||
throw new IllegalArgumentException("Number of rounds must be even");
|
||||
}
|
||||
|
||||
int x00 = input[ 0];
|
||||
int x01 = input[ 1];
|
||||
int x02 = input[ 2];
|
||||
int x03 = input[ 3];
|
||||
int x04 = input[ 4];
|
||||
int x05 = input[ 5];
|
||||
int x06 = input[ 6];
|
||||
int x07 = input[ 7];
|
||||
int x08 = input[ 8];
|
||||
int x09 = input[ 9];
|
||||
int x10 = input[10];
|
||||
int x11 = input[11];
|
||||
int x12 = input[12];
|
||||
int x13 = input[13];
|
||||
int x14 = input[14];
|
||||
int x15 = input[15];
|
||||
|
||||
for (int i = rounds; i > 0; i -= 2)
|
||||
{
|
||||
x04 ^= Integer.rotateLeft(x00 + x12, 7);
|
||||
x08 ^= Integer.rotateLeft(x04 + x00, 9);
|
||||
x12 ^= Integer.rotateLeft(x08 + x04, 13);
|
||||
x00 ^= Integer.rotateLeft(x12 + x08, 18);
|
||||
x09 ^= Integer.rotateLeft(x05 + x01, 7);
|
||||
x13 ^= Integer.rotateLeft(x09 + x05, 9);
|
||||
x01 ^= Integer.rotateLeft(x13 + x09, 13);
|
||||
x05 ^= Integer.rotateLeft(x01 + x13, 18);
|
||||
x14 ^= Integer.rotateLeft(x10 + x06, 7);
|
||||
x02 ^= Integer.rotateLeft(x14 + x10, 9);
|
||||
x06 ^= Integer.rotateLeft(x02 + x14, 13);
|
||||
x10 ^= Integer.rotateLeft(x06 + x02, 18);
|
||||
x03 ^= Integer.rotateLeft(x15 + x11, 7);
|
||||
x07 ^= Integer.rotateLeft(x03 + x15, 9);
|
||||
x11 ^= Integer.rotateLeft(x07 + x03, 13);
|
||||
x15 ^= Integer.rotateLeft(x11 + x07, 18);
|
||||
|
||||
x01 ^= Integer.rotateLeft(x00 + x03, 7);
|
||||
x02 ^= Integer.rotateLeft(x01 + x00, 9);
|
||||
x03 ^= Integer.rotateLeft(x02 + x01, 13);
|
||||
x00 ^= Integer.rotateLeft(x03 + x02, 18);
|
||||
x06 ^= Integer.rotateLeft(x05 + x04, 7);
|
||||
x07 ^= Integer.rotateLeft(x06 + x05, 9);
|
||||
x04 ^= Integer.rotateLeft(x07 + x06, 13);
|
||||
x05 ^= Integer.rotateLeft(x04 + x07, 18);
|
||||
x11 ^= Integer.rotateLeft(x10 + x09, 7);
|
||||
x08 ^= Integer.rotateLeft(x11 + x10, 9);
|
||||
x09 ^= Integer.rotateLeft(x08 + x11, 13);
|
||||
x10 ^= Integer.rotateLeft(x09 + x08, 18);
|
||||
x12 ^= Integer.rotateLeft(x15 + x14, 7);
|
||||
x13 ^= Integer.rotateLeft(x12 + x15, 9);
|
||||
x14 ^= Integer.rotateLeft(x13 + x12, 13);
|
||||
x15 ^= Integer.rotateLeft(x14 + x13, 18);
|
||||
}
|
||||
|
||||
x[ 0] = x00 + input[ 0];
|
||||
x[ 1] = x01 + input[ 1];
|
||||
x[ 2] = x02 + input[ 2];
|
||||
x[ 3] = x03 + input[ 3];
|
||||
x[ 4] = x04 + input[ 4];
|
||||
x[ 5] = x05 + input[ 5];
|
||||
x[ 6] = x06 + input[ 6];
|
||||
x[ 7] = x07 + input[ 7];
|
||||
x[ 8] = x08 + input[ 8];
|
||||
x[ 9] = x09 + input[ 9];
|
||||
x[10] = x10 + input[10];
|
||||
x[11] = x11 + input[11];
|
||||
x[12] = x12 + input[12];
|
||||
x[13] = x13 + input[13];
|
||||
x[14] = x14 + input[14];
|
||||
x[15] = x15 + input[15];
|
||||
}
|
||||
}
|
||||
@@ -14,18 +14,7 @@ public class HOTP {
|
||||
|
||||
public static OTP generateOTP(byte[] secret, String algo, int digits, long counter)
|
||||
throws NoSuchAlgorithmException, InvalidKeyException {
|
||||
SecretKeySpec key = new SecretKeySpec(secret, "RAW");
|
||||
|
||||
// encode counter in big endian
|
||||
byte[] counterBytes = ByteBuffer.allocate(8)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
.putLong(counter)
|
||||
.array();
|
||||
|
||||
// calculate the hash of the counter
|
||||
Mac mac = Mac.getInstance(algo);
|
||||
mac.init(key);
|
||||
byte[] hash = mac.doFinal(counterBytes);
|
||||
byte[] hash = getHash(secret, algo, counter);
|
||||
|
||||
// truncate hash to get the HTOP value
|
||||
// http://tools.ietf.org/html/rfc4226#section-5.4
|
||||
@@ -37,4 +26,20 @@ public class HOTP {
|
||||
|
||||
return new OTP(otp, digits);
|
||||
}
|
||||
|
||||
public static byte[] getHash(byte[] secret, String algo, long counter)
|
||||
throws NoSuchAlgorithmException, InvalidKeyException {
|
||||
SecretKeySpec key = new SecretKeySpec(secret, "RAW");
|
||||
|
||||
// encode counter in big endian
|
||||
byte[] counterBytes = ByteBuffer.allocate(8)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
.putLong(counter)
|
||||
.array();
|
||||
|
||||
// calculate the hash of the counter
|
||||
Mac mac = Mac.getInstance(algo);
|
||||
mac.init(key);
|
||||
return mac.doFinal(counterBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.beemdevelopment.aegis.crypto.otp;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.beemdevelopment.aegis.encoding.Hex;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
public class MOTP {
|
||||
private final String _code;
|
||||
private final int _digits;
|
||||
|
||||
private MOTP(String code, int digits) {
|
||||
_code = code;
|
||||
_digits = digits;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static MOTP generateOTP(byte[] secret, String algo, int digits, int period, String pin)
|
||||
throws NoSuchAlgorithmException {
|
||||
|
||||
return generateOTP(secret, algo, digits, period, pin, System.currentTimeMillis() / 1000);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static MOTP generateOTP(byte[] secret, String algo, int digits, int period, String pin, long time)
|
||||
throws NoSuchAlgorithmException {
|
||||
|
||||
long timeBasedCounter = time / period;
|
||||
String secretAsString = Hex.encode(secret);
|
||||
String toDigest = timeBasedCounter + secretAsString + pin;
|
||||
String code = getDigest(algo, toDigest.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
return new MOTP(code, digits);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@NonNull
|
||||
protected static String getDigest(String algo, byte[] toDigest) throws NoSuchAlgorithmException {
|
||||
MessageDigest md = MessageDigest.getInstance(algo);
|
||||
byte[] digest = md.digest(toDigest);
|
||||
|
||||
return Hex.encode(digest);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return _code.substring(0, _digits);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import androidx.annotation.NonNull;
|
||||
public class OTP {
|
||||
private static final String STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY";
|
||||
|
||||
private int _code;
|
||||
private int _digits;
|
||||
private final int _code;
|
||||
private final int _digits;
|
||||
|
||||
public OTP(int code, int digits) {
|
||||
_code = code;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.beemdevelopment.aegis.crypto.otp;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Arrays;
|
||||
|
||||
public class YAOTP {
|
||||
private static final int EN_ALPHABET_LENGTH = 26;
|
||||
private final long _code;
|
||||
private final int _digits;
|
||||
|
||||
private YAOTP(long code, int digits) {
|
||||
_code = code;
|
||||
_digits = digits;
|
||||
}
|
||||
|
||||
public static YAOTP generateOTP(byte[] secret, String pin, int digits, String otpAlgo, long period)
|
||||
throws NoSuchAlgorithmException, InvalidKeyException, IOException {
|
||||
long seconds = System.currentTimeMillis() / 1000;
|
||||
return generateOTP(secret, pin, digits, otpAlgo, seconds, period);
|
||||
}
|
||||
|
||||
public static YAOTP generateOTP(byte[] secret, String pin, int digits, String otpAlgo, long seconds, long period)
|
||||
throws NoSuchAlgorithmException, InvalidKeyException, IOException {
|
||||
byte[] pinWithHash;
|
||||
byte[] pinBytes = pin.getBytes(StandardCharsets.UTF_8);
|
||||
try (ByteArrayOutputStream stream = new ByteArrayOutputStream(pinBytes.length + secret.length)) {
|
||||
stream.write(pinBytes);
|
||||
stream.write(secret);
|
||||
pinWithHash = stream.toByteArray();
|
||||
}
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] keyHash = md.digest(pinWithHash);
|
||||
if (keyHash[0] == 0) {
|
||||
keyHash = Arrays.copyOfRange(keyHash, 1, keyHash.length);
|
||||
}
|
||||
|
||||
long counter = (long) Math.floor((double) seconds / period);
|
||||
byte[] periodHash = HOTP.getHash(keyHash, otpAlgo, counter);
|
||||
int offset = periodHash[periodHash.length - 1] & 0xf;
|
||||
periodHash[offset] &= 0x7f;
|
||||
long otp = ByteBuffer.wrap(periodHash)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
.getLong(offset);
|
||||
|
||||
return new YAOTP(otp, digits);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
long code = _code % (long) Math.pow(EN_ALPHABET_LENGTH, _digits);
|
||||
char[] chars = new char[_digits];
|
||||
|
||||
for (int i = _digits - 1; i >= 0; i--) {
|
||||
chars[i] = (char) ('a' + (code % EN_ALPHABET_LENGTH));
|
||||
code /= EN_ALPHABET_LENGTH;
|
||||
}
|
||||
|
||||
return new String(chars);
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package com.beemdevelopment.aegis.crypto.pins;
|
||||
|
||||
import info.guardianproject.trustedintents.ApkSignaturePin;
|
||||
|
||||
public final class GuardianProjectFDroidRSA4096 extends ApkSignaturePin {
|
||||
|
||||
public GuardianProjectFDroidRSA4096() {
|
||||
fingerprints = new String[]{
|
||||
"927f7e38b6acbecd84e02dace33efa9a7a2f0979750f28f585688ee38b3a4e28",
|
||||
};
|
||||
certificates = new byte[][]{
|
||||
{48, -126, 3, 95, 48, -126, 2, 71, -96, 3, 2, 1, 2, 2, 4, 28, -30, 107, -102, 48, 13, 6, 9, 42, -122, 72, -122, -9, 13, 1, 1, 11, 5, 0, 48, 96, 49, 11, 48, 9, 6, 3, 85, 4, 6, 19, 2, 85, 75, 49, 12, 48, 10, 6, 3, 85, 4, 8, 19, 3, 79, 82, 71, 49, 12, 48, 10, 6, 3, 85, 4, 7, 19, 3, 79, 82, 71, 49, 19, 48, 17, 6, 3, 85, 4, 10, 19, 10, 102, 100, 114, 111, 105, 100, 46, 111, 114, 103, 49, 15, 48, 13, 6, 3, 85, 4, 11, 19, 6, 70, 68, 114, 111, 105, 100, 49, 15, 48, 13, 6, 3, 85, 4, 3, 19, 6, 70, 68, 114, 111, 105, 100, 48, 30, 23, 13, 49, 55, 49, 50, 48, 55, 49, 55, 51, 48, 52, 50, 90, 23, 13, 52, 53, 48, 52, 50, 52, 49, 55, 51, 48, 52, 50, 90, 48, 96, 49, 11, 48, 9, 6, 3, 85, 4, 6, 19, 2, 85, 75, 49, 12, 48, 10, 6, 3, 85, 4, 8, 19, 3, 79, 82, 71, 49, 12, 48, 10, 6, 3, 85, 4, 7, 19, 3, 79, 82, 71, 49, 19, 48, 17, 6, 3, 85, 4, 10, 19, 10, 102, 100, 114, 111, 105, 100, 46, 111, 114, 103, 49, 15, 48, 13, 6, 3, 85, 4, 11, 19, 6, 70, 68, 114, 111, 105, 100, 49, 15, 48, 13, 6, 3, 85, 4, 3, 19, 6, 70, 68, 114, 111, 105, 100, 48, -126, 1, 34, 48, 13, 6, 9, 42, -122, 72, -122, -9, 13, 1, 1, 1, 5, 0, 3, -126, 1, 15, 0, 48, -126, 1, 10, 2, -126, 1, 1, 0, -107, -115, -106, 1, -26, 72, -105, -99, 62, 3, -55, 34, 99, -112, -68, -20, -115, 31, 34, 118, -50, 12, -32, -59, 74, -58, -37, -87, 21, 105, 36, -82, 13, -51, 66, 4, 55, -111, 13, -46, -7, -69, -15, 36, 118, -7, 101, -86, 123, -83, -103, 110, 116, -54, 112, 46, 12, 96, -76, -48, -70, -33, -81, 52, 59, 73, 107, -126, -72, -25, 32, 93, 29, -20, 5, -41, -27, 123, -9, 104, -31, -59, -1, -83, -93, 99, 85, -116, -62, -55, 18, -63, 6, -51, -110, 33, 9, 7, -49, 102, -20, -122, -124, -68, 93, -102, 31, 48, 86, 96, -99, 105, -52, 95, 12, 57, 99, 12, -24, 70, 40, -99, -20, -21, -85, -70, -105, 95, 117, -31, 126, -126, -39, 46, -62, 59, -23, -74, 108, -12, -56, -40, -96, 79, -37, -82, 1, 99, -104, 48, -60, 92, 14, 109, 127, -22, 31, 115, -27, 108, 9, 92, 118, -45, 103, 117, 57, -50, -82, 114, -113, 68, -82, 87, 96, 111, 72, 65, -63, 12, 31, -34, -31, -55, -101, 101, 101, 59, 73, -119, -122, 82, 28, 47, -108, -85, 59, 46, 89, -93, -1, 9, -11, -51, 63, -44, 109, -76, -103, -26, -49, -80, 6, 52, -27, 73, -104, 40, 2, -101, -124, 60, -52, -105, -70, -24, -62, 88, 38, 53, -99, -92, 31, 119, 26, 79, 60, -124, 25, -115, -89, -115, -109, 0, 6, 122, -78, 116, 82, 3, 39, -67, 45, -43, 17, -39, 2, 3, 1, 0, 1, -93, 33, 48, 31, 48, 29, 6, 3, 85, 29, 14, 4, 22, 4, 20, 63, 109, -42, -109, 25, 22, 7, -37, -22, -41, -38, 58, -56, 2, -68, -38, -22, 65, -28, -60, 48, 13, 6, 9, 42, -122, 72, -122, -9, 13, 1, 1, 11, 5, 0, 3, -126, 1, 1, 0, 94, 17, 31, 36, 85, -11, 85, 44, 19, -80, -20, -92, -118, 93, 40, 45, 96, 31, -3, -37, -110, -96, 102, 81, 61, -74, -125, -117, -112, 58, -47, 17, 78, -18, 111, -116, 26, -91, 73, 100, 84, -99, 21, 87, 73, -106, 108, -51, -125, -21, 119, -88, -78, 2, 82, -109, -64, -9, -86, -112, -115, 66, -86, 46, 71, 107, -65, 96, -102, 47, 35, -45, -126, 33, 34, 121, -25, -85, -121, -56, -42, 22, -1, -95, -86, 81, 100, -70, 113, 104, -73, 22, -19, 79, -19, 52, 62, 42, 76, -112, 94, -34, 42, -57, -75, -90, -58, 118, 127, -106, -39, 108, -56, -79, 103, -33, 22, 3, 47, 103, -76, -81, 53, -22, -44, -26, -102, 63, -99, 39, 38, -108, 75, 33, 10, 25, -110, -125, -115, 114, -69, 73, -112, 36, 74, 77, -82, -44, 29, -123, -8, -117, 71, -105, 15, -109, 51, 22, 4, 80, 1, 43, 118, 121, -113, -70, 83, -56, 82, -110, 4, -63, 16, -57, 126, -70, 81, 73, 61, 2, -61, 24, -14, -10, 4, -21, 90, 24, 66, 41, -57, -60, -113, -18, -54, -1, 103, -75, 32, -64, 67, 103, 109, -79, -12, -113, -27, 114, 89, 116, 115, -13, -123, -70, 61, -41, -46, -118, 29, -105, -97, -75, 39, -51, 60, 88, 125, 55, -46, -95, 52, 57, 52, -115, 80, 44, 109, 119, -116, -62, -77, -74, -88, 41, 57, -65, -71, -115, -67, 23, 66, -21, 56, 51, -91, 109},};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.beemdevelopment.aegis.encoding;
|
||||
|
||||
import com.google.common.io.BaseEncoding;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
|
||||
public class Base32 {
|
||||
@@ -20,4 +21,9 @@ public class Base32 {
|
||||
public static String encode(byte[] data) {
|
||||
return BaseEncoding.base32().omitPadding().encode(data);
|
||||
}
|
||||
|
||||
public static String encode(String s) {
|
||||
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
|
||||
return encode(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,8 @@ public class EncodingException extends IOException {
|
||||
public EncodingException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public EncodingException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ public class FabScrollHelper {
|
||||
}
|
||||
|
||||
public void onScroll(int dx, int dy) {
|
||||
if (dy > 0 && _fabMenu.getVisibility() == View.VISIBLE && !_isAnimating) {
|
||||
if (dy > 2 && _fabMenu.getVisibility() == View.VISIBLE && !_isAnimating) {
|
||||
setVisible(false);
|
||||
} else if (dy < 0 && _fabMenu.getVisibility() != View.VISIBLE && !_isAnimating) {
|
||||
} else if (dy < -2 && _fabMenu.getVisibility() != View.VISIBLE && !_isAnimating) {
|
||||
setVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.beemdevelopment.aegis.helpers;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import static android.graphics.ImageFormat.YUV_420_888;
|
||||
|
||||
import android.util.Log;
|
||||
import android.util.Size;
|
||||
|
||||
@@ -9,21 +9,12 @@ import androidx.annotation.NonNull;
|
||||
import androidx.camera.core.ImageAnalysis;
|
||||
import androidx.camera.core.ImageProxy;
|
||||
|
||||
import com.google.zxing.BinaryBitmap;
|
||||
import com.google.zxing.ChecksumException;
|
||||
import com.google.zxing.FormatException;
|
||||
import com.google.zxing.NotFoundException;
|
||||
import com.google.zxing.PlanarYUVLuminanceSource;
|
||||
import com.google.zxing.Result;
|
||||
import com.google.zxing.common.HybridBinarizer;
|
||||
import com.google.zxing.qrcode.QRCodeReader;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
import static android.graphics.ImageFormat.YUV_420_888;
|
||||
import static android.graphics.ImageFormat.YUV_422_888;
|
||||
import static android.graphics.ImageFormat.YUV_444_888;
|
||||
|
||||
public class QrCodeAnalyzer implements ImageAnalysis.Analyzer {
|
||||
private static final String TAG = QrCodeAnalyzer.class.getSimpleName();
|
||||
public static final Size RESOLUTION = new Size(1200, 1600);
|
||||
@@ -37,56 +28,39 @@ public class QrCodeAnalyzer implements ImageAnalysis.Analyzer {
|
||||
@Override
|
||||
public void analyze(@NonNull ImageProxy image) {
|
||||
int format = image.getFormat();
|
||||
if (format != YUV_420_888 && format != YUV_422_888 && format != YUV_444_888) {
|
||||
Log.e(TAG, String.format("Expected YUV format, got %d instead", format));
|
||||
if (format != YUV_420_888) {
|
||||
Log.e(TAG, String.format("Unexpected YUV image format: %d", format));
|
||||
image.close();
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] data = getLuminancePlaneData(image);
|
||||
PlanarYUVLuminanceSource source = new PlanarYUVLuminanceSource(
|
||||
data, image.getWidth(), image.getHeight(), 0, 0, image.getWidth(), image.getHeight(), false
|
||||
);
|
||||
|
||||
QRCodeReader reader = new QRCodeReader();
|
||||
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
|
||||
try {
|
||||
Result result = reader.decode(bitmap);
|
||||
if (_listener != null) {
|
||||
new Handler(Looper.getMainLooper()).post(() -> _listener.onQrCodeDetected(result));
|
||||
}
|
||||
} catch (ChecksumException | FormatException | NotFoundException ignored) {
|
||||
|
||||
} finally {
|
||||
image.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] getLuminancePlaneData(ImageProxy image) {
|
||||
ImageProxy.PlaneProxy plane = image.getPlanes()[0];
|
||||
ByteBuffer buf = plane.getBuffer();
|
||||
byte[] data = new byte[buf.remaining()];
|
||||
buf.get(data);
|
||||
buf.rewind();
|
||||
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
int rowStride = plane.getRowStride();
|
||||
int pixelStride = plane.getPixelStride();
|
||||
PlanarYUVLuminanceSource source = new PlanarYUVLuminanceSource(
|
||||
data,
|
||||
plane.getRowStride(),
|
||||
image.getHeight(),
|
||||
0,
|
||||
0,
|
||||
image.getWidth(),
|
||||
image.getHeight(),
|
||||
false
|
||||
);
|
||||
|
||||
if (width != rowStride || pixelStride != 1) {
|
||||
// remove padding from the Y plane data
|
||||
byte[] cleanData = new byte[width * height];
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
cleanData[y * width + x] = data[y * rowStride + x * pixelStride];
|
||||
}
|
||||
try {
|
||||
Result result = QrCodeHelper.decodeFromSource(source);
|
||||
if (_listener != null) {
|
||||
_listener.onQrCodeDetected(result);
|
||||
}
|
||||
} catch (NotFoundException ignored) {
|
||||
|
||||
return cleanData;
|
||||
} finally {
|
||||
image.close();
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
public interface Listener {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.beemdevelopment.aegis.helpers;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.Color;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.BinaryBitmap;
|
||||
import com.google.zxing.DecodeHintType;
|
||||
import com.google.zxing.LuminanceSource;
|
||||
import com.google.zxing.MultiFormatReader;
|
||||
import com.google.zxing.NotFoundException;
|
||||
import com.google.zxing.RGBLuminanceSource;
|
||||
import com.google.zxing.Result;
|
||||
import com.google.zxing.WriterException;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import com.google.zxing.common.HybridBinarizer;
|
||||
import com.google.zxing.qrcode.QRCodeWriter;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class QrCodeHelper {
|
||||
private QrCodeHelper() {
|
||||
|
||||
}
|
||||
|
||||
public static Result decodeFromSource(LuminanceSource source) throws NotFoundException {
|
||||
Map<DecodeHintType, Object> hints = new HashMap<>();
|
||||
hints.put(DecodeHintType.POSSIBLE_FORMATS, Collections.singletonList(BarcodeFormat.QR_CODE));
|
||||
hints.put(DecodeHintType.ALSO_INVERTED, true);
|
||||
|
||||
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
|
||||
MultiFormatReader reader = new MultiFormatReader();
|
||||
return reader.decode(bitmap, hints);
|
||||
}
|
||||
|
||||
public static Result decodeFromStream(InputStream inStream) throws DecodeError {
|
||||
BitmapFactory.Options bmOptions = new BitmapFactory.Options();
|
||||
Bitmap bitmap = BitmapFactory.decodeStream(inStream, null, bmOptions);
|
||||
if (bitmap == null) {
|
||||
throw new DecodeError("Unable to decode stream to bitmap");
|
||||
}
|
||||
|
||||
// If ZXing is not able to decode the image on the first try, we try a couple of
|
||||
// more times with smaller versions of the same image.
|
||||
for (int i = 0; i <= 2; i++) {
|
||||
if (i != 0) {
|
||||
bitmap = BitmapHelper.resize(bitmap, bitmap.getWidth() / (i * 2), bitmap.getHeight() / (i * 2));
|
||||
}
|
||||
|
||||
try {
|
||||
int[] pixels = new int[bitmap.getWidth() * bitmap.getHeight()];
|
||||
bitmap.getPixels(pixels, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight());
|
||||
|
||||
LuminanceSource source = new RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), pixels);
|
||||
return decodeFromSource(source);
|
||||
} catch (NotFoundException ignored) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
throw new DecodeError(NotFoundException.getNotFoundInstance());
|
||||
}
|
||||
|
||||
public static Bitmap encodeToBitmap(String data, int width, int height, @ColorInt int backgroundColor) throws WriterException {
|
||||
QRCodeWriter writer = new QRCodeWriter();
|
||||
BitMatrix bitMatrix = writer.encode(data, BarcodeFormat.QR_CODE, width, height);
|
||||
|
||||
int[] pixels = new int[width * height];
|
||||
for (int y = 0; y < height; y++) {
|
||||
int offset = y * width;
|
||||
for (int x = 0; x < width; x++) {
|
||||
pixels[offset + x] = bitMatrix.get(x, y) ? Color.BLACK : backgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
||||
bitmap.setPixels(pixels, 0, width, 0, 0, width, height);
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
public static class DecodeError extends Exception {
|
||||
public DecodeError(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public DecodeError(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.beemdevelopment.aegis.helpers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
public class SafHelper {
|
||||
private SafHelper() {
|
||||
|
||||
}
|
||||
|
||||
public static String getFileName(Context context, Uri uri) {
|
||||
if (uri.getScheme() != null && uri.getScheme().equals("content")) {
|
||||
try (Cursor cursor = context.getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int i = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (i != -1) {
|
||||
return cursor.getString(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uri.getLastPathSegment();
|
||||
}
|
||||
|
||||
public static String getMimeType(Context context, Uri uri) {
|
||||
DocumentFile file = DocumentFile.fromSingleUri(context, uri);
|
||||
if (file != null) {
|
||||
String fileType = file.getType();
|
||||
if (fileType != null) {
|
||||
return fileType;
|
||||
}
|
||||
|
||||
String ext = MimeTypeMap.getFileExtensionFromUrl(uri.toString());
|
||||
if (ext != null) {
|
||||
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.beemdevelopment.aegis.helpers;
|
||||
|
||||
import android.view.animation.Animation;
|
||||
|
||||
public class SimpleAnimationEndListener implements Animation.AnimationListener {
|
||||
private final Listener _listener;
|
||||
|
||||
public SimpleAnimationEndListener(Listener listener) {
|
||||
_listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationStart(Animation animation) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationEnd(Animation animation) {
|
||||
if (_listener != null) {
|
||||
_listener.onAnimationEnd(animation);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationRepeat(Animation animation) {
|
||||
|
||||
}
|
||||
|
||||
public interface Listener {
|
||||
void onAnimationEnd(Animation animation);
|
||||
}
|
||||
}
|
||||
+32
-13
@@ -1,20 +1,23 @@
|
||||
package com.beemdevelopment.aegis.helpers;
|
||||
|
||||
import static androidx.recyclerview.widget.RecyclerView.NO_POSITION;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
import com.beemdevelopment.aegis.ui.views.EntryAdapter;
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
|
||||
public class SimpleItemTouchHelperCallback extends ItemTouchHelper.Callback {
|
||||
|
||||
private VaultEntry _selectedEntry;
|
||||
|
||||
private final ItemTouchHelperAdapter _adapter;
|
||||
private final EntryAdapter _adapter;
|
||||
private boolean _positionChanged = false;
|
||||
private boolean _isLongPressDragEnabled = true;
|
||||
|
||||
public SimpleItemTouchHelperCallback(ItemTouchHelperAdapter adapter) {
|
||||
public SimpleItemTouchHelperCallback(EntryAdapter adapter) {
|
||||
_adapter = adapter;
|
||||
}
|
||||
|
||||
@@ -28,7 +31,14 @@ public class SimpleItemTouchHelperCallback extends ItemTouchHelper.Callback {
|
||||
}
|
||||
|
||||
public void setSelectedEntry(VaultEntry entry) {
|
||||
_selectedEntry = entry;
|
||||
if (entry == null) {
|
||||
_selectedEntry = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry.isFavorite()) {
|
||||
_selectedEntry = entry;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -37,14 +47,22 @@ public class SimpleItemTouchHelperCallback extends ItemTouchHelper.Callback {
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
|
||||
int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
|
||||
int swipeFlags = 0;
|
||||
|
||||
public int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
// It's not clear when this can happen, but sometimes the ViewHolder
|
||||
// that's passed to this function has a position of -1, leading
|
||||
// to a crash down the line.
|
||||
int position = viewHolder.getAdapterPosition();
|
||||
EntryAdapter adapter = (EntryAdapter)recyclerView.getAdapter();
|
||||
if (adapter.getEntryAt(position) != _selectedEntry || !isLongPressDragEnabled())
|
||||
{
|
||||
if (position == NO_POSITION) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int swipeFlags = 0;
|
||||
int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
|
||||
|
||||
EntryAdapter adapter = (EntryAdapter) recyclerView.getAdapter();
|
||||
if (adapter.isPositionFooter(position)
|
||||
|| adapter.getEntryAt(position) != _selectedEntry
|
||||
|| !isLongPressDragEnabled()) {
|
||||
dragFlags = 0;
|
||||
}
|
||||
|
||||
@@ -54,6 +72,9 @@ public class SimpleItemTouchHelperCallback extends ItemTouchHelper.Callback {
|
||||
@Override
|
||||
public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder,
|
||||
RecyclerView.ViewHolder target) {
|
||||
if (target.getAdapterPosition() < _adapter.getShownFavoritesCount()){
|
||||
return false;
|
||||
}
|
||||
_adapter.onItemMove(viewHolder.getAdapterPosition(), target.getAdapterPosition());
|
||||
_positionChanged = true;
|
||||
return true;
|
||||
@@ -73,6 +94,4 @@ public class SimpleItemTouchHelperCallback extends ItemTouchHelper.Callback {
|
||||
_positionChanged = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.beemdevelopment.aegis.helpers;
|
||||
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
|
||||
public final class SimpleTextWatcher implements TextWatcher {
|
||||
private final Listener _listener;
|
||||
|
||||
public SimpleTextWatcher(Listener listener) {
|
||||
_listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
if (_listener != null) {
|
||||
_listener.afterTextChanged(s);
|
||||
}
|
||||
}
|
||||
|
||||
public interface Listener {
|
||||
void afterTextChanged(Editable s);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.view.View;
|
||||
import com.amulyakhare.textdrawable.TextDrawable;
|
||||
import com.amulyakhare.textdrawable.util.ColorGenerator;
|
||||
|
||||
import java.text.BreakIterator;
|
||||
import java.util.Arrays;
|
||||
|
||||
public class TextDrawableHelper {
|
||||
@@ -48,6 +49,18 @@ public class TextDrawableHelper {
|
||||
.width(view.getLayoutParams().width)
|
||||
.height(view.getLayoutParams().height)
|
||||
.endConfig()
|
||||
.buildRound(text.substring(0, 1).toUpperCase(), color);
|
||||
.buildRound(getFirstGrapheme(text).toUpperCase(), color);
|
||||
}
|
||||
|
||||
private static String getFirstGrapheme(String text) {
|
||||
BreakIterator iter = BreakIterator.getCharacterInstance();
|
||||
iter.setText(text);
|
||||
|
||||
int start = iter.first(), end = iter.next();
|
||||
if (end == BreakIterator.DONE) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return text.substring(start, end);
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.beemdevelopment.aegis.helpers.comparators;
|
||||
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
public class FavoriteComparator implements Comparator<VaultEntry> {
|
||||
@Override
|
||||
public int compare(VaultEntry a, VaultEntry b) {
|
||||
return -1 * Boolean.compare(a.isFavorite(), b.isFavorite());
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,6 @@ public class IconPack {
|
||||
return IconType.fromFilename(_relFilename);
|
||||
}
|
||||
|
||||
@SuppressWarnings("UnstableApiUsage")
|
||||
public String getName() {
|
||||
return Files.getNameWithoutExtension(new File(_relFilename).getName());
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ public enum IconType {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("UnstableApiUsage")
|
||||
public static IconType fromFilename(String filename) {
|
||||
switch (Files.getFileExtension(filename).toLowerCase(Locale.ROOT)) {
|
||||
case "svg":
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.beemdevelopment.aegis.importers;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.helpers.ContextHelper;
|
||||
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
|
||||
import com.beemdevelopment.aegis.ui.tasks.PasswordSlotDecryptTask;
|
||||
@@ -71,7 +73,7 @@ public class AegisImporter extends DatabaseImporter {
|
||||
throw new DatabaseImporterException(e);
|
||||
}
|
||||
|
||||
return new DecryptedState(obj);
|
||||
return new DecryptedState(obj, creds);
|
||||
}
|
||||
|
||||
public State decrypt(char[] password) throws DatabaseImporterException {
|
||||
@@ -83,7 +85,7 @@ public class AegisImporter extends DatabaseImporter {
|
||||
|
||||
@Override
|
||||
public void decrypt(Context context, DecryptListener listener) {
|
||||
Dialogs.showPasswordInputDialog(context, (Dialogs.TextInputListener) password -> {
|
||||
Dialogs.showPasswordInputDialog(context, R.string.enter_password_aegis_title, 0, (Dialogs.TextInputListener) password -> {
|
||||
List<PasswordSlot> slots = getSlots().findAll(PasswordSlot.class);
|
||||
PasswordSlotDecryptTask.Params params = new PasswordSlotDecryptTask.Params(slots, password);
|
||||
PasswordSlotDecryptTask task = new PasswordSlotDecryptTask(context, result -> {
|
||||
@@ -108,10 +110,21 @@ public class AegisImporter extends DatabaseImporter {
|
||||
|
||||
public static class DecryptedState extends State {
|
||||
private JSONObject _obj;
|
||||
private VaultFileCredentials _creds;
|
||||
|
||||
private DecryptedState(JSONObject obj) {
|
||||
this(obj, null);
|
||||
}
|
||||
|
||||
private DecryptedState(JSONObject obj, VaultFileCredentials creds) {
|
||||
super(false);
|
||||
_obj = obj;
|
||||
_creds = creds;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public VaultFileCredentials getCredentials() {
|
||||
return _creds;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -123,6 +123,12 @@ public class AndOtpImporter extends DatabaseImporter {
|
||||
if (iterations < 1) {
|
||||
throw new DatabaseImporterException(String.format("Invalid number of iterations for PBKDF: %d", iterations));
|
||||
}
|
||||
// If number of iterations is this high, it's probably not an andOTP file, so
|
||||
// abort early in order to prevent having to wait for an extremely long key derivation
|
||||
// process, only to find out that the user picked the wrong file
|
||||
if (iterations > 10_000_000L) {
|
||||
throw new DatabaseImporterException(String.format("Unexpectedly high number of iterations: %d", iterations));
|
||||
}
|
||||
|
||||
byte[] salt = Arrays.copyOfRange(_data, INT_SIZE, INT_SIZE + SALT_SIZE);
|
||||
return new KeyDerivationParams(password, salt, iterations);
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
|
||||
import com.beemdevelopment.aegis.util.JsonUtils;
|
||||
import com.beemdevelopment.aegis.util.PreferenceParser;
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
import com.topjohnwu.superuser.Shell;
|
||||
import com.topjohnwu.superuser.io.SuFile;
|
||||
import com.topjohnwu.superuser.io.SuFileInputStream;
|
||||
|
||||
@@ -67,8 +68,9 @@ public class AuthyImporter extends DatabaseImporter {
|
||||
}
|
||||
|
||||
@Override
|
||||
public State readFromApp() throws PackageManager.NameNotFoundException, DatabaseImporterException {
|
||||
public State readFromApp(Shell shell) throws PackageManager.NameNotFoundException, DatabaseImporterException {
|
||||
SuFile path = getAppPath();
|
||||
path.setShell(shell);
|
||||
|
||||
JSONArray array;
|
||||
JSONArray authyArray;
|
||||
@@ -129,7 +131,7 @@ public class AuthyImporter extends DatabaseImporter {
|
||||
}
|
||||
|
||||
private JSONArray readFile(SuFile file, String key) throws IOException, XmlPullParserException {
|
||||
try (SuFileInputStream inStream = new SuFileInputStream(file)) {
|
||||
try (InputStream inStream = SuFileInputStream.open(file)) {
|
||||
XmlPullParser parser = Xml.newPullParser();
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false);
|
||||
parser.setInput(inStream, null);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.beemdevelopment.aegis.importers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.util.Xml;
|
||||
|
||||
import com.beemdevelopment.aegis.encoding.EncodingException;
|
||||
import com.beemdevelopment.aegis.encoding.Hex;
|
||||
import com.beemdevelopment.aegis.otp.OtpInfo;
|
||||
import com.beemdevelopment.aegis.otp.OtpInfoException;
|
||||
import com.beemdevelopment.aegis.otp.TotpInfo;
|
||||
import com.beemdevelopment.aegis.util.PreferenceParser;
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
import com.topjohnwu.superuser.io.SuFile;
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser;
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class BattleNetImporter extends DatabaseImporter {
|
||||
private static final String _pkgName = "com.blizzard.bma";
|
||||
private static final String _subPath = "shared_prefs/com.blizzard.bma.AUTH_STORE.xml";
|
||||
|
||||
private static final byte[] _key;
|
||||
|
||||
public BattleNetImporter(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
static {
|
||||
try {
|
||||
_key = Hex.decode("398e27fc50276a656065b0e525f4c06c04c61075286b8e7aeda59da9813b5dd6c80d2fb38068773fa59ba47c17ca6c6479015c1d5b8b8f6b9a");
|
||||
} catch (EncodingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected SuFile getAppPath() throws DatabaseImporterException, PackageManager.NameNotFoundException {
|
||||
return getAppPath(_pkgName, _subPath);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected State read(InputStream stream, boolean isInternal) throws DatabaseImporterException {
|
||||
try {
|
||||
XmlPullParser parser = Xml.newPullParser();
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false);
|
||||
parser.setInput(stream, null);
|
||||
parser.nextTag();
|
||||
|
||||
List<String> entries = new ArrayList<>();
|
||||
for (PreferenceParser.XmlEntry entry : PreferenceParser.parse(parser)) {
|
||||
if (entry.Name.equals("com.blizzard.bma.AUTH_STORE.HASH")) {
|
||||
entries.add(entry.Value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new BattleNetImporter.State(entries);
|
||||
} catch (XmlPullParserException | IOException e) {
|
||||
throw new DatabaseImporterException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static class State extends DatabaseImporter.State {
|
||||
private final List<String> _entries;
|
||||
|
||||
public State(List<String> entries) {
|
||||
super(false);
|
||||
_entries = entries;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result convert() {
|
||||
Result result = new Result();
|
||||
|
||||
for (String str : _entries) {
|
||||
try {
|
||||
VaultEntry entry = convertEntry(str);
|
||||
result.addEntry(entry);
|
||||
} catch (DatabaseImporterEntryException e) {
|
||||
result.addError(e);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static VaultEntry convertEntry(String hashString) throws DatabaseImporterEntryException {
|
||||
try {
|
||||
byte[] hash = Hex.decode(hashString);
|
||||
if (hash.length != _key.length) {
|
||||
throw new DatabaseImporterEntryException(String.format("Unexpected hash length: %d", hash.length), hashString);
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < hash.length; i++) {
|
||||
char c = (char) (hash[i] ^ _key[i]);
|
||||
sb.append(c);
|
||||
}
|
||||
|
||||
final int secretLen = 40;
|
||||
byte[] secret = Hex.decode(sb.substring(0, secretLen));
|
||||
String serial = sb.substring(secretLen);
|
||||
|
||||
OtpInfo info = new TotpInfo(secret, OtpInfo.DEFAULT_ALGORITHM, 8, TotpInfo.DEFAULT_PERIOD);
|
||||
return new VaultEntry(info, serial, "Battle.net");
|
||||
} catch (OtpInfoException | EncodingException e) {
|
||||
throw new DatabaseImporterEntryException(e, hashString);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.beemdevelopment.aegis.importers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import com.beemdevelopment.aegis.encoding.Base32;
|
||||
import com.beemdevelopment.aegis.encoding.EncodingException;
|
||||
import com.beemdevelopment.aegis.otp.GoogleAuthInfo;
|
||||
import com.beemdevelopment.aegis.otp.GoogleAuthInfoException;
|
||||
import com.beemdevelopment.aegis.otp.OtpInfoException;
|
||||
import com.beemdevelopment.aegis.otp.SteamInfo;
|
||||
import com.beemdevelopment.aegis.util.IOUtils;
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
import com.topjohnwu.superuser.io.SuFile;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import org.simpleflatmapper.csv.CsvParser;
|
||||
import org.simpleflatmapper.lightningcsv.Row;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class BitwardenImporter extends DatabaseImporter {
|
||||
public BitwardenImporter(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected SuFile getAppPath() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected State read(InputStream stream, boolean isInternal) throws DatabaseImporterException {
|
||||
String fileString;
|
||||
try {
|
||||
fileString = new String(IOUtils.readAll(stream), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new DatabaseImporterException(e);
|
||||
}
|
||||
try {
|
||||
JSONObject obj = new JSONObject(fileString);
|
||||
JSONArray array = obj.getJSONArray("items");
|
||||
|
||||
List<String> entries = new ArrayList<>();
|
||||
String entry;
|
||||
for (int i = 0; i < array.length(); i++) {
|
||||
entry = array.getJSONObject(i).getJSONObject("login").getString("totp");
|
||||
if (!entry.isEmpty()) {
|
||||
entries.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return new BitwardenImporter.State(entries);
|
||||
} catch (JSONException e) {
|
||||
try {
|
||||
Iterator<Row> rowIterator = CsvParser.separator(',').rowIterator(fileString);
|
||||
List<String> entries = new ArrayList<>();
|
||||
rowIterator.forEachRemaining((row -> {
|
||||
String entry = row.get("login_totp");
|
||||
if (entry != null && !entry.isEmpty()) {
|
||||
entries.add(entry);
|
||||
}
|
||||
}));
|
||||
return new BitwardenImporter.State(entries);
|
||||
} catch (IOException e2) {
|
||||
throw new DatabaseImporterException(e2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class State extends DatabaseImporter.State {
|
||||
private final List<String> _entries;
|
||||
|
||||
public State(List<String> entries) {
|
||||
super(false);
|
||||
_entries = entries;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result convert() {
|
||||
Result result = new Result();
|
||||
|
||||
for (String obj : _entries) {
|
||||
try {
|
||||
VaultEntry entry = convertEntry(obj);
|
||||
result.addEntry(entry);
|
||||
} catch (DatabaseImporterEntryException e) {
|
||||
result.addError(e);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static VaultEntry convertEntry(String obj) throws DatabaseImporterEntryException {
|
||||
try {
|
||||
GoogleAuthInfo info = BitwardenImporter.parseUri(obj);
|
||||
return new VaultEntry(info);
|
||||
} catch (GoogleAuthInfoException | EncodingException | OtpInfoException | URISyntaxException e) {
|
||||
throw new DatabaseImporterEntryException(e, obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static GoogleAuthInfo parseUri(String s) throws EncodingException, OtpInfoException, URISyntaxException, GoogleAuthInfoException {
|
||||
Uri uri = Uri.parse(s);
|
||||
if (Objects.equals(uri.getScheme(), "steam")) {
|
||||
String secretString = uri.getAuthority();
|
||||
if (secretString == null) {
|
||||
throw new GoogleAuthInfoException(uri, "Empty secret (empty authority)");
|
||||
}
|
||||
byte[] secret = Base32.decode(secretString);
|
||||
return new GoogleAuthInfo(new SteamInfo(secret), "Steam account", "Steam");
|
||||
}
|
||||
|
||||
return GoogleAuthInfo.parseUri(uri);
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,13 @@ import androidx.annotation.StringRes;
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.util.UUIDMap;
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
import com.topjohnwu.superuser.Shell;
|
||||
import com.topjohnwu.superuser.io.SuFile;
|
||||
import com.topjohnwu.superuser.io.SuFileInputStream;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.Serializable;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -32,6 +34,9 @@ public abstract class DatabaseImporter {
|
||||
_importers.add(new Definition("andOTP", AndOtpImporter.class, R.string.importer_help_andotp, false));
|
||||
_importers.add(new Definition("Authenticator Plus", AuthenticatorPlusImporter.class, R.string.importer_help_authenticator_plus, false));
|
||||
_importers.add(new Definition("Authy", AuthyImporter.class, R.string.importer_help_authy, true));
|
||||
_importers.add(new Definition("Battle.net Authenticator", BattleNetImporter.class, R.string.importer_help_battle_net_authenticator, true));
|
||||
_importers.add(new Definition("Bitwarden", BitwardenImporter.class, R.string.importer_help_bitwarden, false));
|
||||
_importers.add(new Definition("Duo", DuoImporter.class, R.string.importer_help_duo, true));
|
||||
_importers.add(new Definition("FreeOTP", FreeOtpImporter.class, R.string.importer_help_freeotp, true));
|
||||
_importers.add(new Definition("FreeOTP+", FreeOtpPlusImporter.class, R.string.importer_help_freeotp_plus, true));
|
||||
_importers.add(new Definition("Google Authenticator", GoogleAuthImporter.class, R.string.importer_help_google_authenticator, true));
|
||||
@@ -46,26 +51,32 @@ public abstract class DatabaseImporter {
|
||||
_context = context;
|
||||
}
|
||||
|
||||
protected Context getContext() {
|
||||
protected Context requireContext() {
|
||||
return _context;
|
||||
}
|
||||
|
||||
protected abstract SuFile getAppPath() throws DatabaseImporterException, PackageManager.NameNotFoundException;
|
||||
|
||||
protected SuFile getAppPath(String pkgName, String subPath) throws PackageManager.NameNotFoundException {
|
||||
PackageManager man = getContext().getPackageManager();
|
||||
PackageManager man = requireContext().getPackageManager();
|
||||
return new SuFile(man.getApplicationInfo(pkgName, 0).dataDir, subPath);
|
||||
}
|
||||
|
||||
public boolean isInstalledAppVersionSupported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected abstract State read(InputStream stream, boolean isInternal) throws DatabaseImporterException;
|
||||
|
||||
public State read(InputStream stream) throws DatabaseImporterException {
|
||||
return read(stream, false);
|
||||
}
|
||||
|
||||
public State readFromApp() throws PackageManager.NameNotFoundException, DatabaseImporterException {
|
||||
public State readFromApp(Shell shell) throws PackageManager.NameNotFoundException, DatabaseImporterException {
|
||||
SuFile file = getAppPath();
|
||||
try (SuFileInputStream stream = new SuFileInputStream(file)) {
|
||||
file.setShell(shell);
|
||||
|
||||
try (InputStream stream = SuFileInputStream.open(file)) {
|
||||
return read(stream, true);
|
||||
} catch (IOException e) {
|
||||
throw new DatabaseImporterException(e);
|
||||
@@ -89,12 +100,19 @@ public abstract class DatabaseImporter {
|
||||
return Collections.unmodifiableList(_importers);
|
||||
}
|
||||
|
||||
public static class Definition {
|
||||
public static class Definition implements Serializable {
|
||||
private final String _name;
|
||||
private final Class<? extends DatabaseImporter> _type;
|
||||
private final @StringRes int _help;
|
||||
private final boolean _supportsDirect;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param name The name of the Authenticator the importer handles.
|
||||
* @param type The class which does the importing.
|
||||
* @param help The string that explains the type of file needed (and optionally where it can be obtained).
|
||||
* @param supportsDirect Whether the importer can directly import the entries from the app's internal storage using root access.
|
||||
*/
|
||||
public Definition(String name, Class<? extends DatabaseImporter> type, @StringRes int help, boolean supportsDirect) {
|
||||
_name = name;
|
||||
_type = type;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.beemdevelopment.aegis.importers;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager.NameNotFoundException;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.beemdevelopment.aegis.encoding.Base32;
|
||||
import com.beemdevelopment.aegis.encoding.EncodingException;
|
||||
import com.beemdevelopment.aegis.otp.HotpInfo;
|
||||
import com.beemdevelopment.aegis.otp.OtpInfo;
|
||||
import com.beemdevelopment.aegis.otp.OtpInfoException;
|
||||
import com.beemdevelopment.aegis.otp.TotpInfo;
|
||||
import com.beemdevelopment.aegis.util.IOUtils;
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
import com.topjohnwu.superuser.io.SuFile;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
public class DuoImporter extends DatabaseImporter {
|
||||
private static final String _pkgName = "com.duosecurity.duomobile";
|
||||
private static final String _subPath = "files/duokit/accounts.json";
|
||||
|
||||
public DuoImporter(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @NonNull SuFile getAppPath() throws DatabaseImporterException, NameNotFoundException {
|
||||
return getAppPath(_pkgName, _subPath);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @NonNull State read(
|
||||
@NonNull InputStream stream, boolean isInternal
|
||||
) throws DatabaseImporterException {
|
||||
try {
|
||||
String contents = new String(IOUtils.readAll(stream), UTF_8);
|
||||
return new DecryptedState(new JSONArray(contents));
|
||||
} catch (JSONException | IOException e) {
|
||||
throw new DatabaseImporterException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static class DecryptedState extends DatabaseImporter.State {
|
||||
private final JSONArray _array;
|
||||
|
||||
public DecryptedState(@NonNull JSONArray array) {
|
||||
super(false);
|
||||
_array = array;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Result convert() throws DatabaseImporterException {
|
||||
Result result = new Result();
|
||||
|
||||
try {
|
||||
for (int i = 0; i < _array.length(); i++) {
|
||||
JSONObject entry = _array.getJSONObject(i);
|
||||
try {
|
||||
result.addEntry(convertEntry(entry));
|
||||
} catch (DatabaseImporterEntryException e) {
|
||||
result.addError(e);
|
||||
}
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
throw new DatabaseImporterException(e);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static @NonNull VaultEntry convertEntry(
|
||||
@NonNull JSONObject entry
|
||||
) throws DatabaseImporterEntryException {
|
||||
try {
|
||||
String label = entry.optString("name");
|
||||
JSONObject otpData = entry.getJSONObject("otpGenerator");
|
||||
byte[] secret = Base32.decode(otpData.getString("otpSecret"));
|
||||
Long counter = otpData.has("counter") ? otpData.getLong("counter") : null;
|
||||
|
||||
OtpInfo otp = counter == null
|
||||
? new TotpInfo(secret)
|
||||
: new HotpInfo(secret, counter);
|
||||
|
||||
return new VaultEntry(otp, label, "");
|
||||
} catch (JSONException | OtpInfoException | EncodingException e) {
|
||||
throw new DatabaseImporterEntryException(e, entry.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public class FreeOtpPlusImporter extends DatabaseImporter {
|
||||
State state;
|
||||
|
||||
if (isInternal) {
|
||||
state = new FreeOtpImporter(getContext()).read(stream);
|
||||
state = new FreeOtpImporter(requireContext()).read(stream);
|
||||
} else {
|
||||
try {
|
||||
String json = new String(IOUtils.readAll(stream), StandardCharsets.UTF_8);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.beemdevelopment.aegis.importers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.database.Cursor;
|
||||
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.encoding.EncodingException;
|
||||
import com.beemdevelopment.aegis.otp.GoogleAuthInfo;
|
||||
import com.beemdevelopment.aegis.otp.HotpInfo;
|
||||
@@ -11,6 +13,7 @@ import com.beemdevelopment.aegis.otp.OtpInfo;
|
||||
import com.beemdevelopment.aegis.otp.OtpInfoException;
|
||||
import com.beemdevelopment.aegis.otp.TotpInfo;
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
import com.topjohnwu.superuser.Shell;
|
||||
import com.topjohnwu.superuser.io.SuFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
@@ -29,30 +32,49 @@ public class GoogleAuthImporter extends DatabaseImporter {
|
||||
|
||||
@Override
|
||||
protected SuFile getAppPath() throws PackageManager.NameNotFoundException {
|
||||
return getAppPath(_pkgName, _subPath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException {
|
||||
SqlImporterHelper helper = new SqlImporterHelper(getContext());
|
||||
List<Entry> entries = helper.read(Entry.class, stream, "accounts");
|
||||
return new State(entries);
|
||||
SuFile file = getAppPath(_pkgName, _subPath);
|
||||
return file;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DatabaseImporter.State readFromApp() throws PackageManager.NameNotFoundException, DatabaseImporterException {
|
||||
public boolean isInstalledAppVersionSupported() {
|
||||
PackageInfo info;
|
||||
try {
|
||||
info = requireContext().getPackageManager().getPackageInfo(_pkgName, 0);
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return info.versionCode <= 5000100;
|
||||
}
|
||||
|
||||
@Override
|
||||
public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException {
|
||||
final Context context = requireContext();
|
||||
SqlImporterHelper helper = new SqlImporterHelper(context);
|
||||
List<Entry> entries = helper.read(Entry.class, stream, "accounts");
|
||||
return new State(entries, context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DatabaseImporter.State readFromApp(Shell shell) throws PackageManager.NameNotFoundException, DatabaseImporterException {
|
||||
SuFile path = getAppPath();
|
||||
SqlImporterHelper helper = new SqlImporterHelper(getContext());
|
||||
path.setShell(shell);
|
||||
|
||||
final Context context = requireContext();
|
||||
SqlImporterHelper helper = new SqlImporterHelper(context);
|
||||
List<Entry> entries = helper.read(Entry.class, path, "accounts");
|
||||
return new State(entries);
|
||||
return new State(entries, context);
|
||||
}
|
||||
|
||||
public static class State extends DatabaseImporter.State {
|
||||
private List<Entry> _entries;
|
||||
private Context _context;
|
||||
|
||||
private State(List<Entry> entries) {
|
||||
private State(List<Entry> entries, Context context) {
|
||||
super(false);
|
||||
_entries = entries;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -61,7 +83,7 @@ public class GoogleAuthImporter extends DatabaseImporter {
|
||||
|
||||
for (Entry sqlEntry : _entries) {
|
||||
try {
|
||||
VaultEntry entry = convertEntry(sqlEntry);
|
||||
VaultEntry entry = convertEntry(sqlEntry, _context);
|
||||
result.addEntry(entry);
|
||||
} catch (DatabaseImporterEntryException e) {
|
||||
result.addError(e);
|
||||
@@ -71,8 +93,11 @@ public class GoogleAuthImporter extends DatabaseImporter {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static VaultEntry convertEntry(Entry entry) throws DatabaseImporterEntryException {
|
||||
private static VaultEntry convertEntry(Entry entry, Context context) throws DatabaseImporterEntryException {
|
||||
try {
|
||||
if (entry.isEncrypted()) {
|
||||
throw new DatabaseImporterException(context.getString(R.string.importer_encrypted_exception_google_authenticator, entry.getEmail()));
|
||||
}
|
||||
byte[] secret = GoogleAuthInfo.parseSecret(entry.getSecret());
|
||||
|
||||
OtpInfo info;
|
||||
@@ -102,6 +127,7 @@ public class GoogleAuthImporter extends DatabaseImporter {
|
||||
|
||||
private static class Entry extends SqlImporterHelper.Entry {
|
||||
private int _type;
|
||||
private boolean _isEncrypted;
|
||||
private String _secret;
|
||||
private String _email;
|
||||
private String _issuer;
|
||||
@@ -114,12 +140,18 @@ public class GoogleAuthImporter extends DatabaseImporter {
|
||||
_email = SqlImporterHelper.getString(cursor, "email", "");
|
||||
_issuer = SqlImporterHelper.getString(cursor, "issuer", "");
|
||||
_counter = SqlImporterHelper.getLong(cursor, "counter");
|
||||
_isEncrypted = (cursor.getColumnIndex("isencrypted") != -1 && SqlImporterHelper.getInt(cursor, "isencrypted") > 0);
|
||||
}
|
||||
|
||||
|
||||
public int getType() {
|
||||
return _type;
|
||||
}
|
||||
|
||||
public boolean isEncrypted() {
|
||||
return _isEncrypted;
|
||||
}
|
||||
|
||||
public String getSecret() {
|
||||
return _secret;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.beemdevelopment.aegis.otp.OtpInfo;
|
||||
import com.beemdevelopment.aegis.otp.OtpInfoException;
|
||||
import com.beemdevelopment.aegis.otp.TotpInfo;
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
import com.topjohnwu.superuser.Shell;
|
||||
import com.topjohnwu.superuser.io.SuFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
@@ -34,15 +35,17 @@ public class MicrosoftAuthImporter extends DatabaseImporter {
|
||||
|
||||
@Override
|
||||
public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException {
|
||||
SqlImporterHelper helper = new SqlImporterHelper(getContext());
|
||||
SqlImporterHelper helper = new SqlImporterHelper(requireContext());
|
||||
List<Entry> entries = helper.read(Entry.class, stream, "accounts");
|
||||
return new State(entries);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DatabaseImporter.State readFromApp() throws PackageManager.NameNotFoundException, DatabaseImporterException {
|
||||
public DatabaseImporter.State readFromApp(Shell shell) throws PackageManager.NameNotFoundException, DatabaseImporterException {
|
||||
SuFile path = getAppPath();
|
||||
SqlImporterHelper helper = new SqlImporterHelper(getContext());
|
||||
path.setShell(shell);
|
||||
|
||||
SqlImporterHelper helper = new SqlImporterHelper(requireContext());
|
||||
List<Entry> entries = helper.read(Entry.class, path, "accounts");
|
||||
return new State(entries);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.beemdevelopment.aegis.importers;
|
||||
|
||||
import static android.database.sqlite.SQLiteDatabase.OPEN_READONLY;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
@@ -19,8 +21,6 @@ import java.lang.reflect.InvocationTargetException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static android.database.sqlite.SQLiteDatabase.OPEN_READONLY;
|
||||
|
||||
public class SqlImporterHelper {
|
||||
private Context _context;
|
||||
|
||||
@@ -36,7 +36,7 @@ public class SqlImporterHelper {
|
||||
for (SuFile file : SqlImporterHelper.findDatabaseFiles(path)) {
|
||||
// create temporary copies of the database files so that SQLiteDatabase can open them
|
||||
File fileCopy = null;
|
||||
try (SuFileInputStream inStream = new SuFileInputStream(file)) {
|
||||
try (InputStream inStream = SuFileInputStream.open(file)) {
|
||||
fileCopy = new File(dir, file.getName());
|
||||
try (FileOutputStream out = new FileOutputStream(fileCopy)) {
|
||||
IOUtils.copy(inStream, out);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.beemdevelopment.aegis.importers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
|
||||
import com.beemdevelopment.aegis.encoding.Base64;
|
||||
@@ -39,6 +40,18 @@ public class SteamImporter extends DatabaseImporter {
|
||||
return files[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInstalledAppVersionSupported() {
|
||||
PackageInfo info;
|
||||
try {
|
||||
info = requireContext().getPackageManager().getPackageInfo(_pkgName, 0);
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return info.versionCode < 7460894;
|
||||
}
|
||||
|
||||
@Override
|
||||
public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException {
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.beemdevelopment.aegis.crypto.CryptoUtils;
|
||||
import com.beemdevelopment.aegis.encoding.Base32;
|
||||
import com.beemdevelopment.aegis.encoding.Base64;
|
||||
import com.beemdevelopment.aegis.encoding.EncodingException;
|
||||
import com.beemdevelopment.aegis.otp.HotpInfo;
|
||||
import com.beemdevelopment.aegis.otp.OtpInfo;
|
||||
import com.beemdevelopment.aegis.otp.OtpInfoException;
|
||||
import com.beemdevelopment.aegis.otp.TotpInfo;
|
||||
@@ -59,7 +60,7 @@ public class TwoFASImporter extends DatabaseImporter {
|
||||
String json = new String(IOUtils.readAll(stream), StandardCharsets.UTF_8);
|
||||
JSONObject obj = new JSONObject(json);
|
||||
int version = obj.getInt("schemaVersion");
|
||||
if (version > 2) {
|
||||
if (version > 3) {
|
||||
throw new DatabaseImporterException(String.format("Unsupported schema version: %d", version));
|
||||
}
|
||||
|
||||
@@ -173,13 +174,23 @@ public class TwoFASImporter extends DatabaseImporter {
|
||||
try {
|
||||
byte[] secret = Base32.decode(obj.getString("secret"));
|
||||
JSONObject info = obj.getJSONObject("otp");
|
||||
String issuer = info.getString("issuer");
|
||||
String issuer = info.optString("issuer");
|
||||
String name = info.optString("account");
|
||||
int digits = info.optInt("digits", TotpInfo.DEFAULT_DIGITS);
|
||||
int period = info.optInt("period", TotpInfo.DEFAULT_PERIOD);
|
||||
String algorithm = info.optString("algorithm", TotpInfo.DEFAULT_ALGORITHM);
|
||||
|
||||
OtpInfo otp = new TotpInfo(secret, algorithm, digits, period);
|
||||
OtpInfo otp;
|
||||
String tokenType = JsonUtils.optString(info, "tokenType");
|
||||
if (tokenType == null || tokenType.equals("TOTP")) {
|
||||
int period = info.optInt("period", TotpInfo.DEFAULT_PERIOD);
|
||||
otp = new TotpInfo(secret, algorithm, digits, period);
|
||||
} else if (tokenType.equals("HOTP")) {
|
||||
long counter = info.optLong("counter", 0);
|
||||
otp = new HotpInfo(secret, algorithm, digits, counter);
|
||||
} else {
|
||||
throw new DatabaseImporterEntryException(String.format("Unrecognized tokenType: %s", tokenType), obj.toString());
|
||||
}
|
||||
|
||||
return new VaultEntry(otp, name, issuer);
|
||||
} catch (OtpInfoException | JSONException | EncodingException e) {
|
||||
throw new DatabaseImporterEntryException(e, obj.toString());
|
||||
|
||||
@@ -19,7 +19,7 @@ public class WinAuthImporter extends DatabaseImporter {
|
||||
|
||||
@Override
|
||||
public WinAuthImporter.State read(InputStream stream, boolean isInternal) throws DatabaseImporterException {
|
||||
GoogleAuthUriImporter importer = new GoogleAuthUriImporter(getContext());
|
||||
GoogleAuthUriImporter importer = new GoogleAuthUriImporter(requireContext());
|
||||
DatabaseImporter.State state = importer.read(stream);
|
||||
return new State(state);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,24 @@ package com.beemdevelopment.aegis.otp;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.beemdevelopment.aegis.GoogleAuthProtos;
|
||||
import com.beemdevelopment.aegis.encoding.Base32;
|
||||
import com.beemdevelopment.aegis.encoding.Base64;
|
||||
import com.beemdevelopment.aegis.encoding.EncodingException;
|
||||
import com.beemdevelopment.aegis.encoding.Hex;
|
||||
import com.google.protobuf.ByteString;
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class GoogleAuthInfo implements Serializable {
|
||||
public class GoogleAuthInfo implements Transferable, Serializable {
|
||||
public static final String SCHEME = "otpauth";
|
||||
public static final String SCHEME_EXPORT = "otpauth-migration";
|
||||
|
||||
@@ -26,42 +33,6 @@ public class GoogleAuthInfo implements Serializable {
|
||||
_issuer = issuer;
|
||||
}
|
||||
|
||||
public OtpInfo getOtpInfo() {
|
||||
return _info;
|
||||
}
|
||||
|
||||
public Uri getUri() {
|
||||
Uri.Builder builder = new Uri.Builder();
|
||||
builder.scheme(SCHEME);
|
||||
|
||||
if (_info instanceof TotpInfo) {
|
||||
if (_info instanceof SteamInfo) {
|
||||
builder.authority("steam");
|
||||
} else {
|
||||
builder.authority("totp");
|
||||
}
|
||||
builder.appendQueryParameter("period", Integer.toString(((TotpInfo)_info).getPeriod()));
|
||||
} else if (_info instanceof HotpInfo) {
|
||||
builder.authority("hotp");
|
||||
builder.appendQueryParameter("counter", Long.toString(((HotpInfo)_info).getCounter()));
|
||||
} else {
|
||||
throw new RuntimeException(String.format("Unsupported OtpInfo type: %s", _info.getClass()));
|
||||
}
|
||||
|
||||
builder.appendQueryParameter("digits", Integer.toString(_info.getDigits()));
|
||||
builder.appendQueryParameter("algorithm", _info.getAlgorithm(false));
|
||||
builder.appendQueryParameter("secret", Base32.encode(_info.getSecret()));
|
||||
|
||||
if (_issuer != null && !_issuer.equals("")) {
|
||||
builder.path(String.format("%s:%s", _issuer, _accountName));
|
||||
builder.appendQueryParameter("issuer", _issuer);
|
||||
} else {
|
||||
builder.path(_accountName);
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static GoogleAuthInfo parseUri(String s) throws GoogleAuthInfoException {
|
||||
Uri uri = Uri.parse(s);
|
||||
if (uri == null) {
|
||||
@@ -72,7 +43,7 @@ public class GoogleAuthInfo implements Serializable {
|
||||
|
||||
public static GoogleAuthInfo parseUri(Uri uri) throws GoogleAuthInfoException {
|
||||
String scheme = uri.getScheme();
|
||||
if (scheme == null || !scheme.equals(SCHEME)) {
|
||||
if (scheme == null || !(scheme.equals(SCHEME) || scheme.equals(MotpInfo.SCHEME))) {
|
||||
throw new GoogleAuthInfoException(uri, String.format("Unsupported protocol: %s", scheme));
|
||||
}
|
||||
|
||||
@@ -84,14 +55,18 @@ public class GoogleAuthInfo implements Serializable {
|
||||
|
||||
byte[] secret;
|
||||
try {
|
||||
secret = parseSecret(encodedSecret);
|
||||
secret = (scheme.equals(MotpInfo.SCHEME)) ? Hex.decode(encodedSecret) : parseSecret(encodedSecret);
|
||||
} catch (EncodingException e) {
|
||||
throw new GoogleAuthInfoException(uri, "Bad secret", e);
|
||||
}
|
||||
if (secret.length == 0) {
|
||||
throw new GoogleAuthInfoException(uri, "Secret is empty");
|
||||
}
|
||||
|
||||
OtpInfo info;
|
||||
String issuer = "";
|
||||
try {
|
||||
String type = uri.getHost();
|
||||
String type = (scheme.equals(MotpInfo.SCHEME)) ? MotpInfo.ID : uri.getHost();
|
||||
if (type == null) {
|
||||
throw new GoogleAuthInfoException(uri, String.format("Host not present in URI: %s", uri.toString()));
|
||||
}
|
||||
@@ -122,10 +97,22 @@ public class GoogleAuthInfo implements Serializable {
|
||||
hotpInfo.setCounter(Long.parseLong(counter));
|
||||
info = hotpInfo;
|
||||
break;
|
||||
case YandexInfo.HOST_ID:
|
||||
String pin = uri.getQueryParameter("pin");
|
||||
if (pin != null) {
|
||||
pin = new String(parseSecret(pin), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
info = new YandexInfo(secret, pin);
|
||||
issuer = info.getType();
|
||||
break;
|
||||
case MotpInfo.ID:
|
||||
info = new MotpInfo(secret);
|
||||
break;
|
||||
default:
|
||||
throw new GoogleAuthInfoException(uri, String.format("Unsupported OTP type: %s", type));
|
||||
}
|
||||
} catch (OtpInfoException | NumberFormatException e) {
|
||||
} catch (OtpInfoException | NumberFormatException | EncodingException e) {
|
||||
throw new GoogleAuthInfoException(uri, e);
|
||||
}
|
||||
|
||||
@@ -134,7 +121,6 @@ public class GoogleAuthInfo implements Serializable {
|
||||
String label = path != null && path.length() > 0 ? path.substring(1) : "";
|
||||
|
||||
String accountName = "";
|
||||
String issuer = "";
|
||||
|
||||
if (label.contains(":")) {
|
||||
// a label can only contain one colon
|
||||
@@ -151,7 +137,9 @@ public class GoogleAuthInfo implements Serializable {
|
||||
// label only contains the account name
|
||||
// grab the issuer's info from the 'issuer' parameter if it's present
|
||||
String issuerParam = uri.getQueryParameter("issuer");
|
||||
issuer = issuerParam != null ? issuerParam : "";
|
||||
if (issuer.isEmpty()) {
|
||||
issuer = issuerParam != null ? issuerParam : "";
|
||||
}
|
||||
accountName = label;
|
||||
}
|
||||
|
||||
@@ -248,6 +236,10 @@ public class GoogleAuthInfo implements Serializable {
|
||||
}
|
||||
|
||||
byte[] secret = params.getSecret().toByteArray();
|
||||
if (secret.length == 0) {
|
||||
throw new GoogleAuthInfoException(uri, "Secret is empty");
|
||||
}
|
||||
|
||||
switch (params.getType()) {
|
||||
case OTP_TYPE_UNSPECIFIED:
|
||||
// intentional fallthrough
|
||||
@@ -260,7 +252,7 @@ public class GoogleAuthInfo implements Serializable {
|
||||
default:
|
||||
throw new GoogleAuthInfoException(uri, String.format("Unsupported algorithm: %d", params.getType().ordinal()));
|
||||
}
|
||||
} catch (OtpInfoException e){
|
||||
} catch (OtpInfoException e) {
|
||||
throw new GoogleAuthInfoException(uri, e);
|
||||
}
|
||||
|
||||
@@ -279,6 +271,55 @@ public class GoogleAuthInfo implements Serializable {
|
||||
return new Export(infos, payload.getBatchId(), payload.getBatchIndex(), payload.getBatchSize());
|
||||
}
|
||||
|
||||
public OtpInfo getOtpInfo() {
|
||||
return _info;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri getUri() {
|
||||
Uri.Builder builder = new Uri.Builder();
|
||||
|
||||
if (_info instanceof MotpInfo) {
|
||||
builder.scheme(MotpInfo.SCHEME);
|
||||
builder.appendQueryParameter("secret", Hex.encode(_info.getSecret()));
|
||||
} else {
|
||||
builder.scheme(SCHEME);
|
||||
|
||||
if (_info instanceof TotpInfo) {
|
||||
if (_info instanceof SteamInfo) {
|
||||
builder.authority("steam");
|
||||
} else if (_info instanceof YandexInfo) {
|
||||
builder.authority(YandexInfo.HOST_ID);
|
||||
} else {
|
||||
builder.authority("totp");
|
||||
}
|
||||
builder.appendQueryParameter("period", Integer.toString(((TotpInfo) _info).getPeriod()));
|
||||
} else if (_info instanceof HotpInfo) {
|
||||
builder.authority("hotp");
|
||||
builder.appendQueryParameter("counter", Long.toString(((HotpInfo) _info).getCounter()));
|
||||
} else {
|
||||
throw new RuntimeException(String.format("Unsupported OtpInfo type: %s", _info.getClass()));
|
||||
}
|
||||
|
||||
builder.appendQueryParameter("digits", Integer.toString(_info.getDigits()));
|
||||
builder.appendQueryParameter("algorithm", _info.getAlgorithm(false));
|
||||
builder.appendQueryParameter("secret", Base32.encode(_info.getSecret()));
|
||||
|
||||
if (_info instanceof YandexInfo) {
|
||||
builder.appendQueryParameter("pin", Base32.encode(((YandexInfo) _info).getPin()));
|
||||
}
|
||||
}
|
||||
|
||||
if (_issuer != null && !_issuer.equals("")) {
|
||||
builder.path(String.format("%s:%s", _issuer, _accountName));
|
||||
builder.appendQueryParameter("issuer", _issuer);
|
||||
} else {
|
||||
builder.path(_accountName);
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public String getIssuer() {
|
||||
return _issuer;
|
||||
}
|
||||
@@ -287,7 +328,7 @@ public class GoogleAuthInfo implements Serializable {
|
||||
return _accountName;
|
||||
}
|
||||
|
||||
public static class Export {
|
||||
public static class Export implements Transferable, Serializable {
|
||||
private int _batchId;
|
||||
private int _batchIndex;
|
||||
private int _batchSize;
|
||||
@@ -315,5 +356,110 @@ public class GoogleAuthInfo implements Serializable {
|
||||
public int getBatchId() {
|
||||
return _batchId;
|
||||
}
|
||||
|
||||
public static List<Integer> getMissingIndices(@NonNull List<Export> exports) throws IllegalArgumentException {
|
||||
if (!isSingleBatch(exports)) {
|
||||
throw new IllegalArgumentException("Export list contains entries from different batches");
|
||||
}
|
||||
|
||||
List<Integer> indicesMissing = new ArrayList<>();
|
||||
if (exports.isEmpty()) {
|
||||
return indicesMissing;
|
||||
}
|
||||
|
||||
Set<Integer> indicesPresent = exports.stream()
|
||||
.map(Export::getBatchIndex)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
for (int i = 0; i < exports.get(0).getBatchSize(); i++) {
|
||||
if (!indicesPresent.contains(i)) {
|
||||
indicesMissing.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
return indicesMissing;
|
||||
}
|
||||
|
||||
public static boolean isSingleBatch(@NonNull List<Export> exports) {
|
||||
if (exports.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
int batchId = exports.get(0).getBatchId();
|
||||
for (Export export : exports) {
|
||||
if (export.getBatchId() != batchId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri getUri() throws GoogleAuthInfoException {
|
||||
GoogleAuthProtos.MigrationPayload.Builder builder = GoogleAuthProtos.MigrationPayload.newBuilder();
|
||||
builder.setBatchId(_batchId)
|
||||
.setBatchIndex(_batchIndex)
|
||||
.setBatchSize(_batchSize)
|
||||
.setVersion(1);
|
||||
|
||||
for (GoogleAuthInfo info: _entries) {
|
||||
GoogleAuthProtos.MigrationPayload.OtpParameters.Builder parameters = GoogleAuthProtos.MigrationPayload.OtpParameters.newBuilder()
|
||||
.setSecret(ByteString.copyFrom(info.getOtpInfo().getSecret()))
|
||||
.setName(info.getAccountName())
|
||||
.setIssuer(info.getIssuer());
|
||||
|
||||
switch (info.getOtpInfo().getAlgorithm(false)) {
|
||||
case "SHA1":
|
||||
parameters.setAlgorithm(GoogleAuthProtos.MigrationPayload.Algorithm.ALGORITHM_SHA1);
|
||||
break;
|
||||
case "SHA256":
|
||||
parameters.setAlgorithm(GoogleAuthProtos.MigrationPayload.Algorithm.ALGORITHM_SHA256);
|
||||
break;
|
||||
case "SHA512":
|
||||
parameters.setAlgorithm(GoogleAuthProtos.MigrationPayload.Algorithm.ALGORITHM_SHA512);
|
||||
break;
|
||||
case "MD5":
|
||||
parameters.setAlgorithm(GoogleAuthProtos.MigrationPayload.Algorithm.ALGORITHM_MD5);
|
||||
break;
|
||||
default:
|
||||
throw new GoogleAuthInfoException(info.getUri(), String.format("Unsupported Algorithm: %s", info.getOtpInfo().getAlgorithm(false)));
|
||||
}
|
||||
|
||||
switch (info.getOtpInfo().getDigits()) {
|
||||
case 6:
|
||||
parameters.setDigits(GoogleAuthProtos.MigrationPayload.DigitCount.DIGIT_COUNT_SIX);
|
||||
break;
|
||||
case 8:
|
||||
parameters.setDigits(GoogleAuthProtos.MigrationPayload.DigitCount.DIGIT_COUNT_EIGHT);
|
||||
break;
|
||||
default:
|
||||
throw new GoogleAuthInfoException(info.getUri(), String.format("Unsupported number of digits: %s", info.getOtpInfo().getDigits()));
|
||||
}
|
||||
|
||||
switch (info.getOtpInfo().getType().toLowerCase()) {
|
||||
case HotpInfo.ID:
|
||||
parameters.setType(GoogleAuthProtos.MigrationPayload.OtpType.OTP_TYPE_HOTP);
|
||||
parameters.setCounter(((HotpInfo) info.getOtpInfo()).getCounter());
|
||||
break;
|
||||
case TotpInfo.ID:
|
||||
parameters.setType(GoogleAuthProtos.MigrationPayload.OtpType.OTP_TYPE_TOTP);
|
||||
break;
|
||||
default:
|
||||
throw new GoogleAuthInfoException(info.getUri(), String.format("Type unsupported by GoogleAuthProtos: %s", info.getOtpInfo().getType()));
|
||||
}
|
||||
|
||||
builder.addOtpParameters(parameters.build());
|
||||
}
|
||||
|
||||
Uri.Builder exportUriBuilder = new Uri.Builder()
|
||||
.scheme(SCHEME_EXPORT)
|
||||
.authority("offline");
|
||||
|
||||
String data = Base64.encode(builder.build().toByteArray());
|
||||
exportUriBuilder.appendQueryParameter("data", data);
|
||||
|
||||
return exportUriBuilder.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ public class GoogleAuthInfoException extends Exception {
|
||||
@Override
|
||||
public String getMessage() {
|
||||
Throwable cause = getCause();
|
||||
if (cause == null) {
|
||||
if (cause == null
|
||||
|| this == cause
|
||||
|| (super.getMessage() != null && super.getMessage().equals(cause.getMessage()))) {
|
||||
return super.getMessage();
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ public class HotpInfo extends OtpInfo {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOtp() {
|
||||
public String getOtp() throws OtpInfoException {
|
||||
checkSecret();
|
||||
|
||||
try {
|
||||
OTP otp = HOTP.generateOTP(getSecret(), getAlgorithm(true), getDigits(), getCounter());
|
||||
return otp.toString();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.beemdevelopment.aegis.otp;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.beemdevelopment.aegis.crypto.otp.MOTP;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Objects;
|
||||
|
||||
public class MotpInfo extends TotpInfo {
|
||||
public static final String ID = "motp";
|
||||
public static final String SCHEME = "motp";
|
||||
public static final String ALGORITHM = "MD5";
|
||||
|
||||
public static final int PERIOD = 10;
|
||||
public static final int DIGITS = 6;
|
||||
|
||||
private String _pin;
|
||||
|
||||
public MotpInfo(@NonNull byte[] secret) throws OtpInfoException {
|
||||
this(secret, null);
|
||||
}
|
||||
|
||||
public MotpInfo(byte[] secret, String pin) throws OtpInfoException {
|
||||
super(secret, ALGORITHM, DIGITS, PERIOD);
|
||||
setPin(pin);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOtp() {
|
||||
if (_pin == null) {
|
||||
throw new IllegalStateException("PIN must be set before generating an OTP");
|
||||
}
|
||||
|
||||
try {
|
||||
MOTP otp = MOTP.generateOTP(getSecret(), getAlgorithm(false), getDigits(), getPeriod(), getPin());
|
||||
return otp.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOtp(long time) {
|
||||
if (_pin == null) {
|
||||
throw new IllegalStateException("PIN must be set before generating an OTP");
|
||||
}
|
||||
|
||||
try {
|
||||
MOTP otp = MOTP.generateOTP(getSecret(), getAlgorithm(false), getDigits(), getPeriod(), getPin(), time);
|
||||
return otp.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTypeId() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject toJson() {
|
||||
JSONObject result = super.toJson();
|
||||
try {
|
||||
result.put("pin", getPin());
|
||||
} catch (JSONException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getPin() {
|
||||
return _pin;
|
||||
}
|
||||
|
||||
public void setPin(@NonNull String pin) {
|
||||
this._pin = pin;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof MotpInfo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
MotpInfo info = (MotpInfo) o;
|
||||
return super.equals(o) && Objects.equals(getPin(), info.getPin());
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,13 @@ public abstract class OtpInfo implements Serializable {
|
||||
setDigits(digits);
|
||||
}
|
||||
|
||||
public abstract String getOtp();
|
||||
public abstract String getOtp() throws OtpInfoException;
|
||||
|
||||
protected void checkSecret() throws OtpInfoException {
|
||||
if (getSecret().length == 0) {
|
||||
throw new OtpInfoException("Secret is empty");
|
||||
}
|
||||
}
|
||||
|
||||
public abstract String getTypeId();
|
||||
|
||||
@@ -40,7 +46,7 @@ public abstract class OtpInfo implements Serializable {
|
||||
JSONObject obj = new JSONObject();
|
||||
|
||||
try {
|
||||
obj.put("secret", new String(Base32.encode(getSecret())));
|
||||
obj.put("secret", Base32.encode(getSecret()));
|
||||
obj.put("algo", getAlgorithm(false));
|
||||
obj.put("digits", getDigits());
|
||||
} catch (JSONException e) {
|
||||
@@ -70,7 +76,8 @@ public abstract class OtpInfo implements Serializable {
|
||||
}
|
||||
|
||||
public static boolean isAlgorithmValid(String algorithm) {
|
||||
return algorithm.equals("SHA1") || algorithm.equals("SHA256") || algorithm.equals("SHA512");
|
||||
return algorithm.equals("SHA1") || algorithm.equals("SHA256") ||
|
||||
algorithm.equals("SHA512") || algorithm.equals("MD5");
|
||||
}
|
||||
|
||||
public void setAlgorithm(String algorithm) throws OtpInfoException {
|
||||
@@ -105,6 +112,12 @@ public abstract class OtpInfo implements Serializable {
|
||||
String algo = obj.getString("algo");
|
||||
int digits = obj.getInt("digits");
|
||||
|
||||
// Special case to work around a bug where a user could accidentally
|
||||
// set the hash algorithm of a non-mOTP entry to MD5
|
||||
if (!type.equals(MotpInfo.ID) && algo.equals("MD5")) {
|
||||
algo = DEFAULT_ALGORITHM;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case TotpInfo.ID:
|
||||
info = new TotpInfo(secret, algo, digits, obj.getInt("period"));
|
||||
@@ -115,6 +128,12 @@ public abstract class OtpInfo implements Serializable {
|
||||
case HotpInfo.ID:
|
||||
info = new HotpInfo(secret, algo, digits, obj.getLong("counter"));
|
||||
break;
|
||||
case YandexInfo.ID:
|
||||
info = new YandexInfo(secret, obj.getString("pin"));
|
||||
break;
|
||||
case MotpInfo.ID:
|
||||
info = new MotpInfo(secret, obj.getString("pin"));
|
||||
break;
|
||||
default:
|
||||
throw new OtpInfoException("unsupported otp type: " + type);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ public class SteamInfo extends TotpInfo {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOtp() {
|
||||
public String getOtp() throws OtpInfoException {
|
||||
checkSecret();
|
||||
|
||||
try {
|
||||
OTP otp = TOTP.generateOTP(getSecret(), getAlgorithm(true), getDigits(), getPeriod());
|
||||
return otp.toSteamString();
|
||||
|
||||
@@ -26,7 +26,9 @@ public class TotpInfo extends OtpInfo {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOtp() {
|
||||
public String getOtp() throws OtpInfoException {
|
||||
checkSecret();
|
||||
|
||||
try {
|
||||
OTP otp = TOTP.generateOTP(getSecret(), getAlgorithm(true), getDigits(), getPeriod());
|
||||
return otp.toString();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.beemdevelopment.aegis.otp;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
public interface Transferable {
|
||||
Uri getUri() throws GoogleAuthInfoException;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.beemdevelopment.aegis.otp;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.beemdevelopment.aegis.crypto.otp.YAOTP;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
public class YandexInfo extends TotpInfo {
|
||||
public static final String DEFAULT_ALGORITHM = "SHA256";
|
||||
public static final int DIGITS = 8;
|
||||
|
||||
public static final int SECRET_LENGTH = 16;
|
||||
public static final int SECRET_FULL_LENGTH = 26;
|
||||
public static final String ID = "yandex";
|
||||
public static final String HOST_ID = "yaotp";
|
||||
|
||||
@Nullable
|
||||
private String _pin;
|
||||
|
||||
public YandexInfo(@NonNull byte[] secret) throws OtpInfoException {
|
||||
this(secret, null);
|
||||
}
|
||||
|
||||
public YandexInfo(@NonNull byte[] secret, @Nullable String pin) throws OtpInfoException {
|
||||
super(secret, DEFAULT_ALGORITHM, DIGITS, TotpInfo.DEFAULT_PERIOD);
|
||||
setSecret(parseSecret(secret));
|
||||
_pin = pin;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOtp() {
|
||||
if (_pin == null) {
|
||||
throw new IllegalStateException("PIN must be set before generating an OTP");
|
||||
}
|
||||
|
||||
try {
|
||||
YAOTP otp = YAOTP.generateOTP(getSecret(), getPin(), getDigits(), getAlgorithm(true), getPeriod());
|
||||
return otp.toString();
|
||||
} catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getPin() {
|
||||
return _pin;
|
||||
}
|
||||
|
||||
public void setPin(@NonNull String pin) {
|
||||
_pin = pin;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTypeId() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType() {
|
||||
String id = getTypeId();
|
||||
return id.substring(0, 1).toUpperCase(Locale.ROOT) + id.substring(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject toJson() {
|
||||
JSONObject result = super.toJson();
|
||||
try {
|
||||
result.put("pin", getPin());
|
||||
} catch (JSONException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof YandexInfo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
YandexInfo info = (YandexInfo) o;
|
||||
return super.equals(o) && Objects.equals(getPin(), info.getPin());
|
||||
}
|
||||
|
||||
public static byte[] parseSecret(byte[] secret) throws OtpInfoException {
|
||||
validateSecret(secret);
|
||||
|
||||
if (secret.length != SECRET_LENGTH) {
|
||||
return Arrays.copyOfRange(secret, 0, SECRET_LENGTH);
|
||||
}
|
||||
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Java implementation of ChecksumIsValid
|
||||
* From: https://github.com/norblik/KeeYaOtp/blob/188a1a99f13f82e4ef8df8a1b9b9351ba236e2a1/KeeYaOtp/Core/Secret.cs
|
||||
* License: GPLv3+
|
||||
*/
|
||||
public static void validateSecret(byte[] secret) throws OtpInfoException {
|
||||
if (secret.length != SECRET_LENGTH && secret.length != SECRET_FULL_LENGTH) {
|
||||
throw new OtpInfoException(String.format("Invalid Yandex secret length: %d bytes", secret.length));
|
||||
}
|
||||
|
||||
// Secrets originating from a QR code do not have a checksum, so we assume those are valid
|
||||
if (secret.length == SECRET_LENGTH) {
|
||||
return;
|
||||
}
|
||||
|
||||
char originalChecksum = (char) ((secret[secret.length - 2] & 0x0F) << 8 | secret[secret.length - 1] & 0xff);
|
||||
|
||||
char accum = 0;
|
||||
int accumBits = 0;
|
||||
|
||||
int inputTotalBitsAvailable = secret.length * 8 - 12;
|
||||
int inputIndex = 0;
|
||||
int inputBitsAvailable = 8;
|
||||
|
||||
while (inputTotalBitsAvailable > 0) {
|
||||
int requiredBits = 13 - accumBits;
|
||||
if (inputTotalBitsAvailable < requiredBits) {
|
||||
requiredBits = inputTotalBitsAvailable;
|
||||
}
|
||||
|
||||
while (requiredBits > 0) {
|
||||
int curInput = (secret[inputIndex] & (1 << inputBitsAvailable) - 1) & 0xff;
|
||||
int bitsToRead = Math.min(requiredBits, inputBitsAvailable);
|
||||
|
||||
curInput >>= inputBitsAvailable - bitsToRead;
|
||||
accum = (char) (accum << bitsToRead | curInput);
|
||||
|
||||
inputTotalBitsAvailable -= bitsToRead;
|
||||
requiredBits -= bitsToRead;
|
||||
inputBitsAvailable -= bitsToRead;
|
||||
accumBits += bitsToRead;
|
||||
|
||||
if (inputBitsAvailable == 0) {
|
||||
inputIndex += 1;
|
||||
inputBitsAvailable = 8;
|
||||
}
|
||||
}
|
||||
|
||||
if (accumBits == 13) {
|
||||
accum ^= 0b1_1000_1111_0011;
|
||||
}
|
||||
accumBits = 16 - getNumberOfLeadingZeros(accum);
|
||||
}
|
||||
|
||||
if (accum != originalChecksum) {
|
||||
throw new OtpInfoException("Yandex secret checksum invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static int getNumberOfLeadingZeros(char value) {
|
||||
if (value == 0) {
|
||||
return 16;
|
||||
}
|
||||
|
||||
int n = 0;
|
||||
if ((value & 0xFF00) == 0) {
|
||||
n += 8;
|
||||
value <<= 8;
|
||||
}
|
||||
if ((value & 0xF000) == 0) {
|
||||
n += 4;
|
||||
value <<= 4;
|
||||
}
|
||||
if ((value & 0xC000) == 0) {
|
||||
n += 2;
|
||||
value <<= 2;
|
||||
}
|
||||
if ((value & 0x8000) == 0) {
|
||||
n++;
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.beemdevelopment.aegis.receivers;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import com.beemdevelopment.aegis.BuildConfig;
|
||||
import com.beemdevelopment.aegis.Preferences;
|
||||
import com.beemdevelopment.aegis.vault.VaultManager;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint;
|
||||
|
||||
@AndroidEntryPoint
|
||||
public class VaultLockReceiver extends BroadcastReceiver {
|
||||
public static final String ACTION_LOCK_VAULT
|
||||
= String.format("%s.LOCK_VAULT", BuildConfig.APPLICATION_ID);
|
||||
|
||||
@Inject
|
||||
protected VaultManager _vaultManager;
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent.getAction() == null
|
||||
|| (!intent.getAction().equals(ACTION_LOCK_VAULT)
|
||||
&& !intent.getAction().equals(Intent.ACTION_SCREEN_OFF))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_vaultManager.isAutoLockEnabled(Preferences.AUTO_LOCK_ON_DEVICE_LOCK)) {
|
||||
_vaultManager.lock(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.beemdevelopment.aegis.services;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.service.quicksettings.Tile;
|
||||
import android.service.quicksettings.TileService;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import com.beemdevelopment.aegis.ui.MainActivity;
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.N)
|
||||
public class LaunchAppTileService extends TileService {
|
||||
|
||||
@Override
|
||||
public void onStartListening() {
|
||||
super.onStartListening();
|
||||
Tile tile = getQsTile();
|
||||
tile.setState(Tile.STATE_INACTIVE);
|
||||
tile.updateTile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick() {
|
||||
super.onClick();
|
||||
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
intent.setAction(Intent.ACTION_MAIN);
|
||||
|
||||
startActivityAndCollapse(intent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.beemdevelopment.aegis.services;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.service.quicksettings.Tile;
|
||||
import android.service.quicksettings.TileService;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import com.beemdevelopment.aegis.ui.MainActivity;
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.N)
|
||||
public class LaunchScannerTileService extends TileService {
|
||||
|
||||
@Override
|
||||
public void onStartListening() {
|
||||
super.onStartListening();
|
||||
Tile tile = getQsTile();
|
||||
tile.setState(Tile.STATE_INACTIVE);
|
||||
tile.updateTile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick() {
|
||||
super.onClick();
|
||||
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.putExtra("action", "scan");
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
intent.setAction(Intent.ACTION_MAIN);
|
||||
|
||||
startActivityAndCollapse(intent);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,24 @@
|
||||
package com.beemdevelopment.aegis.services;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
import com.beemdevelopment.aegis.BuildConfig;
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.receivers.VaultLockReceiver;
|
||||
|
||||
public class NotificationService extends Service {
|
||||
public static final int VAULT_UNLOCKED_ID = 1;
|
||||
private static final int NOTIFICATION_VAULT_UNLOCKED = 1;
|
||||
|
||||
private static final String CODE_LOCK_STATUS_ID = "lock_status_channel";
|
||||
private static final String CODE_LOCK_VAULT_ACTION = "lock_vault";
|
||||
private static final String CHANNEL_ID = "lock_status_channel";
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent,int flags, int startId){
|
||||
@@ -24,25 +27,33 @@ public class NotificationService extends Service {
|
||||
return Service.START_STICKY;
|
||||
}
|
||||
|
||||
@SuppressLint("LaunchActivityFromNotification")
|
||||
public void serviceMethod() {
|
||||
Intent intentAction = new Intent(CODE_LOCK_VAULT_ACTION);
|
||||
PendingIntent lockDatabaseIntent = PendingIntent.getBroadcast(this, 1, intentAction, 0);
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CODE_LOCK_STATUS_ID)
|
||||
.setSmallIcon(R.drawable.ic_fingerprint_black_24dp)
|
||||
int flags = PendingIntent.FLAG_ONE_SHOT;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
flags |= PendingIntent.FLAG_IMMUTABLE;
|
||||
}
|
||||
|
||||
Intent intent = new Intent(this, VaultLockReceiver.class);
|
||||
intent.setAction(VaultLockReceiver.ACTION_LOCK_VAULT);
|
||||
intent.setPackage(BuildConfig.APPLICATION_ID);
|
||||
PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 1, intent, flags);
|
||||
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_aegis_notification)
|
||||
.setContentTitle(getString(R.string.app_name_full))
|
||||
.setContentText(getString(R.string.vault_unlocked_state))
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(lockDatabaseIntent);
|
||||
.setContentIntent(pendingIntent);
|
||||
|
||||
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
|
||||
notificationManager.notify(VAULT_UNLOCKED_ID, builder.build());
|
||||
startForeground(NOTIFICATION_VAULT_UNLOCKED, builder.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
|
||||
notificationManager.cancel(VAULT_UNLOCKED_ID);
|
||||
notificationManager.cancel(NOTIFICATION_VAULT_UNLOCKED);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,11 @@ public class AboutActivity extends AegisActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
LayoutInflaterCompat.setFactory2(getLayoutInflater(), new IconicsLayoutInflater2(getDelegate()));
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
if (abortIfOrphan(savedInstanceState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setContentView(R.layout.activity_about);
|
||||
setSupportActionBar(findViewById(R.id.toolbar));
|
||||
|
||||
@@ -124,7 +127,7 @@ public class AboutActivity extends AegisActivity {
|
||||
mailIntent.putExtra(Intent.EXTRA_EMAIL, mailaddress);
|
||||
mailIntent.putExtra(Intent.EXTRA_SUBJECT, R.string.app_name_full);
|
||||
|
||||
startActivity(Intent.createChooser(mailIntent, this.getString(R.string.email)));
|
||||
startActivity(Intent.createChooser(mailIntent, getString(R.string.email)));
|
||||
}
|
||||
|
||||
private void showThirdPartyLicenseDialog() {
|
||||
@@ -157,7 +160,7 @@ public class AboutActivity extends AegisActivity {
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
onBackPressed();
|
||||
finish();
|
||||
break;
|
||||
default:
|
||||
return super.onOptionsItemSelected(item);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.beemdevelopment.aegis.ui;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.os.Bundle;
|
||||
@@ -9,58 +9,60 @@ import android.view.WindowManager;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.CallSuper;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import com.beemdevelopment.aegis.AegisApplication;
|
||||
import com.beemdevelopment.aegis.Preferences;
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.Theme;
|
||||
import com.beemdevelopment.aegis.ThemeMap;
|
||||
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
|
||||
import com.beemdevelopment.aegis.vault.VaultManagerException;
|
||||
import com.beemdevelopment.aegis.icons.IconPackManager;
|
||||
import com.beemdevelopment.aegis.vault.VaultManager;
|
||||
import com.beemdevelopment.aegis.vault.VaultRepositoryException;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
public abstract class AegisActivity extends AppCompatActivity implements AegisApplication.LockListener {
|
||||
private AegisApplication _app;
|
||||
import javax.inject.Inject;
|
||||
|
||||
import dagger.hilt.InstallIn;
|
||||
import dagger.hilt.android.AndroidEntryPoint;
|
||||
import dagger.hilt.android.EarlyEntryPoint;
|
||||
import dagger.hilt.android.EarlyEntryPoints;
|
||||
import dagger.hilt.components.SingletonComponent;
|
||||
|
||||
@AndroidEntryPoint
|
||||
public abstract class AegisActivity extends AppCompatActivity implements VaultManager.LockListener {
|
||||
protected Preferences _prefs;
|
||||
|
||||
@Inject
|
||||
protected VaultManager _vaultManager;
|
||||
|
||||
@Inject
|
||||
protected IconPackManager _iconPackManager;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
_app = (AegisApplication) getApplication();
|
||||
|
||||
// set the theme and locale before creating the activity
|
||||
Preferences prefs = getPreferences();
|
||||
_prefs = EarlyEntryPoints.get(getApplicationContext(), PrefEntryPoint.class).getPreferences();
|
||||
onSetTheme();
|
||||
setLocale(prefs.getLocale());
|
||||
setLocale(_prefs.getLocale());
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// if the app was killed, relaunch MainActivity and close everything else
|
||||
if (savedInstanceState != null && isOrphan()) {
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// set FLAG_SECURE on the window of every AegisActivity
|
||||
if (getPreferences().isSecureScreenEnabled()) {
|
||||
if (_prefs.isSecureScreenEnabled()) {
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
|
||||
}
|
||||
|
||||
// register a callback to listen for lock events
|
||||
_app.registerLockListener(this);
|
||||
_vaultManager.registerLockListener(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
@CallSuper
|
||||
protected void onDestroy() {
|
||||
_app.unregisterLockListener(this);
|
||||
_vaultManager.unregisterLockListener(this);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@@ -68,30 +70,28 @@ public abstract class AegisActivity extends AppCompatActivity implements AegisAp
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
_app.setBlockAutoLock(false);
|
||||
_vaultManager.setBlockAutoLock(false);
|
||||
}
|
||||
|
||||
@SuppressLint("SoonBlockedPrivateApi")
|
||||
@SuppressWarnings("JavaReflectionMemberAccess")
|
||||
@Override
|
||||
public void onLocked(boolean userInitiated) {
|
||||
setResult(RESULT_CANCELED, null);
|
||||
|
||||
try {
|
||||
// Call a private overload of the finish() method to prevent the app
|
||||
// from disappearing from the recent apps menu
|
||||
Method method = Activity.class.getDeclaredMethod("finish", int.class);
|
||||
method.setAccessible(true);
|
||||
method.invoke(this, 2); // FINISH_TASK_WITH_ACTIVITY = 2
|
||||
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
|
||||
e.printStackTrace();
|
||||
// On recent Android versions, the overload of the finish() method
|
||||
// used above is no longer accessible
|
||||
finishAndRemoveTask();
|
||||
}
|
||||
}
|
||||
|
||||
protected AegisApplication getApp() {
|
||||
return _app;
|
||||
}
|
||||
|
||||
protected Preferences getPreferences() {
|
||||
return _app.getPreferences();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the activity is expected to set its theme.
|
||||
*/
|
||||
@@ -109,7 +109,7 @@ public abstract class AegisActivity extends AppCompatActivity implements AegisAp
|
||||
}
|
||||
|
||||
protected Theme getConfiguredTheme() {
|
||||
Theme theme = getPreferences().getCurrentTheme();
|
||||
Theme theme = _prefs.getCurrentTheme();
|
||||
|
||||
if (theme == Theme.SYSTEM || theme == Theme.SYSTEM_AMOLED) {
|
||||
int currentNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
|
||||
@@ -129,87 +129,60 @@ public abstract class AegisActivity extends AppCompatActivity implements AegisAp
|
||||
Configuration config = new Configuration();
|
||||
config.locale = locale;
|
||||
|
||||
this.getResources().updateConfiguration(config, this.getResources().getDisplayMetrics());
|
||||
getResources().updateConfiguration(config, getResources().getDisplayMetrics());
|
||||
}
|
||||
|
||||
protected boolean saveVault(boolean backup) {
|
||||
protected boolean saveVault() {
|
||||
try {
|
||||
getApp().getVaultManager().save(backup);
|
||||
_vaultManager.save();
|
||||
return true;
|
||||
} catch (VaultManagerException e) {
|
||||
} catch (VaultRepositoryException e) {
|
||||
Toast.makeText(this, getString(R.string.saving_error), Toast.LENGTH_LONG).show();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected boolean saveAndBackupVault() {
|
||||
try {
|
||||
_vaultManager.saveAndBackup();
|
||||
return true;
|
||||
} catch (VaultRepositoryException e) {
|
||||
Toast.makeText(this, getString(R.string.saving_error), Toast.LENGTH_LONG).show();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether this Activity instance has become an orphan. This can happen if
|
||||
* the vault was locked by an external trigger while the Activity was still open.
|
||||
* Closes this activity if it has become an orphan (isOrphan() == true) and launches MainActivity.
|
||||
* @param savedInstanceState the bundle passed to onCreate.
|
||||
* @return whether to abort onCreate.
|
||||
*/
|
||||
protected boolean isOrphan() {
|
||||
return !(this instanceof MainActivity) && !(this instanceof AuthActivity) && !(this instanceof IntroActivity) && _app.isVaultLocked();
|
||||
protected boolean abortIfOrphan(Bundle savedInstanceState) {
|
||||
if (savedInstanceState == null || !isOrphan()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static class Helper {
|
||||
private Helper() {
|
||||
/**
|
||||
* Reports whether this Activity instance has become an orphan. This can happen if
|
||||
* the vault was killed/locked by an external trigger while the Activity was still open.
|
||||
*/
|
||||
private boolean isOrphan() {
|
||||
return !(this instanceof MainActivity)
|
||||
&& !(this instanceof AuthActivity)
|
||||
&& !(this instanceof IntroActivity)
|
||||
&& !_vaultManager.isVaultLoaded();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an external activity, temporarily blocks automatic lock of Aegis and
|
||||
* shows an error dialog if the target activity is not found.
|
||||
*/
|
||||
public static void startExtActivityForResult(Activity activity, Intent intent, int requestCode) {
|
||||
AegisApplication app = (AegisApplication) activity.getApplication();
|
||||
app.setBlockAutoLock(true);
|
||||
|
||||
try {
|
||||
activity.startActivityForResult(intent, requestCode, null);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
|
||||
if (isDocsAction(intent.getAction())) {
|
||||
Dialogs.showErrorDialog(activity, R.string.documentsui_error, e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an external activity, temporarily blocks automatic lock of Aegis and
|
||||
* shows an error dialog if the target activity is not found.
|
||||
*/
|
||||
public static void startExtActivity(Fragment fragment, Intent intent) {
|
||||
startExtActivityForResult(fragment, intent, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an external activity, temporarily blocks automatic lock of Aegis and
|
||||
* shows an error dialog if the target activity is not found.
|
||||
*/
|
||||
public static void startExtActivityForResult(Fragment fragment, Intent intent, int requestCode) {
|
||||
AegisApplication app = (AegisApplication) fragment.getActivity().getApplication();
|
||||
app.setBlockAutoLock(true);
|
||||
|
||||
try {
|
||||
fragment.startActivityForResult(intent, requestCode, null);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
|
||||
if (isDocsAction(intent.getAction())) {
|
||||
Dialogs.showErrorDialog(fragment.getContext(), R.string.documentsui_error, e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isDocsAction(@Nullable String action) {
|
||||
return action != null && (action.equals(Intent.ACTION_GET_CONTENT)
|
||||
|| action.equals(Intent.ACTION_CREATE_DOCUMENT)
|
||||
|| action.equals(Intent.ACTION_OPEN_DOCUMENT)
|
||||
|| action.equals(Intent.ACTION_OPEN_DOCUMENT_TREE));
|
||||
}
|
||||
@EarlyEntryPoint
|
||||
@InstallIn(SingletonComponent.class)
|
||||
public interface PrefEntryPoint {
|
||||
Preferences getPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,11 @@ import android.widget.PopupWindow;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.biometric.BiometricPrompt;
|
||||
|
||||
import com.beemdevelopment.aegis.AegisApplication;
|
||||
import com.beemdevelopment.aegis.Preferences;
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.ThemeMap;
|
||||
import com.beemdevelopment.aegis.crypto.KeyStoreHandle;
|
||||
@@ -37,7 +36,7 @@ import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
|
||||
import com.beemdevelopment.aegis.ui.tasks.PasswordSlotDecryptTask;
|
||||
import com.beemdevelopment.aegis.vault.VaultFile;
|
||||
import com.beemdevelopment.aegis.vault.VaultFileCredentials;
|
||||
import com.beemdevelopment.aegis.vault.VaultManagerException;
|
||||
import com.beemdevelopment.aegis.vault.VaultRepositoryException;
|
||||
import com.beemdevelopment.aegis.vault.slots.BiometricSlot;
|
||||
import com.beemdevelopment.aegis.vault.slots.PasswordSlot;
|
||||
import com.beemdevelopment.aegis.vault.slots.Slot;
|
||||
@@ -51,6 +50,9 @@ import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
public class AuthActivity extends AegisActivity {
|
||||
// Permission request codes
|
||||
private static final int CODE_PERM_NOTIFICATIONS = 0;
|
||||
|
||||
private EditText _textPassword;
|
||||
|
||||
private SlotList _slots;
|
||||
@@ -64,18 +66,17 @@ public class AuthActivity extends AegisActivity {
|
||||
// biometric prompt by setting 'inhibitBioPrompt' to true through the intent
|
||||
private boolean _inhibitBioPrompt;
|
||||
|
||||
private Preferences _prefs;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
_prefs = new Preferences(this);
|
||||
setContentView(R.layout.activity_auth);
|
||||
_textPassword = findViewById(R.id.text_password);
|
||||
LinearLayout boxBiometricInfo = findViewById(R.id.box_biometric_info);
|
||||
Button decryptButton = findViewById(R.id.button_decrypt);
|
||||
TextView biometricsButton = findViewById(R.id.button_biometrics);
|
||||
|
||||
getOnBackPressedDispatcher().addCallback(this, new BackPressHandler());
|
||||
|
||||
_textPassword.setOnEditorActionListener((v, actionId, event) -> {
|
||||
if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER)) || (actionId == EditorInfo.IME_ACTION_DONE)) {
|
||||
decryptButton.performClick();
|
||||
@@ -90,19 +91,29 @@ public class AuthActivity extends AegisActivity {
|
||||
Intent intent = getIntent();
|
||||
if (savedInstanceState == null) {
|
||||
_inhibitBioPrompt = intent.getBooleanExtra("inhibitBioPrompt", false);
|
||||
|
||||
// A persistent notification is shown to let the user know that the vault is unlocked. Permission
|
||||
// to do so is required since API 33, so for existing users, we have to request permission here
|
||||
// in order to be able to show the notification after unlock.
|
||||
//
|
||||
// NOTE: Disabled for now. See issue: #1047
|
||||
/*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
PermissionHelper.request(this, CODE_PERM_NOTIFICATIONS, Manifest.permission.POST_NOTIFICATIONS);
|
||||
}*/
|
||||
} else {
|
||||
_inhibitBioPrompt = savedInstanceState.getBoolean("inhibitBioPrompt", false);
|
||||
}
|
||||
|
||||
try {
|
||||
VaultFile vaultFile = getApp().loadVaultFile();
|
||||
_slots = vaultFile.getHeader().getSlots();
|
||||
} catch (VaultManagerException e) {
|
||||
e.printStackTrace();
|
||||
Dialogs.showErrorDialog(this, R.string.vault_load_error, e, (dialog, which) -> onBackPressed());
|
||||
if (_vaultManager.getVaultFileError() != null) {
|
||||
Dialogs.showErrorDialog(this, R.string.vault_load_error, _vaultManager.getVaultFileError(), (dialog, which) -> {
|
||||
getOnBackPressedDispatcher().onBackPressed();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
VaultFile vaultFile = _vaultManager.getVaultFile();
|
||||
_slots = vaultFile.getHeader().getSlots();
|
||||
|
||||
// only show the biometric prompt if the api version is new enough, permission is granted, a scanner is found and a biometric slot is found
|
||||
if (_slots.has(BiometricSlot.class) && BiometricsHelper.isAvailable(this)) {
|
||||
boolean invalidated = false;
|
||||
@@ -173,11 +184,6 @@ public class AuthActivity extends AegisActivity {
|
||||
imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
finishAffinity();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
@@ -227,6 +233,10 @@ public class AuthActivity extends AegisActivity {
|
||||
popup.setElevation(5.0f);
|
||||
}
|
||||
_textPassword.post(() -> {
|
||||
if (isFinishing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// calculating the actual height of the popup window does not seem possible
|
||||
// adding 25dp seems to look good enough
|
||||
int yoff = _textPassword.getHeight()
|
||||
@@ -266,12 +276,11 @@ public class AuthActivity extends AegisActivity {
|
||||
VaultFileCredentials creds = new VaultFileCredentials(key, _slots);
|
||||
|
||||
try {
|
||||
AegisApplication app = getApp();
|
||||
app.initVaultManager(app.loadVaultFile(), creds);
|
||||
_vaultManager.unlock(creds);
|
||||
if (isSlotRepaired) {
|
||||
saveVault(true);
|
||||
saveAndBackupVault();
|
||||
}
|
||||
} catch (VaultManagerException e) {
|
||||
} catch (VaultRepositoryException e) {
|
||||
e.printStackTrace();
|
||||
Dialogs.showErrorDialog(this, R.string.decryption_corrupt_error, e);
|
||||
return;
|
||||
@@ -296,6 +305,20 @@ public class AuthActivity extends AegisActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private class BackPressHandler extends OnBackPressedCallback {
|
||||
public BackPressHandler() {
|
||||
super(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
// This breaks predictive back gestures, but it doesn't make sense
|
||||
// to go back to MainActivity when cancelling auth
|
||||
setResult(RESULT_CANCELED);
|
||||
finishAffinity();
|
||||
}
|
||||
}
|
||||
|
||||
private class PasswordDerivationListener implements PasswordSlotDecryptTask.Callback {
|
||||
@Override
|
||||
public void onTaskFinished(PasswordSlotDecryptTask.Result result) {
|
||||
@@ -305,7 +328,7 @@ public class AuthActivity extends AegisActivity {
|
||||
_slots.replace(result.getSlot());
|
||||
}
|
||||
|
||||
if (result.getSlot().getType() == Slot.TYPE_DERIVED) {
|
||||
if (result.getSlot().getType() == Slot.TYPE_PASSWORD) {
|
||||
_prefs.resetPasswordReminderTimestamp();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.graphics.drawable.BitmapDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
@@ -16,36 +15,41 @@ import android.view.ViewGroup;
|
||||
import android.view.animation.AccelerateInterpolator;
|
||||
import android.view.animation.AlphaAnimation;
|
||||
import android.view.animation.Animation;
|
||||
import android.webkit.MimeTypeMap;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.AutoCompleteTextView;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.RelativeLayout;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
import com.amulyakhare.textdrawable.TextDrawable;
|
||||
import com.avito.android.krop.KropView;
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.encoding.Base32;
|
||||
import com.beemdevelopment.aegis.encoding.EncodingException;
|
||||
import com.beemdevelopment.aegis.encoding.Hex;
|
||||
import com.beemdevelopment.aegis.helpers.DropdownHelper;
|
||||
import com.beemdevelopment.aegis.helpers.EditTextHelper;
|
||||
import com.beemdevelopment.aegis.helpers.IconViewHelper;
|
||||
import com.beemdevelopment.aegis.helpers.SafHelper;
|
||||
import com.beemdevelopment.aegis.helpers.SimpleAnimationEndListener;
|
||||
import com.beemdevelopment.aegis.helpers.SimpleTextWatcher;
|
||||
import com.beemdevelopment.aegis.helpers.TextDrawableHelper;
|
||||
import com.beemdevelopment.aegis.icons.IconPack;
|
||||
import com.beemdevelopment.aegis.icons.IconType;
|
||||
import com.beemdevelopment.aegis.otp.GoogleAuthInfo;
|
||||
import com.beemdevelopment.aegis.otp.HotpInfo;
|
||||
import com.beemdevelopment.aegis.otp.MotpInfo;
|
||||
import com.beemdevelopment.aegis.otp.OtpInfo;
|
||||
import com.beemdevelopment.aegis.otp.OtpInfoException;
|
||||
import com.beemdevelopment.aegis.otp.SteamInfo;
|
||||
import com.beemdevelopment.aegis.otp.TotpInfo;
|
||||
import com.beemdevelopment.aegis.otp.YandexInfo;
|
||||
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
|
||||
import com.beemdevelopment.aegis.ui.dialogs.IconPickerDialog;
|
||||
import com.beemdevelopment.aegis.ui.glide.IconLoader;
|
||||
@@ -54,7 +58,7 @@ import com.beemdevelopment.aegis.ui.views.IconAdapter;
|
||||
import com.beemdevelopment.aegis.util.Cloner;
|
||||
import com.beemdevelopment.aegis.util.IOUtils;
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
import com.beemdevelopment.aegis.vault.VaultManager;
|
||||
import com.beemdevelopment.aegis.vault.VaultRepository;
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.request.target.CustomTarget;
|
||||
@@ -90,7 +94,6 @@ public class EditEntryActivity extends AegisActivity {
|
||||
// keep track of icon changes separately as the generated jpeg's are not deterministic
|
||||
private boolean _hasChangedIcon = false;
|
||||
private IconPack.Icon _selectedIcon;
|
||||
private boolean _isEditingIcon;
|
||||
private CircleImageView _iconView;
|
||||
private ImageView _saveImageButton;
|
||||
|
||||
@@ -101,6 +104,8 @@ public class EditEntryActivity extends AegisActivity {
|
||||
private TextInputEditText _textDigits;
|
||||
private TextInputLayout _textDigitsLayout;
|
||||
private TextInputEditText _textSecret;
|
||||
private TextInputEditText _textPin;
|
||||
private LinearLayout _textPinLayout;
|
||||
private TextInputEditText _textUsageCount;
|
||||
private TextInputEditText _textNote;
|
||||
|
||||
@@ -115,26 +120,36 @@ public class EditEntryActivity extends AegisActivity {
|
||||
private RelativeLayout _advancedSettingsHeader;
|
||||
private RelativeLayout _advancedSettings;
|
||||
|
||||
private VaultManager _vault;
|
||||
private BackPressHandler _backPressHandler;
|
||||
private IconBackPressHandler _iconBackPressHandler;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (abortIfOrphan(savedInstanceState)) {
|
||||
return;
|
||||
}
|
||||
setContentView(R.layout.activity_edit_entry);
|
||||
setSupportActionBar(findViewById(R.id.toolbar));
|
||||
|
||||
_vault = getApp().getVaultManager();
|
||||
_groups = _vault.getGroups();
|
||||
_groups = _vaultManager.getVault().getGroups();
|
||||
|
||||
ActionBar bar = getSupportActionBar();
|
||||
bar.setHomeAsUpIndicator(R.drawable.ic_close);
|
||||
bar.setDisplayHomeAsUpEnabled(true);
|
||||
if (bar != null) {
|
||||
bar.setHomeAsUpIndicator(R.drawable.ic_close);
|
||||
bar.setDisplayHomeAsUpEnabled(true);
|
||||
}
|
||||
|
||||
_backPressHandler = new BackPressHandler();
|
||||
getOnBackPressedDispatcher().addCallback(this, _backPressHandler);
|
||||
_iconBackPressHandler = new IconBackPressHandler();
|
||||
getOnBackPressedDispatcher().addCallback(this, _iconBackPressHandler);
|
||||
|
||||
// retrieve info from the calling activity
|
||||
Intent intent = getIntent();
|
||||
UUID entryUUID = (UUID) intent.getSerializableExtra("entryUUID");
|
||||
if (entryUUID != null) {
|
||||
_origEntry = _vault.getEntryByUUID(entryUUID);
|
||||
_origEntry = _vaultManager.getVault().getEntryByUUID(entryUUID);
|
||||
} else {
|
||||
_origEntry = (VaultEntry) intent.getSerializableExtra("newEntry");
|
||||
_isManual = intent.getBooleanExtra("isManual", false);
|
||||
@@ -153,6 +168,8 @@ public class EditEntryActivity extends AegisActivity {
|
||||
_textDigits = findViewById(R.id.text_digits);
|
||||
_textDigitsLayout = findViewById(R.id.text_digits_layout);
|
||||
_textSecret = findViewById(R.id.text_secret);
|
||||
_textPin = findViewById(R.id.text_pin);
|
||||
_textPinLayout = findViewById(R.id.layout_pin);
|
||||
_textUsageCount = findViewById(R.id.text_usage_count);
|
||||
_textNote = findViewById(R.id.text_note);
|
||||
_dropdownType = findViewById(R.id.dropdown_type);
|
||||
@@ -165,13 +182,21 @@ public class EditEntryActivity extends AegisActivity {
|
||||
DropdownHelper.fillDropdown(this, _dropdownGroup, _dropdownGroupList);
|
||||
|
||||
// if this is NOT a manually entered entry, move the "Secret" field from basic to advanced settings
|
||||
if (!_isNew || (_isNew && !_isManual)) {
|
||||
if (!_isNew || !_isManual) {
|
||||
int secretIndex = 0;
|
||||
LinearLayout layoutSecret = findViewById(R.id.layout_secret);
|
||||
LinearLayout layoutBasic = findViewById(R.id.layout_basic);
|
||||
LinearLayout layoutAdvanced = findViewById(R.id.layout_advanced);
|
||||
layoutBasic.removeView(layoutSecret);
|
||||
layoutAdvanced.addView(layoutSecret, 0);
|
||||
((LinearLayout.LayoutParams) layoutSecret.getLayoutParams()).topMargin = 0;
|
||||
if (!_isNew) {
|
||||
secretIndex = 1;
|
||||
layoutBasic.removeView(_textPinLayout);
|
||||
layoutAdvanced.addView(_textPinLayout, 0);
|
||||
((LinearLayout.LayoutParams) _textPinLayout.getLayoutParams()).topMargin = 0;
|
||||
} else {
|
||||
((LinearLayout.LayoutParams) layoutSecret.getLayoutParams()).topMargin = 0;
|
||||
}
|
||||
layoutAdvanced.addView(layoutSecret, secretIndex);
|
||||
|
||||
if (_isNew && !_isManual) {
|
||||
setViewEnabled(layoutAdvanced, false);
|
||||
@@ -206,6 +231,7 @@ public class EditEntryActivity extends AegisActivity {
|
||||
_textNote.setText(_origEntry.getNote());
|
||||
|
||||
OtpInfo info = _origEntry.getInfo();
|
||||
|
||||
if (info instanceof TotpInfo) {
|
||||
_textPeriodCounterLayout.setHint(R.string.period_hint);
|
||||
_textPeriodCounter.setText(Integer.toString(((TotpInfo) info).getPeriod()));
|
||||
@@ -219,20 +245,40 @@ public class EditEntryActivity extends AegisActivity {
|
||||
|
||||
byte[] secretBytes = _origEntry.getInfo().getSecret();
|
||||
if (secretBytes != null) {
|
||||
String secretString = Base32.encode(secretBytes);
|
||||
String secretString = (info instanceof MotpInfo) ? Hex.encode(secretBytes) : Base32.encode(secretBytes);
|
||||
_textSecret.setText(secretString);
|
||||
}
|
||||
|
||||
_dropdownType.setText(_origEntry.getInfo().getType(), false);
|
||||
_dropdownAlgo.setText(_origEntry.getInfo().getAlgorithm(false), false);
|
||||
|
||||
if (info instanceof YandexInfo) {
|
||||
_textPin.setText(((YandexInfo) info).getPin());
|
||||
} else if (info instanceof MotpInfo) {
|
||||
_textPin.setText(((MotpInfo) info).getPin());
|
||||
}
|
||||
|
||||
updateAdvancedFieldStatus(_origEntry.getInfo().getTypeId());
|
||||
updatePinFieldVisibility(_origEntry.getInfo().getTypeId());
|
||||
|
||||
String group = _origEntry.getGroup();
|
||||
setGroup(group);
|
||||
|
||||
// update the icon if the text changed
|
||||
_textIssuer.addTextChangedListener(_iconChangeListener);
|
||||
_textName.addTextChangedListener(_iconChangeListener);
|
||||
// Update the icon if the issuer or name has changed
|
||||
_textIssuer.addTextChangedListener(_nameChangeListener);
|
||||
_textName.addTextChangedListener(_nameChangeListener);
|
||||
|
||||
// Register listeners to trigger validation
|
||||
_textIssuer.addTextChangedListener(_validationListener);
|
||||
_textName.addTextChangedListener(_validationListener);
|
||||
_textNote.addTextChangedListener(_validationListener);
|
||||
_textSecret.addTextChangedListener(_validationListener);
|
||||
_dropdownType.addTextChangedListener(_validationListener);
|
||||
_dropdownGroup.addTextChangedListener(_validationListener);
|
||||
_dropdownAlgo.addTextChangedListener(_validationListener);
|
||||
_textPeriodCounter.addTextChangedListener(_validationListener);
|
||||
_textDigits.addTextChangedListener(_validationListener);
|
||||
_textPin.addTextChangedListener(_validationListener);
|
||||
|
||||
// show/hide period and counter fields on type change
|
||||
_dropdownType.setOnItemClickListener((parent, view, position, id) -> {
|
||||
@@ -245,20 +291,35 @@ public class EditEntryActivity extends AegisActivity {
|
||||
_textDigits.setText(String.valueOf(SteamInfo.DIGITS));
|
||||
break;
|
||||
case TotpInfo.ID:
|
||||
_dropdownAlgo.setText(OtpInfo.DEFAULT_ALGORITHM, false);
|
||||
_textPeriodCounterLayout.setHint(R.string.period_hint);
|
||||
_textPeriodCounter.setText(String.valueOf(TotpInfo.DEFAULT_PERIOD));
|
||||
_textDigits.setText(String.valueOf(OtpInfo.DEFAULT_DIGITS));
|
||||
break;
|
||||
case HotpInfo.ID:
|
||||
_dropdownAlgo.setText(OtpInfo.DEFAULT_ALGORITHM, false);
|
||||
_textPeriodCounterLayout.setHint(R.string.counter);
|
||||
_textPeriodCounter.setText(String.valueOf(HotpInfo.DEFAULT_COUNTER));
|
||||
_textDigits.setText(String.valueOf(OtpInfo.DEFAULT_DIGITS));
|
||||
break;
|
||||
case YandexInfo.ID:
|
||||
_dropdownAlgo.setText(YandexInfo.DEFAULT_ALGORITHM, false);
|
||||
_textPeriodCounterLayout.setHint(R.string.period_hint);
|
||||
_textPeriodCounter.setText(String.valueOf(TotpInfo.DEFAULT_PERIOD));
|
||||
_textDigits.setText(String.valueOf(YandexInfo.DIGITS));
|
||||
break;
|
||||
case MotpInfo.ID:
|
||||
_dropdownAlgo.setText(MotpInfo.ALGORITHM, false);
|
||||
_textPeriodCounterLayout.setHint(R.string.period_hint);
|
||||
_textPeriodCounter.setText(String.valueOf(MotpInfo.PERIOD));
|
||||
_textDigits.setText(String.valueOf(MotpInfo.DIGITS));
|
||||
break;
|
||||
default:
|
||||
throw new RuntimeException(String.format("Unsupported OTP type: %s", type));
|
||||
}
|
||||
|
||||
updateAdvancedFieldStatus(type);
|
||||
updatePinFieldVisibility(type);
|
||||
});
|
||||
|
||||
_iconView.setOnClickListener(v -> {
|
||||
@@ -286,16 +347,23 @@ public class EditEntryActivity extends AegisActivity {
|
||||
}
|
||||
});
|
||||
|
||||
_textUsageCount.setText(getPreferences().getUsageCount(entryUUID).toString());
|
||||
_textUsageCount.setText(_prefs.getUsageCount(entryUUID).toString());
|
||||
}
|
||||
|
||||
private void updateAdvancedFieldStatus(String otpType) {
|
||||
boolean enabled = !otpType.equals(SteamInfo.ID) && (!_isNew || _isManual);
|
||||
boolean enabled = !otpType.equals(SteamInfo.ID) && !otpType.equals(YandexInfo.ID)
|
||||
&& !otpType.equals(MotpInfo.ID) && (!_isNew || _isManual);
|
||||
_textDigitsLayout.setEnabled(enabled);
|
||||
_textPeriodCounterLayout.setEnabled(enabled);
|
||||
_dropdownAlgoLayout.setEnabled(enabled);
|
||||
}
|
||||
|
||||
private void updatePinFieldVisibility(String otpType) {
|
||||
boolean visible = otpType.equals(YandexInfo.ID) || otpType.equals(MotpInfo.ID);
|
||||
_textPinLayout.setVisibility(visible ? View.VISIBLE : View.GONE);
|
||||
_textPin.setHint(otpType.equals(MotpInfo.ID) ? R.string.motp_pin : R.string.yandex_pin);
|
||||
}
|
||||
|
||||
private void setGroup(String groupName) {
|
||||
int pos = 0;
|
||||
if (groupName != null) {
|
||||
@@ -315,40 +383,14 @@ public class EditEntryActivity extends AegisActivity {
|
||||
fadeIn.setInterpolator(new AccelerateInterpolator());
|
||||
fadeIn.setDuration(250);
|
||||
|
||||
fadeOut.setAnimationListener(new Animation.AnimationListener() {
|
||||
@Override
|
||||
public void onAnimationStart(Animation animation) {
|
||||
fadeOut.setAnimationListener(new SimpleAnimationEndListener((a) -> {
|
||||
_advancedSettingsHeader.setVisibility(View.GONE);
|
||||
_advancedSettings.startAnimation(fadeIn);
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationEnd(Animation animation) {
|
||||
_advancedSettingsHeader.setVisibility(View.GONE);
|
||||
_advancedSettings.startAnimation(fadeIn);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationRepeat(Animation animation) {
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
fadeIn.setAnimationListener(new Animation.AnimationListener() {
|
||||
@Override
|
||||
public void onAnimationStart(Animation animation) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationEnd(Animation animation) {
|
||||
_advancedSettings.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationRepeat(Animation animation) {
|
||||
|
||||
}
|
||||
});
|
||||
fadeIn.setAnimationListener(new SimpleAnimationEndListener((a) -> {
|
||||
_advancedSettings.setVisibility(View.VISIBLE);
|
||||
}));
|
||||
}
|
||||
|
||||
private void updateGroupDropdownList() {
|
||||
@@ -359,30 +401,26 @@ public class EditEntryActivity extends AegisActivity {
|
||||
_dropdownGroupList.add(res.getString(R.string.new_group));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (_isEditingIcon) {
|
||||
stopEditingIcon(false);
|
||||
return;
|
||||
}
|
||||
private boolean hasUnsavedChanges(VaultEntry newEntry) {
|
||||
return _hasChangedIcon || !_origEntry.equals(newEntry);
|
||||
}
|
||||
|
||||
private void discardAndFinish() {
|
||||
AtomicReference<String> msg = new AtomicReference<>();
|
||||
AtomicReference<VaultEntry> entry = new AtomicReference<>();
|
||||
|
||||
try {
|
||||
entry.set(parseEntry());
|
||||
} catch (ParseException e) {
|
||||
msg.set(e.getMessage());
|
||||
}
|
||||
|
||||
// close the activity if the entry has not been changed
|
||||
if (!_hasChangedIcon && _origEntry.equals(entry.get())) {
|
||||
super.onBackPressed();
|
||||
if (!hasUnsavedChanges(entry.get())) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// ask for confirmation if the entry has been changed
|
||||
Dialogs.showDiscardDialog(this,
|
||||
Dialogs.showDiscardDialog(EditEntryActivity.this,
|
||||
(dialog, which) -> {
|
||||
// if the entry couldn't be parsed, we show an error dialog
|
||||
if (msg.get() != null) {
|
||||
@@ -392,7 +430,7 @@ public class EditEntryActivity extends AegisActivity {
|
||||
|
||||
addAndFinish(entry.get());
|
||||
},
|
||||
(dialog, which) -> super.onBackPressed()
|
||||
(dialog, which) -> finish()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -400,7 +438,7 @@ public class EditEntryActivity extends AegisActivity {
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
onBackPressed();
|
||||
discardAndFinish();
|
||||
break;
|
||||
case R.id.action_save:
|
||||
onSave();
|
||||
@@ -444,16 +482,16 @@ public class EditEntryActivity extends AegisActivity {
|
||||
|
||||
Intent chooserIntent = Intent.createChooser(galleryIntent, getString(R.string.select_icon));
|
||||
chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[] { fileIntent });
|
||||
AegisActivity.Helper.startExtActivityForResult(this, chooserIntent, PICK_IMAGE_REQUEST);
|
||||
_vaultManager.startActivityForResult(this, chooserIntent, PICK_IMAGE_REQUEST);
|
||||
}
|
||||
|
||||
private void resetUsageCount() {
|
||||
getPreferences().resetUsageCount(_origEntry.getUUID());
|
||||
_prefs.resetUsageCount(_origEntry.getUUID());
|
||||
_textUsageCount.setText("0");
|
||||
}
|
||||
|
||||
private void startIconSelection() {
|
||||
List<IconPack> iconPacks = getApp().getIconPackManager().getIconPacks().stream()
|
||||
List<IconPack> iconPacks = _iconPackManager.getIconPacks().stream()
|
||||
.sorted(Comparator.comparing(IconPack::getName))
|
||||
.collect(Collectors.toList());
|
||||
if (iconPacks.size() == 0) {
|
||||
@@ -514,7 +552,7 @@ public class EditEntryActivity extends AegisActivity {
|
||||
stopEditingIcon(true);
|
||||
});
|
||||
|
||||
_isEditingIcon = true;
|
||||
_iconBackPressHandler.setEnabled(true);
|
||||
}
|
||||
|
||||
private void stopEditingIcon(boolean save) {
|
||||
@@ -526,7 +564,7 @@ public class EditEntryActivity extends AegisActivity {
|
||||
|
||||
_hasCustomIcon = _hasCustomIcon || save;
|
||||
_hasChangedIcon = save;
|
||||
_isEditingIcon = false;
|
||||
_iconBackPressHandler.setEnabled(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -547,17 +585,18 @@ public class EditEntryActivity extends AegisActivity {
|
||||
// vault to disk failed, causing the user to tap 'Save' again. Calling addEntry
|
||||
// again would cause a crash in that case, so the isEntryDuplicate check prevents
|
||||
// that.
|
||||
if (_isNew && !_vault.isEntryDuplicate(entry)) {
|
||||
_vault.addEntry(entry);
|
||||
VaultRepository vault = _vaultManager.getVault();
|
||||
if (_isNew && !vault.isEntryDuplicate(entry)) {
|
||||
vault.addEntry(entry);
|
||||
} else {
|
||||
_vault.replaceEntry(entry);
|
||||
vault.replaceEntry(entry);
|
||||
}
|
||||
|
||||
saveAndFinish(entry, false);
|
||||
}
|
||||
|
||||
private void deleteAndFinish(VaultEntry entry) {
|
||||
_vault.removeEntry(entry);
|
||||
_vaultManager.getVault().removeEntry(entry);
|
||||
saveAndFinish(entry, true);
|
||||
}
|
||||
|
||||
@@ -566,7 +605,7 @@ public class EditEntryActivity extends AegisActivity {
|
||||
intent.putExtra("entryUUID", entry.getUUID());
|
||||
intent.putExtra("delete", delete);
|
||||
|
||||
if (saveVault(true)) {
|
||||
if (saveAndBackupVault()) {
|
||||
setResult(RESULT_OK, intent);
|
||||
finish();
|
||||
}
|
||||
@@ -575,15 +614,15 @@ public class EditEntryActivity extends AegisActivity {
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, final int resultCode, Intent data) {
|
||||
if (requestCode == PICK_IMAGE_REQUEST && resultCode == RESULT_OK && data != null && data.getData() != null) {
|
||||
String fileType = getMimeType(data.getData());
|
||||
String fileType = SafHelper.getMimeType(this, data.getData());
|
||||
if (fileType != null && fileType.equals(IconType.SVG.toMimeType())) {
|
||||
ImportFileTask.Params params = new ImportFileTask.Params(data.getData(), "icon", null);
|
||||
ImportFileTask task = new ImportFileTask(this, result -> {
|
||||
if (result.getException() == null) {
|
||||
if (result.getError() == null) {
|
||||
CustomSvgIcon icon = new CustomSvgIcon(result.getFile());
|
||||
selectIcon(icon);
|
||||
} else {
|
||||
Dialogs.showErrorDialog(this, R.string.reading_file_error, result.getException());
|
||||
Dialogs.showErrorDialog(this, R.string.reading_file_error, result.getError());
|
||||
}
|
||||
});
|
||||
task.execute(getLifecycle(), params);
|
||||
@@ -595,23 +634,6 @@ public class EditEntryActivity extends AegisActivity {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
}
|
||||
|
||||
private String getMimeType(Uri uri) {
|
||||
DocumentFile file = DocumentFile.fromSingleUri(this, uri);
|
||||
if (file != null) {
|
||||
String fileType = file.getType();
|
||||
if (fileType != null) {
|
||||
return fileType;
|
||||
}
|
||||
|
||||
String ext = MimeTypeMap.getFileExtensionFromUrl(uri.toString());
|
||||
if (ext != null) {
|
||||
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private int parsePeriod() throws ParseException {
|
||||
try {
|
||||
return Integer.parseInt(_textPeriodCounter.getText().toString());
|
||||
@@ -627,6 +649,17 @@ public class EditEntryActivity extends AegisActivity {
|
||||
|
||||
String type = _dropdownType.getText().toString();
|
||||
String algo = _dropdownAlgo.getText().toString();
|
||||
String lowerCasedType = type.toLowerCase(Locale.ROOT);
|
||||
|
||||
if (lowerCasedType.equals(YandexInfo.ID) || lowerCasedType.equals(MotpInfo.ID)) {
|
||||
int pinLength = _textPin.length();
|
||||
if (pinLength < 4) {
|
||||
throw new ParseException("PIN is a required field. Must have a minimum length of 4 digits.");
|
||||
}
|
||||
if (pinLength != 4 && lowerCasedType.equals(MotpInfo.ID)) {
|
||||
throw new ParseException("PIN must have a length of 4 digits.");
|
||||
}
|
||||
}
|
||||
|
||||
int digits;
|
||||
try {
|
||||
@@ -638,12 +671,18 @@ public class EditEntryActivity extends AegisActivity {
|
||||
byte[] secret;
|
||||
try {
|
||||
String secretString = new String(EditTextHelper.getEditTextChars(_textSecret));
|
||||
secret = GoogleAuthInfo.parseSecret(secretString);
|
||||
|
||||
secret = (lowerCasedType.equals(MotpInfo.ID)) ?
|
||||
Hex.decode(secretString) : GoogleAuthInfo.parseSecret(secretString);
|
||||
|
||||
if (secret.length == 0) {
|
||||
throw new ParseException("Secret cannot be empty");
|
||||
}
|
||||
} catch (EncodingException e) {
|
||||
throw new ParseException("Secret is not valid base32.");
|
||||
String exceptionMessage = (lowerCasedType.equals(MotpInfo.ID)) ?
|
||||
"Secret is not valid hexadecimal" : "Secret is not valid base32.";
|
||||
|
||||
throw new ParseException(exceptionMessage);
|
||||
}
|
||||
|
||||
OtpInfo info;
|
||||
@@ -664,6 +703,12 @@ public class EditEntryActivity extends AegisActivity {
|
||||
}
|
||||
info = new HotpInfo(secret, algo, digits, counter);
|
||||
break;
|
||||
case YandexInfo.ID:
|
||||
info = new YandexInfo(secret, _textPin.getText().toString());
|
||||
break;
|
||||
case MotpInfo.ID:
|
||||
info = new MotpInfo(secret, _textPin.getText().toString());
|
||||
break;
|
||||
default:
|
||||
throw new RuntimeException(String.format("Unsupported OTP type: %s", type));
|
||||
}
|
||||
@@ -724,7 +769,7 @@ public class EditEntryActivity extends AegisActivity {
|
||||
}
|
||||
|
||||
private boolean onSave() {
|
||||
if (_isEditingIcon) {
|
||||
if (_iconBackPressHandler.isEnabled()) {
|
||||
stopEditingIcon(true);
|
||||
}
|
||||
|
||||
@@ -751,23 +796,50 @@ public class EditEntryActivity extends AegisActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private final TextWatcher _iconChangeListener = new TextWatcher() {
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
private final TextWatcher _validationListener = new SimpleTextWatcher((s) -> {
|
||||
updateBackPressHandlerState();
|
||||
});
|
||||
|
||||
private final TextWatcher _nameChangeListener = new SimpleTextWatcher((s) -> {
|
||||
if (!_hasCustomIcon) {
|
||||
TextDrawable drawable = TextDrawableHelper.generate(_textIssuer.getText().toString(), _textName.getText().toString(), _iconView);
|
||||
_iconView.setImageDrawable(drawable);
|
||||
}
|
||||
});
|
||||
|
||||
private void updateBackPressHandlerState() {
|
||||
VaultEntry entry = null;
|
||||
try {
|
||||
entry = parseEntry();
|
||||
} catch (ParseException ignored) {
|
||||
|
||||
}
|
||||
|
||||
boolean backEnabled = hasUnsavedChanges(entry);
|
||||
_backPressHandler.setEnabled(backEnabled);
|
||||
}
|
||||
|
||||
private class BackPressHandler extends OnBackPressedCallback {
|
||||
public BackPressHandler() {
|
||||
super(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
public void handleOnBackPressed() {
|
||||
discardAndFinish();
|
||||
}
|
||||
}
|
||||
|
||||
private class IconBackPressHandler extends OnBackPressedCallback {
|
||||
public IconBackPressHandler() {
|
||||
super(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
if (!_hasCustomIcon) {
|
||||
TextDrawable drawable = TextDrawableHelper.generate(_textIssuer.getText().toString(), _textName.getText().toString(), _iconView);
|
||||
_iconView.setImageDrawable(drawable);
|
||||
}
|
||||
public void handleOnBackPressed() {
|
||||
stopEditingIcon(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static class ParseException extends Exception {
|
||||
public ParseException(String message) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.beemdevelopment.aegis.ui;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
@@ -12,33 +14,42 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
|
||||
import com.beemdevelopment.aegis.ui.views.GroupAdapter;
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
|
||||
import java.text.Collator;
|
||||
import java.util.ArrayList;
|
||||
import java.util.TreeSet;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class GroupManagerActivity extends AegisActivity implements GroupAdapter.Listener {
|
||||
private GroupAdapter _adapter;
|
||||
private TreeSet<String> _groups;
|
||||
private HashSet<String> _removedGroups;
|
||||
private RecyclerView _slotsView;
|
||||
private View _emptyStateView;
|
||||
private BackPressHandler _backPressHandler;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (abortIfOrphan(savedInstanceState)) {
|
||||
return;
|
||||
}
|
||||
setContentView(R.layout.activity_groups);
|
||||
setSupportActionBar(findViewById(R.id.toolbar));
|
||||
|
||||
Intent intent = getIntent();
|
||||
_groups = new TreeSet<>(Collator.getInstance());
|
||||
_groups.addAll(intent.getStringArrayListExtra("groups"));
|
||||
|
||||
if (getSupportActionBar() != null) {
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
getSupportActionBar().setDisplayShowHomeEnabled(true);
|
||||
}
|
||||
_backPressHandler = new BackPressHandler();
|
||||
getOnBackPressedDispatcher().addCallback(this, _backPressHandler);
|
||||
|
||||
if (savedInstanceState != null) {
|
||||
List<String> groups = savedInstanceState.getStringArrayList("removedGroups");
|
||||
_removedGroups = new HashSet<>(Objects.requireNonNull(groups));
|
||||
} else {
|
||||
_removedGroups = new HashSet<>();
|
||||
}
|
||||
|
||||
// set up the recycler view
|
||||
_adapter = new GroupAdapter(this);
|
||||
_slotsView= findViewById(R.id.list_slots);
|
||||
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
|
||||
@@ -46,7 +57,7 @@ public class GroupManagerActivity extends AegisActivity implements GroupAdapter.
|
||||
_slotsView.setAdapter(_adapter);
|
||||
_slotsView.setNestedScrollingEnabled(false);
|
||||
|
||||
for (String group : _groups) {
|
||||
for (String group : _vaultManager.getVault().getGroups()) {
|
||||
_adapter.addGroup(group);
|
||||
}
|
||||
|
||||
@@ -54,6 +65,74 @@ public class GroupManagerActivity extends AegisActivity implements GroupAdapter.
|
||||
updateEmptyState();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(@NonNull Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putStringArrayList("removedGroups", new ArrayList<>(_removedGroups));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRemoveGroup(String group) {
|
||||
Dialogs.showSecureDialog(new AlertDialog.Builder(this)
|
||||
.setTitle(R.string.remove_group)
|
||||
.setMessage(R.string.remove_group_description)
|
||||
.setPositiveButton(android.R.string.yes, (dialog, whichButton) -> {
|
||||
_removedGroups.add(group);
|
||||
_adapter.removeGroup(group);
|
||||
_backPressHandler.setEnabled(true);
|
||||
updateEmptyState();
|
||||
})
|
||||
.setNegativeButton(android.R.string.no, null)
|
||||
.create());
|
||||
}
|
||||
|
||||
private void saveAndFinish() {
|
||||
if (!_removedGroups.isEmpty()) {
|
||||
for (VaultEntry entry : _vaultManager.getVault().getEntries()) {
|
||||
if (_removedGroups.contains(entry.getGroup())) {
|
||||
entry.setGroup(null);
|
||||
}
|
||||
}
|
||||
|
||||
saveAndBackupVault();
|
||||
}
|
||||
|
||||
finish();
|
||||
}
|
||||
|
||||
private void discardAndFinish() {
|
||||
if (_removedGroups.isEmpty()) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
Dialogs.showDiscardDialog(this,
|
||||
(dialog, which) -> saveAndFinish(),
|
||||
(dialog, which) -> finish());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
getMenuInflater().inflate(R.menu.menu_groups, menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
discardAndFinish();
|
||||
break;
|
||||
case R.id.action_save:
|
||||
saveAndFinish();
|
||||
break;
|
||||
default:
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void updateEmptyState() {
|
||||
if (_adapter.getItemCount() > 0) {
|
||||
_slotsView.setVisibility(View.VISIBLE);
|
||||
@@ -64,38 +143,14 @@ public class GroupManagerActivity extends AegisActivity implements GroupAdapter.
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRemoveGroup(String group) {
|
||||
Dialogs.showSecureDialog(new AlertDialog.Builder(this)
|
||||
.setTitle(R.string.remove_group)
|
||||
.setMessage(R.string.remove_group_description)
|
||||
.setPositiveButton(android.R.string.yes, (dialog, whichButton) -> {
|
||||
_groups.remove(group);
|
||||
_adapter.removeGroup(group);
|
||||
updateEmptyState();
|
||||
})
|
||||
.setNegativeButton(android.R.string.no, null)
|
||||
.create());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
onBackPressed();
|
||||
break;
|
||||
default:
|
||||
return super.onOptionsItemSelected(item);
|
||||
private class BackPressHandler extends OnBackPressedCallback {
|
||||
public BackPressHandler() {
|
||||
super(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
Intent intent = new Intent();
|
||||
intent.putExtra("groups", new ArrayList<>(_groups));
|
||||
setResult(RESULT_OK, intent);
|
||||
finish();
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
discardAndFinish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package com.beemdevelopment.aegis.ui;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.widget.Toast;
|
||||
@@ -24,19 +20,18 @@ import com.beemdevelopment.aegis.importers.DatabaseImporterEntryException;
|
||||
import com.beemdevelopment.aegis.importers.DatabaseImporterException;
|
||||
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
|
||||
import com.beemdevelopment.aegis.ui.models.ImportEntry;
|
||||
import com.beemdevelopment.aegis.ui.tasks.RootShellTask;
|
||||
import com.beemdevelopment.aegis.ui.views.ImportEntriesAdapter;
|
||||
import com.beemdevelopment.aegis.util.UUIDMap;
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
import com.beemdevelopment.aegis.vault.VaultManager;
|
||||
import com.beemdevelopment.aegis.vault.VaultRepository;
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton;
|
||||
import com.topjohnwu.superuser.Shell;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ImportEntriesActivity extends AegisActivity {
|
||||
@@ -47,6 +42,9 @@ public class ImportEntriesActivity extends AegisActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (abortIfOrphan(savedInstanceState)) {
|
||||
return;
|
||||
}
|
||||
setContentView(R.layout.activity_import_entries);
|
||||
setSupportActionBar(findViewById(R.id.toolbar));
|
||||
|
||||
@@ -71,7 +69,7 @@ public class ImportEntriesActivity extends AegisActivity {
|
||||
|
||||
FloatingActionButton fab = findViewById(R.id.fab);
|
||||
fab.setOnClickListener(v -> {
|
||||
if (getApp().getVaultManager().getEntries().size() > 0
|
||||
if (_vaultManager.getVault().getEntries().size() > 0
|
||||
&& _menu.findItem(R.id.toggle_wipe_vault).isChecked()) {
|
||||
showWipeEntriesDialog();
|
||||
} else {
|
||||
@@ -80,21 +78,35 @@ public class ImportEntriesActivity extends AegisActivity {
|
||||
});
|
||||
_fabScrollHelper = new FabScrollHelper(fab);
|
||||
|
||||
Class<? extends DatabaseImporter> importerType = (Class<? extends DatabaseImporter>) getIntent().getSerializableExtra("importerType");
|
||||
startImport(importerType, (File) getIntent().getSerializableExtra("file"));
|
||||
DatabaseImporter.Definition importerDef = (DatabaseImporter.Definition) getIntent().getSerializableExtra("importerDef");
|
||||
startImport(importerDef, (File) getIntent().getSerializableExtra("file"));
|
||||
}
|
||||
|
||||
private void startImport(@NonNull Class<? extends DatabaseImporter> importerType, @Nullable File file) {
|
||||
private void startImport(DatabaseImporter.Definition importerDef, @Nullable File file) {
|
||||
DatabaseImporter importer = DatabaseImporter.create(this, importerDef.getType());
|
||||
if (file == null) {
|
||||
startImportApp(importerType);
|
||||
if (importer.isInstalledAppVersionSupported()) {
|
||||
startImportApp(importer);
|
||||
} else {
|
||||
Dialogs.showSecureDialog(new AlertDialog.Builder(this)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(getString(R.string.app_version_error, importerDef.getName()))
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.yes, (dialog1, which) -> {
|
||||
startImportApp(importer);
|
||||
})
|
||||
.setNegativeButton(R.string.no, (dialog1, which) -> {
|
||||
finish();
|
||||
})
|
||||
.create());
|
||||
}
|
||||
} else {
|
||||
startImportFile(importerType, file);
|
||||
startImportFile(importer, file);
|
||||
}
|
||||
}
|
||||
|
||||
private void startImportFile(@NonNull Class<? extends DatabaseImporter> importerType, @NonNull File file) {
|
||||
private void startImportFile(@NonNull DatabaseImporter importer, @NonNull File file) {
|
||||
try (InputStream stream = new FileInputStream(file)) {
|
||||
DatabaseImporter importer = DatabaseImporter.create(this, importerType);
|
||||
DatabaseImporter.State state = importer.read(stream);
|
||||
processImporterState(state);
|
||||
} catch (FileNotFoundException e) {
|
||||
@@ -105,28 +117,37 @@ public class ImportEntriesActivity extends AegisActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private void startImportApp(@NonNull Class<? extends DatabaseImporter> importerType) {
|
||||
DatabaseImporter importer = DatabaseImporter.create(this, importerType);
|
||||
private void startImportApp(@NonNull DatabaseImporter importer) {
|
||||
RootShellTask task = new RootShellTask(this, shell -> {
|
||||
if (isFinishing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// obtain the global root shell and close it immediately after we're done
|
||||
// TODO: find a way to use SuFileInputStream with Shell.newInstance()
|
||||
try (Shell shell = Shell.getShell()) {
|
||||
if (!shell.isRoot()) {
|
||||
if (shell == null || !shell.isRoot()) {
|
||||
Toast.makeText(this, R.string.root_error, Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
DatabaseImporter.State state = importer.readFromApp();
|
||||
processImporterState(state);
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
Toast.makeText(this, R.string.app_lookup_error, Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
} catch (IOException | DatabaseImporterException e) {
|
||||
e.printStackTrace();
|
||||
Dialogs.showErrorDialog(this, R.string.reading_file_error, e, (dialog, which) -> finish());
|
||||
}
|
||||
try {
|
||||
DatabaseImporter.State state = importer.readFromApp(shell);
|
||||
processImporterState(state);
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
Toast.makeText(this, R.string.app_lookup_error, Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
} catch (DatabaseImporterException e) {
|
||||
e.printStackTrace();
|
||||
Dialogs.showErrorDialog(this, R.string.reading_file_error, e, (dialog, which) -> finish());
|
||||
} finally {
|
||||
try {
|
||||
shell.close();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
});
|
||||
task.execute(this);
|
||||
}
|
||||
|
||||
private void processImporterState(DatabaseImporter.State state) {
|
||||
@@ -175,39 +196,11 @@ public class ImportEntriesActivity extends AegisActivity {
|
||||
|
||||
List<DatabaseImporterEntryException> errors = result.getErrors();
|
||||
if (errors.size() > 0) {
|
||||
showErrorDialog(errors);
|
||||
String message = getResources().getQuantityString(R.plurals.import_error_dialog, errors.size(), errors.size());
|
||||
Dialogs.showMultiErrorDialog(this, R.string.import_error_title, message, errors, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void showErrorDialog(List<DatabaseImporterEntryException> errors) {
|
||||
Dialogs.showSecureDialog(new AlertDialog.Builder(this)
|
||||
.setTitle(R.string.import_error_title)
|
||||
.setMessage(getResources().getQuantityString(R.plurals.import_error_dialog, errors.size(), errors.size()))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.setNeutralButton(getString(R.string.details), (dialog, which) -> showDetailedErrorDialog(errors))
|
||||
.create());
|
||||
}
|
||||
|
||||
private void showDetailedErrorDialog(List<DatabaseImporterEntryException> errors) {
|
||||
List<String> messages = new ArrayList<>();
|
||||
for (DatabaseImporterEntryException e : errors) {
|
||||
messages.add(e.getMessage());
|
||||
}
|
||||
|
||||
String message = TextUtils.join("\n\n", messages);
|
||||
Dialogs.showSecureDialog(new AlertDialog.Builder(this)
|
||||
.setTitle(R.string.import_error_title)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.setNeutralButton(android.R.string.copy, (dialog2, which2) -> {
|
||||
ClipboardManager clipboard = (ClipboardManager) this.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
ClipData clip = ClipData.newPlainText("text/plain", message);
|
||||
clipboard.setPrimaryClip(clip);
|
||||
Toast.makeText(this, R.string.errors_copied, Toast.LENGTH_SHORT).show();
|
||||
})
|
||||
.create());
|
||||
}
|
||||
|
||||
private void showWipeEntriesDialog() {
|
||||
Dialogs.showCheckboxDialog(this, R.string.dialog_wipe_entries_title,
|
||||
R.string.dialog_wipe_entries_message,
|
||||
@@ -217,7 +210,7 @@ public class ImportEntriesActivity extends AegisActivity {
|
||||
}
|
||||
|
||||
private void saveAndFinish(boolean wipeEntries) {
|
||||
VaultManager vault = getApp().getVaultManager();
|
||||
VaultRepository vault = _vaultManager.getVault();
|
||||
if (wipeEntries) {
|
||||
vault.wipeEntries();
|
||||
}
|
||||
@@ -234,7 +227,7 @@ public class ImportEntriesActivity extends AegisActivity {
|
||||
vault.addEntry(entry);
|
||||
}
|
||||
|
||||
if (saveVault(true)) {
|
||||
if (saveAndBackupVault()) {
|
||||
String toastMessage = getResources().getQuantityString(R.plurals.imported_entries_count, selectedEntries.size(), selectedEntries.size());
|
||||
Toast.makeText(this, toastMessage, Toast.LENGTH_SHORT).show();
|
||||
|
||||
@@ -254,7 +247,7 @@ public class ImportEntriesActivity extends AegisActivity {
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
onBackPressed();
|
||||
finish();
|
||||
break;
|
||||
case R.id.toggle_checkboxes:
|
||||
_adapter.toggleCheckboxes();
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
package com.beemdevelopment.aegis.ui;
|
||||
|
||||
import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_BIOMETRIC;
|
||||
import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_INVALID;
|
||||
import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_NONE;
|
||||
import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_PASS;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.WindowManager;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.ThemeMap;
|
||||
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
|
||||
@@ -12,23 +21,15 @@ import com.beemdevelopment.aegis.ui.slides.DoneSlide;
|
||||
import com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide;
|
||||
import com.beemdevelopment.aegis.ui.slides.SecuritySetupSlide;
|
||||
import com.beemdevelopment.aegis.ui.slides.WelcomeSlide;
|
||||
import com.beemdevelopment.aegis.vault.Vault;
|
||||
import com.beemdevelopment.aegis.vault.VaultFile;
|
||||
import com.beemdevelopment.aegis.vault.VaultFileCredentials;
|
||||
import com.beemdevelopment.aegis.vault.VaultFileException;
|
||||
import com.beemdevelopment.aegis.vault.VaultManager;
|
||||
import com.beemdevelopment.aegis.vault.VaultManagerException;
|
||||
import com.beemdevelopment.aegis.vault.VaultRepositoryException;
|
||||
import com.beemdevelopment.aegis.vault.slots.BiometricSlot;
|
||||
import com.beemdevelopment.aegis.vault.slots.PasswordSlot;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_BIOMETRIC;
|
||||
import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_INVALID;
|
||||
import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_NONE;
|
||||
import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_PASS;
|
||||
|
||||
public class IntroActivity extends IntroBaseActivity {
|
||||
// Permission request codes
|
||||
private static final int CODE_PERM_NOTIFICATIONS = 0;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -45,7 +46,7 @@ public class IntroActivity extends IntroBaseActivity {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onBeforeSlideChanged(Class<? extends SlideFragment> oldSlide, Class<? extends SlideFragment> newSlide) {
|
||||
protected boolean onBeforeSlideChanged(Class<? extends SlideFragment> oldSlide, @NonNull Class<? extends SlideFragment> newSlide) {
|
||||
// hide the keyboard before every slide change
|
||||
InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
|
||||
imm.hideSoftInputFromWindow(findViewById(android.R.id.content).getWindowToken(), 0);
|
||||
@@ -57,47 +58,67 @@ public class IntroActivity extends IntroBaseActivity {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (oldSlide == WelcomeSlide.class
|
||||
&& newSlide == SecurityPickerSlide.class
|
||||
&& getState().getBoolean("imported")) {
|
||||
skipToSlide(DoneSlide.class);
|
||||
return true;
|
||||
}
|
||||
|
||||
// on the welcome page, we don't want the keyboard to push any views up
|
||||
getWindow().setSoftInputMode(newSlide == WelcomeSlide.class
|
||||
? WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING
|
||||
: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAfterSlideChanged(@Nullable Class<? extends SlideFragment> oldSlide, @NonNull Class<? extends SlideFragment> newSlide) {
|
||||
// If the user has enabled encryption, we need to request permission to show notifications
|
||||
// in order to be able to show the "Vault unlocked" notification.
|
||||
//
|
||||
// NOTE: Disabled for now. See issue: #1047
|
||||
/*if (newSlide == DoneSlide.class && getState().getSerializable("creds") != null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
PermissionHelper.request(this, CODE_PERM_NOTIFICATIONS, Manifest.permission.POST_NOTIFICATIONS);
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDonePressed() {
|
||||
Bundle state = getState();
|
||||
|
||||
int cryptType = state.getInt("cryptType", CRYPT_TYPE_INVALID);
|
||||
VaultFileCredentials creds = (VaultFileCredentials) state.getSerializable("creds");
|
||||
if (cryptType == CRYPT_TYPE_INVALID
|
||||
|| (cryptType == CRYPT_TYPE_NONE && creds != null)
|
||||
|| (cryptType == CRYPT_TYPE_PASS && (creds == null || !creds.getSlots().has(PasswordSlot.class)))
|
||||
|| (cryptType == CRYPT_TYPE_BIOMETRIC && (creds == null || !creds.getSlots().has(PasswordSlot.class) || !creds.getSlots().has(BiometricSlot.class)))) {
|
||||
throw new RuntimeException(String.format("State of SecuritySetupSlide not properly propagated, cryptType: %d, creds: %s", cryptType, creds));
|
||||
}
|
||||
|
||||
Vault vault = new Vault();
|
||||
VaultFile vaultFile = new VaultFile();
|
||||
try {
|
||||
JSONObject obj = vault.toJson();
|
||||
if (cryptType == CRYPT_TYPE_NONE) {
|
||||
vaultFile.setContent(obj);
|
||||
} else {
|
||||
vaultFile.setContent(obj, creds);
|
||||
if (!state.getBoolean("imported")) {
|
||||
int cryptType = state.getInt("cryptType", CRYPT_TYPE_INVALID);
|
||||
if (cryptType == CRYPT_TYPE_INVALID
|
||||
|| (cryptType == CRYPT_TYPE_NONE && creds != null)
|
||||
|| (cryptType == CRYPT_TYPE_PASS && (creds == null || !creds.getSlots().has(PasswordSlot.class)))
|
||||
|| (cryptType == CRYPT_TYPE_BIOMETRIC && (creds == null || !creds.getSlots().has(PasswordSlot.class) || !creds.getSlots().has(BiometricSlot.class)))) {
|
||||
throw new RuntimeException(String.format("State of SecuritySetupSlide not properly propagated, cryptType: %d, creds: %s", cryptType, creds));
|
||||
}
|
||||
|
||||
VaultManager.save(getApplicationContext(), vaultFile);
|
||||
} catch (VaultManagerException | VaultFileException e) {
|
||||
e.printStackTrace();
|
||||
Dialogs.showErrorDialog(this, R.string.vault_init_error, e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cryptType == CRYPT_TYPE_NONE) {
|
||||
getApp().initVaultManager(vault, null);
|
||||
try {
|
||||
_vaultManager.initNew(creds);
|
||||
} catch (VaultRepositoryException e) {
|
||||
e.printStackTrace();
|
||||
Dialogs.showErrorDialog(this, R.string.vault_init_error, e);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
getApp().initVaultManager(vault, creds);
|
||||
try {
|
||||
_vaultManager.load(creds);
|
||||
} catch (VaultRepositoryException e) {
|
||||
e.printStackTrace();
|
||||
Dialogs.showErrorDialog(this, R.string.vault_load_error, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// skip the intro from now on
|
||||
getPreferences().setIntroDone(true);
|
||||
_prefs.setIntroDone(true);
|
||||
|
||||
setResult(RESULT_OK);
|
||||
finish();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,10 @@ import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.beemdevelopment.aegis.Preferences;
|
||||
import com.beemdevelopment.aegis.BuildConfig;
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.crypto.pins.GuardianProjectFDroidRSA2048;
|
||||
import com.beemdevelopment.aegis.vault.VaultManager;
|
||||
import com.beemdevelopment.aegis.vault.VaultRepository;
|
||||
|
||||
import info.guardianproject.GuardianProjectRSA4096;
|
||||
import info.guardianproject.trustedintents.TrustedIntents;
|
||||
@@ -18,22 +18,29 @@ public class PanicResponderActivity extends AegisActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
Preferences prefs = getPreferences();
|
||||
|
||||
if(!prefs.isPanicTriggerEnabled()) {
|
||||
if (!_prefs.isPanicTriggerEnabled()) {
|
||||
Toast.makeText(this, R.string.panic_trigger_ignore_toast, Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
TrustedIntents trustedIntents = TrustedIntents.get(this);
|
||||
trustedIntents.addTrustedSigner(GuardianProjectRSA4096.class);
|
||||
trustedIntents.addTrustedSigner(GuardianProjectFDroidRSA2048.class);
|
||||
Intent intent;
|
||||
if (!BuildConfig.TEST.get()) {
|
||||
TrustedIntents trustedIntents = TrustedIntents.get(this);
|
||||
trustedIntents.addTrustedSigner(GuardianProjectRSA4096.class);
|
||||
trustedIntents.addTrustedSigner(GuardianProjectFDroidRSA2048.class);
|
||||
|
||||
intent = trustedIntents.getIntentFromTrustedSender(this);
|
||||
} else {
|
||||
intent = getIntent();
|
||||
}
|
||||
|
||||
Intent intent = trustedIntents.getIntentFromTrustedSender(this);
|
||||
if (intent != null && PANIC_TRIGGER_ACTION.equals(intent.getAction())) {
|
||||
getApp().lock(false);
|
||||
VaultManager.deleteFile(this);
|
||||
VaultRepository.deleteFile(this);
|
||||
_vaultManager.lock(false);
|
||||
finishApp();
|
||||
return;
|
||||
}
|
||||
|
||||
finish();
|
||||
@@ -41,7 +48,6 @@ public class PanicResponderActivity extends AegisActivity {
|
||||
|
||||
private void finishApp() {
|
||||
ExitActivity.exitAppAndRemoveFromRecents(this);
|
||||
|
||||
finishAndRemoveTask();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ package com.beemdevelopment.aegis.ui;
|
||||
import android.os.Bundle;
|
||||
import android.view.MenuItem;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.preference.Preference;
|
||||
import androidx.preference.PreferenceFragmentCompat;
|
||||
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.ui.fragments.MainPreferencesFragment;
|
||||
import com.beemdevelopment.aegis.ui.fragments.PreferencesFragment;
|
||||
import com.beemdevelopment.aegis.ui.fragments.preferences.MainPreferencesFragment;
|
||||
import com.beemdevelopment.aegis.ui.fragments.preferences.PreferencesFragment;
|
||||
|
||||
public class PreferencesActivity extends AegisActivity implements
|
||||
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
|
||||
@@ -18,8 +20,13 @@ public class PreferencesActivity extends AegisActivity implements
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (abortIfOrphan(savedInstanceState)) {
|
||||
return;
|
||||
}
|
||||
setContentView(R.layout.activity_preferences);
|
||||
setSupportActionBar(findViewById(R.id.toolbar));
|
||||
getSupportFragmentManager()
|
||||
.registerFragmentLifecycleCallbacks(new FragmentResumeListener(), true);
|
||||
|
||||
if (getSupportActionBar() != null) {
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
@@ -30,7 +37,7 @@ public class PreferencesActivity extends AegisActivity implements
|
||||
_fragment = new MainPreferencesFragment();
|
||||
_fragment.setArguments(getIntent().getExtras());
|
||||
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.replace(R.id.content, _fragment)
|
||||
.commit();
|
||||
|
||||
@@ -45,13 +52,7 @@ public class PreferencesActivity extends AegisActivity implements
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
super.onBackPressed();
|
||||
setTitle(R.string.action_settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(final Bundle inState) {
|
||||
protected void onRestoreInstanceState(@NonNull final Bundle inState) {
|
||||
if (_fragment instanceof PreferencesFragment) {
|
||||
// pass the stored result intent back to the fragment
|
||||
if (inState.containsKey("result")) {
|
||||
@@ -62,7 +63,7 @@ public class PreferencesActivity extends AegisActivity implements
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(final Bundle outState) {
|
||||
protected void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||
if (_fragment instanceof PreferencesFragment) {
|
||||
// save the result intent of the fragment
|
||||
// this is done so we don't lose anything if the fragment calls recreate on this activity
|
||||
@@ -74,7 +75,7 @@ public class PreferencesActivity extends AegisActivity implements
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
if (item.getItemId() == android.R.id.home) {
|
||||
onBackPressed();
|
||||
getOnBackPressedDispatcher().onBackPressed();
|
||||
} else {
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
@@ -83,7 +84,7 @@ public class PreferencesActivity extends AegisActivity implements
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPreferenceStartFragment(PreferenceFragmentCompat caller, Preference pref) {
|
||||
public boolean onPreferenceStartFragment(@NonNull PreferenceFragmentCompat caller, Preference pref) {
|
||||
_fragment = getSupportFragmentManager().getFragmentFactory().instantiate(getClassLoader(), pref.getFragment());
|
||||
_fragment.setArguments(pref.getExtras());
|
||||
_fragment.setTargetFragment(caller, 0);
|
||||
@@ -114,4 +115,13 @@ public class PreferencesActivity extends AegisActivity implements
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private class FragmentResumeListener extends FragmentManager.FragmentLifecycleCallbacks {
|
||||
@Override
|
||||
public void onFragmentStarted(@NonNull FragmentManager fm, @NonNull Fragment f) {
|
||||
if (f instanceof MainPreferencesFragment) {
|
||||
setTitle(R.string.action_settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.beemdevelopment.aegis.ui;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.widget.Toast;
|
||||
@@ -51,6 +52,9 @@ public class ScannerActivity extends AegisActivity implements QrCodeAnalyzer.Lis
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (abortIfOrphan(savedInstanceState)) {
|
||||
return;
|
||||
}
|
||||
setContentView(R.layout.activity_scanner);
|
||||
setSupportActionBar(findViewById(R.id.toolbar));
|
||||
|
||||
@@ -85,7 +89,9 @@ public class ScannerActivity extends AegisActivity implements QrCodeAnalyzer.Lis
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
_executor.shutdownNow();
|
||||
if (_executor != null) {
|
||||
_executor.shutdownNow();
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@@ -103,6 +109,10 @@ public class ScannerActivity extends AegisActivity implements QrCodeAnalyzer.Lis
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
if (_cameraProvider == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.getItemId() == R.id.action_camera) {
|
||||
unbindPreview(_cameraProvider);
|
||||
_currentLens = _currentLens == CameraSelector.LENS_FACING_BACK ? CameraSelector.LENS_FACING_FRONT : CameraSelector.LENS_FACING_BACK;
|
||||
@@ -167,23 +177,30 @@ public class ScannerActivity extends AegisActivity implements QrCodeAnalyzer.Lis
|
||||
|
||||
@Override
|
||||
public void onQrCodeDetected(Result result) {
|
||||
if (_analysis != null) {
|
||||
try {
|
||||
Uri uri = Uri.parse(result.getText().trim());
|
||||
if (uri.getScheme() != null && uri.getScheme().equals(GoogleAuthInfo.SCHEME_EXPORT)) {
|
||||
handleExportUri(uri);
|
||||
} else {
|
||||
handleUri(uri);
|
||||
}
|
||||
} catch (GoogleAuthInfoException e) {
|
||||
e.printStackTrace();
|
||||
|
||||
unbindPreview(_cameraProvider);
|
||||
Dialogs.showErrorDialog(this,
|
||||
e.isPhoneFactor() ? R.string.read_qr_error_phonefactor : R.string.read_qr_error,
|
||||
e, ((dialog, which) -> bindPreview(_cameraProvider)));
|
||||
new Handler(getMainLooper()).post(() -> {
|
||||
if (isFinishing()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_analysis != null) {
|
||||
try {
|
||||
Uri uri = Uri.parse(result.getText().trim());
|
||||
if (uri.getScheme() != null && uri.getScheme().equals(GoogleAuthInfo.SCHEME_EXPORT)) {
|
||||
handleExportUri(uri);
|
||||
} else {
|
||||
handleUri(uri);
|
||||
}
|
||||
} catch (GoogleAuthInfoException e) {
|
||||
e.printStackTrace();
|
||||
|
||||
unbindPreview(_cameraProvider);
|
||||
|
||||
Dialogs.showErrorDialog(this,
|
||||
e.isPhoneFactor() ? R.string.read_qr_error_phonefactor : R.string.read_qr_error,
|
||||
e, ((dialog, which) -> bindPreview(_cameraProvider)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleUri(Uri uri) throws GoogleAuthInfoException {
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
package com.beemdevelopment.aegis.ui;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.biometric.BiometricPrompt;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.crypto.KeyStoreHandle;
|
||||
import com.beemdevelopment.aegis.crypto.KeyStoreHandleException;
|
||||
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
|
||||
import com.beemdevelopment.aegis.vault.VaultFileCredentials;
|
||||
import com.beemdevelopment.aegis.vault.slots.BiometricSlot;
|
||||
import com.beemdevelopment.aegis.vault.slots.PasswordSlot;
|
||||
import com.beemdevelopment.aegis.vault.slots.Slot;
|
||||
import com.beemdevelopment.aegis.vault.slots.SlotException;
|
||||
import com.beemdevelopment.aegis.vault.slots.SlotList;
|
||||
import com.beemdevelopment.aegis.helpers.BiometricSlotInitializer;
|
||||
import com.beemdevelopment.aegis.helpers.BiometricsHelper;
|
||||
import com.beemdevelopment.aegis.ui.views.SlotAdapter;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
|
||||
public class SlotManagerActivity extends AegisActivity implements SlotAdapter.Listener {
|
||||
private VaultFileCredentials _creds;
|
||||
private SlotAdapter _adapter;
|
||||
|
||||
private boolean _edited;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_slots);
|
||||
setSupportActionBar(findViewById(R.id.toolbar));
|
||||
_edited = false;
|
||||
|
||||
ActionBar bar = getSupportActionBar();
|
||||
bar.setHomeAsUpIndicator(R.drawable.ic_close);
|
||||
bar.setDisplayHomeAsUpEnabled(true);
|
||||
|
||||
findViewById(R.id.button_add_biometric).setOnClickListener(view -> {
|
||||
if (BiometricsHelper.isAvailable(this)) {
|
||||
BiometricSlotInitializer initializer = new BiometricSlotInitializer(SlotManagerActivity.this, new RegisterBiometricsListener());
|
||||
BiometricPrompt.PromptInfo info = new BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(getString(R.string.add_biometric_slot))
|
||||
.setNegativeButtonText(getString(android.R.string.cancel))
|
||||
.build();
|
||||
initializer.authenticate(info);
|
||||
}
|
||||
});
|
||||
|
||||
findViewById(R.id.button_add_password).setOnClickListener(view -> {
|
||||
Dialogs.showSetPasswordDialog(this, new PasswordListener());
|
||||
});
|
||||
|
||||
// set up the recycler view
|
||||
_adapter = new SlotAdapter(this);
|
||||
RecyclerView slotsView = findViewById(R.id.list_slots);
|
||||
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
|
||||
slotsView.setLayoutManager(layoutManager);
|
||||
slotsView.setAdapter(_adapter);
|
||||
slotsView.setNestedScrollingEnabled(false);
|
||||
|
||||
// load the slots and masterKey
|
||||
_creds = (VaultFileCredentials) getIntent().getSerializableExtra("creds");
|
||||
for (Slot slot : _creds.getSlots()) {
|
||||
_adapter.addSlot(slot);
|
||||
}
|
||||
|
||||
updateBiometricsButton();
|
||||
}
|
||||
|
||||
private void updateBiometricsButton() {
|
||||
// only show the biometrics option if we can get an instance of the biometrics manager
|
||||
// and if none of the slots in the collection has a matching alias in the keystore
|
||||
int visibility = View.VISIBLE;
|
||||
if (BiometricsHelper.isAvailable(this)) {
|
||||
try {
|
||||
KeyStoreHandle keyStore = new KeyStoreHandle();
|
||||
for (BiometricSlot slot : _creds.getSlots().findAll(BiometricSlot.class)) {
|
||||
if (keyStore.containsKey(slot.getUUID().toString())) {
|
||||
visibility = View.GONE;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (KeyStoreHandleException e) {
|
||||
visibility = View.GONE;
|
||||
}
|
||||
} else {
|
||||
visibility = View.GONE;
|
||||
}
|
||||
findViewById(R.id.button_add_biometric).setVisibility(visibility);
|
||||
}
|
||||
|
||||
private void onSave() {
|
||||
Intent intent = new Intent();
|
||||
intent.putExtra("creds", _creds);
|
||||
setResult(RESULT_OK, intent);
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
onBackPressed();
|
||||
break;
|
||||
case R.id.action_save:
|
||||
onSave();
|
||||
break;
|
||||
default:
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
getMenuInflater().inflate(R.menu.menu_slots, menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (!_edited) {
|
||||
super.onBackPressed();
|
||||
return;
|
||||
}
|
||||
|
||||
Dialogs.showDiscardDialog(this,
|
||||
(dialog, which) -> onSave(),
|
||||
(dialog, which) -> super.onBackPressed()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEditSlot(Slot slot) {
|
||||
/*EditText textName = new EditText(this);
|
||||
textName.setHint("Name");
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Edit slot name")
|
||||
.setView(textName)
|
||||
.setPositiveButton(android.R.string.ok, (dialog, whichButton) -> {
|
||||
String name = textName.getText().toString();
|
||||
_edited = true;
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();*/
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRemoveSlot(Slot slot) {
|
||||
SlotList slots = _creds.getSlots();
|
||||
if (slot instanceof PasswordSlot && slots.findAll(PasswordSlot.class).size() <= 1) {
|
||||
Toast.makeText(this, R.string.password_slot_error, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
Dialogs.showSecureDialog(new AlertDialog.Builder(this)
|
||||
.setTitle(R.string.remove_slot)
|
||||
.setMessage(R.string.remove_slot_description)
|
||||
.setPositiveButton(android.R.string.yes, (dialog, whichButton) -> {
|
||||
slots.remove(slot);
|
||||
_adapter.removeSlot(slot);
|
||||
_edited = true;
|
||||
updateBiometricsButton();
|
||||
})
|
||||
.setNegativeButton(android.R.string.no, null)
|
||||
.create());
|
||||
}
|
||||
|
||||
private void addSlot(Slot slot) {
|
||||
_creds.getSlots().add(slot);
|
||||
_adapter.addSlot(slot);
|
||||
_edited = true;
|
||||
updateBiometricsButton();
|
||||
}
|
||||
|
||||
private void showSlotError(String error) {
|
||||
Toast.makeText(SlotManagerActivity.this, getString(R.string.adding_new_slot_error) + error, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
private class RegisterBiometricsListener implements BiometricSlotInitializer.Listener {
|
||||
|
||||
@Override
|
||||
public void onInitializeSlot(BiometricSlot slot, Cipher cipher) {
|
||||
try {
|
||||
slot.setKey(_creds.getKey(), cipher);
|
||||
addSlot(slot);
|
||||
} catch (SlotException e) {
|
||||
onSlotInitializationFailed(0, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSlotInitializationFailed(int errorCode, @NonNull CharSequence errString) {
|
||||
if (!BiometricsHelper.isCanceled(errorCode)) {
|
||||
showSlotError(errString.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class PasswordListener implements Dialogs.SlotListener {
|
||||
|
||||
@Override
|
||||
public void onSlotResult(Slot slot, Cipher cipher) {
|
||||
try {
|
||||
slot.setKey(_creds.getKey(), cipher);
|
||||
} catch (SlotException e) {
|
||||
onException(e);
|
||||
return;
|
||||
}
|
||||
|
||||
addSlot(slot);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onException(Exception e) {
|
||||
showSlotError(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +1,66 @@
|
||||
package com.beemdevelopment.aegis.ui;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipDescription;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Color;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.PersistableBundle;
|
||||
import android.util.TypedValue;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.Theme;
|
||||
import com.beemdevelopment.aegis.helpers.QrCodeHelper;
|
||||
import com.beemdevelopment.aegis.otp.GoogleAuthInfo;
|
||||
import com.beemdevelopment.aegis.otp.GoogleAuthInfoException;
|
||||
import com.beemdevelopment.aegis.otp.Transferable;
|
||||
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
|
||||
import com.beemdevelopment.aegis.vault.VaultManager;
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.WriterException;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import com.google.zxing.qrcode.QRCodeWriter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class TransferEntriesActivity extends AegisActivity {
|
||||
private List<GoogleAuthInfo> _authInfos;
|
||||
private List<Transferable> _authInfos;
|
||||
private ImageView _qrImage;
|
||||
private TextView _description;
|
||||
private TextView _issuer;
|
||||
private TextView _accountName;
|
||||
private TextView _entriesCount;
|
||||
private Button _nextButton;
|
||||
private Button _previousButton;
|
||||
|
||||
private VaultManager _vault;
|
||||
private Button _copyButton;
|
||||
private int _currentEntryCount = 1;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (abortIfOrphan(savedInstanceState)) {
|
||||
return;
|
||||
}
|
||||
setContentView(R.layout.activity_share_entry);
|
||||
setSupportActionBar(findViewById(R.id.toolbar));
|
||||
_vault = getApp().getVaultManager();
|
||||
|
||||
_qrImage = findViewById(R.id.ivQrCode);
|
||||
_description = findViewById(R.id.tvDescription);
|
||||
_issuer = findViewById(R.id.tvIssuer);
|
||||
_accountName = findViewById(R.id.tvAccountName);
|
||||
_entriesCount = findViewById(R.id.tvEntriesCount);
|
||||
_nextButton = findViewById(R.id.btnNext);
|
||||
_previousButton = findViewById(R.id.btnPrevious);
|
||||
_copyButton = findViewById(R.id.btnCopyClipboard);
|
||||
|
||||
if (getSupportActionBar() != null){
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
@@ -58,7 +68,7 @@ public class TransferEntriesActivity extends AegisActivity {
|
||||
}
|
||||
|
||||
Intent intent = getIntent();
|
||||
_authInfos = (ArrayList<GoogleAuthInfo>) intent.getSerializableExtra("authInfos");
|
||||
_authInfos = (ArrayList<Transferable>) intent.getSerializableExtra("authInfos");
|
||||
|
||||
int controlVisibility = _authInfos.size() != 1 ? View.VISIBLE : View.INVISIBLE;
|
||||
_nextButton.setVisibility(controlVisibility);
|
||||
@@ -89,6 +99,30 @@ public class TransferEntriesActivity extends AegisActivity {
|
||||
}
|
||||
});
|
||||
|
||||
if (_authInfos.get(0) instanceof GoogleAuthInfo) {
|
||||
_copyButton.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
_copyButton.setOnClickListener(v -> {
|
||||
Transferable selectedEntry = _authInfos.get(_currentEntryCount - 1);
|
||||
try {
|
||||
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
ClipData clip = ClipData.newPlainText("text/plain", selectedEntry.getUri().toString());
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
PersistableBundle extras = new PersistableBundle();
|
||||
extras.putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true);
|
||||
clip.getDescription().setExtras(extras);
|
||||
}
|
||||
if (clipboard != null) {
|
||||
clipboard.setPrimaryClip(clip);
|
||||
}
|
||||
Toast.makeText(this,R.string.uri_copied_to_clipboard, Toast.LENGTH_SHORT).show();
|
||||
|
||||
} catch (GoogleAuthInfoException e) {
|
||||
Dialogs.showErrorDialog(this, R.string.unable_to_copy_uri_to_clipboard, e);
|
||||
}
|
||||
});
|
||||
|
||||
generateQR();
|
||||
}
|
||||
|
||||
@@ -96,7 +130,7 @@ public class TransferEntriesActivity extends AegisActivity {
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
onBackPressed();
|
||||
finish();
|
||||
break;
|
||||
default:
|
||||
return super.onOptionsItemSelected(item);
|
||||
@@ -105,22 +139,18 @@ public class TransferEntriesActivity extends AegisActivity {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
private void generateQR() {
|
||||
GoogleAuthInfo selectedEntry = _authInfos.get(_currentEntryCount - 1);
|
||||
_issuer.setText(selectedEntry.getIssuer());
|
||||
_accountName.setText(selectedEntry.getAccountName());
|
||||
_entriesCount.setText(getResources().getQuantityString(R.plurals.entries_count, _authInfos.size(), _currentEntryCount, _authInfos.size()));
|
||||
|
||||
QRCodeWriter writer = new QRCodeWriter();
|
||||
BitMatrix bitMatrix;
|
||||
try {
|
||||
bitMatrix = writer.encode(selectedEntry.getUri().toString(), BarcodeFormat.QR_CODE, 512, 512);
|
||||
} catch (WriterException e) {
|
||||
Dialogs.showErrorDialog(this, R.string.unable_to_generate_qrcode, e);
|
||||
return;
|
||||
Transferable selectedEntry = _authInfos.get(_currentEntryCount - 1);
|
||||
if (selectedEntry instanceof GoogleAuthInfo) {
|
||||
GoogleAuthInfo entry = (GoogleAuthInfo) selectedEntry;
|
||||
_issuer.setText(entry.getIssuer());
|
||||
_accountName.setText(entry.getAccountName());
|
||||
} else if (selectedEntry instanceof GoogleAuthInfo.Export) {
|
||||
_description.setText(R.string.google_auth_compatible_transfer_description);
|
||||
}
|
||||
|
||||
_entriesCount.setText(getResources().getQuantityString(R.plurals.qr_count, _authInfos.size(), _currentEntryCount, _authInfos.size()));
|
||||
|
||||
@ColorInt int backgroundColor = Color.WHITE;
|
||||
if (getConfiguredTheme() == Theme.LIGHT) {
|
||||
TypedValue typedValue = new TypedValue();
|
||||
@@ -128,18 +158,14 @@ public class TransferEntriesActivity extends AegisActivity {
|
||||
backgroundColor = typedValue.data;
|
||||
}
|
||||
|
||||
int width = bitMatrix.getWidth();
|
||||
int height = bitMatrix.getHeight();
|
||||
int[] pixels = new int[width * height];
|
||||
for (int y = 0; y < height; y++) {
|
||||
int offset = y * width;
|
||||
for (int x = 0; x < width; x++) {
|
||||
pixels[offset + x] = bitMatrix.get(x, y) ? Color.BLACK : backgroundColor;
|
||||
}
|
||||
Bitmap bitmap;
|
||||
try {
|
||||
bitmap = QrCodeHelper.encodeToBitmap(selectedEntry.getUri().toString(), 512, 512, backgroundColor);
|
||||
} catch (WriterException | GoogleAuthInfoException e) {
|
||||
Dialogs.showErrorDialog(this, R.string.unable_to_generate_qrcode, e);
|
||||
return;
|
||||
}
|
||||
|
||||
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
||||
bitmap.setPixels(pixels, 0, width, 0, 0, width, height);
|
||||
_qrImage.setImageBitmap(bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.beemdevelopment.aegis.ui.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.text.InputType;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.BaseAdapter;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.Filter;
|
||||
import android.widget.Filterable;
|
||||
|
||||
import androidx.annotation.PluralsRes;
|
||||
import androidx.appcompat.widget.AppCompatAutoCompleteTextView;
|
||||
|
||||
import com.beemdevelopment.aegis.R;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class DropdownCheckBoxes extends AppCompatAutoCompleteTextView {
|
||||
private @PluralsRes int _selectedCountPlural = R.plurals.dropdown_checkboxes_default_count;
|
||||
|
||||
private boolean _allowFiltering = false;
|
||||
|
||||
private final List<String> _items = new ArrayList<>();
|
||||
private List<String> _visibleItems = new ArrayList<>();
|
||||
private final Set<String> _checkedItems = new TreeSet<>();
|
||||
|
||||
private CheckboxAdapter _adapter;
|
||||
|
||||
public DropdownCheckBoxes(Context context) {
|
||||
super(context);
|
||||
initialise(context, null);
|
||||
}
|
||||
|
||||
public DropdownCheckBoxes(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initialise(context, attrs);
|
||||
}
|
||||
|
||||
public DropdownCheckBoxes(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
initialise(context, attrs);
|
||||
}
|
||||
|
||||
private void initialise(Context context, AttributeSet attrs) {
|
||||
_adapter = new CheckboxAdapter();
|
||||
setAdapter(_adapter);
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray a = context.obtainStyledAttributes(
|
||||
attrs,
|
||||
R.styleable.DropdownCheckBoxes,
|
||||
0, 0);
|
||||
|
||||
_allowFiltering = a.getBoolean(R.styleable.DropdownCheckBoxes_allow_filtering, false);
|
||||
a.recycle();
|
||||
}
|
||||
|
||||
if (!_allowFiltering) {
|
||||
setInputType(0);
|
||||
} else {
|
||||
setInputType(InputType.TYPE_CLASS_TEXT);
|
||||
}
|
||||
}
|
||||
|
||||
public void addItems(List<String> items, boolean startChecked) {
|
||||
_items.addAll(items);
|
||||
_visibleItems.addAll(items);
|
||||
|
||||
if (startChecked) {
|
||||
_checkedItems.addAll(items);
|
||||
}
|
||||
|
||||
updateCheckedItemsCountText();
|
||||
_adapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
private void updateCheckedItemsCountText() {
|
||||
if (_allowFiltering) {
|
||||
return;
|
||||
}
|
||||
|
||||
int count = _checkedItems.size();
|
||||
String countString = getResources().getQuantityString(_selectedCountPlural, count, count);
|
||||
|
||||
setText(countString, false);
|
||||
}
|
||||
|
||||
public void setCheckedItemsCountTextRes(@PluralsRes int resId) {
|
||||
_selectedCountPlural = resId;
|
||||
}
|
||||
|
||||
public Set<String> getCheckedItems() {
|
||||
return _checkedItems;
|
||||
}
|
||||
|
||||
private class CheckboxAdapter extends BaseAdapter implements Filterable {
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return _visibleItems.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getItem(int i) {
|
||||
return _visibleItems.get(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int i) {
|
||||
return i;
|
||||
}
|
||||
|
||||
@Override
|
||||
public View getView(int i, View convertView, ViewGroup viewGroup) {
|
||||
if (convertView == null) {
|
||||
convertView = LayoutInflater.from(getContext()).inflate(R.layout.dropdown_checkbox, viewGroup, false);
|
||||
}
|
||||
|
||||
String item = _visibleItems.get(i);
|
||||
|
||||
CheckBox checkBox = convertView.findViewById(R.id.checkbox_in_dropdown);
|
||||
checkBox.setText(item);
|
||||
checkBox.setChecked(_checkedItems.contains(item));
|
||||
|
||||
checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> {
|
||||
String label = buttonView.getText().toString();
|
||||
|
||||
if (isChecked) {
|
||||
_checkedItems.add(label);
|
||||
} else {
|
||||
_checkedItems.remove(label);
|
||||
}
|
||||
|
||||
updateCheckedItemsCountText();
|
||||
});
|
||||
|
||||
return convertView;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Filter getFilter() {
|
||||
return new Filter() {
|
||||
@Override
|
||||
protected FilterResults performFiltering(CharSequence query) {
|
||||
FilterResults results = new FilterResults();
|
||||
results.values = (query == null || query.toString().isEmpty())
|
||||
? _items
|
||||
: _items.stream().filter(str -> {
|
||||
String q = query.toString().toLowerCase();
|
||||
String strLower = str.toLowerCase();
|
||||
|
||||
return strLower.contains(q);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void publishResults(CharSequence charSequence, FilterResults filterResults) {
|
||||
_visibleItems = (List<String>) filterResults.values;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.beemdevelopment.aegis.ui.dialogs;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Dialog;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
@@ -8,8 +7,8 @@ import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.graphics.Color;
|
||||
import android.text.Editable;
|
||||
import android.text.InputType;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextWatcher;
|
||||
import android.text.method.PasswordTransformationMethod;
|
||||
import android.view.LayoutInflater;
|
||||
@@ -18,12 +17,12 @@ import android.view.WindowManager;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.CompoundButton;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ListView;
|
||||
import android.widget.NumberPicker;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.ComponentActivity;
|
||||
import androidx.annotation.StringRes;
|
||||
@@ -33,6 +32,7 @@ import com.beemdevelopment.aegis.Preferences;
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.helpers.EditTextHelper;
|
||||
import com.beemdevelopment.aegis.helpers.PasswordStrengthHelper;
|
||||
import com.beemdevelopment.aegis.helpers.SimpleTextWatcher;
|
||||
import com.beemdevelopment.aegis.importers.DatabaseImporter;
|
||||
import com.beemdevelopment.aegis.ui.tasks.KeyDerivationTask;
|
||||
import com.beemdevelopment.aegis.vault.VaultEntry;
|
||||
@@ -44,6 +44,7 @@ import com.google.android.material.textfield.TextInputLayout;
|
||||
import com.nulabinc.zxcvbn.Strength;
|
||||
import com.nulabinc.zxcvbn.Zxcvbn;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -56,39 +57,36 @@ public class Dialogs {
|
||||
}
|
||||
|
||||
public static void secureDialog(Dialog dialog) {
|
||||
dialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);
|
||||
if (new Preferences(dialog.getContext()).isSecureScreenEnabled()) {
|
||||
dialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);
|
||||
}
|
||||
}
|
||||
|
||||
public static void showSecureDialog(Dialog dialog) {
|
||||
if (new Preferences(dialog.getContext()).isSecureScreenEnabled()) {
|
||||
secureDialog(dialog);
|
||||
}
|
||||
secureDialog(dialog);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
public static void showDeleteEntriesDialog(Activity activity, List<VaultEntry> services, DialogInterface.OnClickListener onDelete) {
|
||||
View view = activity.getLayoutInflater().inflate(R.layout.dialog_delete_entry, null);
|
||||
public static void showDeleteEntriesDialog(Context context, List<VaultEntry> services, DialogInterface.OnClickListener onDelete) {
|
||||
View view = LayoutInflater.from(context).inflate(R.layout.dialog_delete_entry, null);
|
||||
TextView textMessage = view.findViewById(R.id.text_message);
|
||||
TextView textExplanation = view.findViewById(R.id.text_explanation);
|
||||
String entries = services.stream()
|
||||
.map(entry -> !entry.getIssuer().isEmpty() ? entry.getIssuer()
|
||||
: !entry.getName().isEmpty() ? entry.getName()
|
||||
: activity.getString(R.string.unknown_issuer)
|
||||
)
|
||||
.collect(Collectors.joining(", "));
|
||||
textExplanation.setText(activity.getString(R.string.delete_entry_explanation, entries));
|
||||
.map(entry -> String.format("• %s", getVaultEntryName(context, entry)))
|
||||
.collect(Collectors.joining("\n"));
|
||||
textExplanation.setText(context.getString(R.string.delete_entry_explanation, entries));
|
||||
|
||||
String title, message;
|
||||
if (services.size() > 1) {
|
||||
title = activity.getString(R.string.delete_entries);
|
||||
message = activity.getResources().getQuantityString(R.plurals.delete_entries_description, services.size(), services.size());
|
||||
title = context.getString(R.string.delete_entries);
|
||||
message = context.getResources().getQuantityString(R.plurals.delete_entries_description, services.size(), services.size());
|
||||
} else {
|
||||
title = activity.getString(R.string.delete_entry);
|
||||
message = activity.getString(R.string.delete_entry_description);
|
||||
title = context.getString(R.string.delete_entry);
|
||||
message = context.getString(R.string.delete_entry_description);
|
||||
}
|
||||
textMessage.setText(message);
|
||||
|
||||
showSecureDialog(new AlertDialog.Builder(activity)
|
||||
showSecureDialog(new AlertDialog.Builder(context)
|
||||
.setTitle(title)
|
||||
.setView(view)
|
||||
.setPositiveButton(android.R.string.yes, onDelete)
|
||||
@@ -96,16 +94,28 @@ public class Dialogs {
|
||||
.create());
|
||||
}
|
||||
|
||||
public static void showDiscardDialog(Activity activity, DialogInterface.OnClickListener onSave, DialogInterface.OnClickListener onDiscard) {
|
||||
showSecureDialog(new AlertDialog.Builder(activity)
|
||||
.setTitle(activity.getString(R.string.discard_changes))
|
||||
.setMessage(activity.getString(R.string.discard_changes_description))
|
||||
private static String getVaultEntryName(Context context, VaultEntry entry) {
|
||||
if (!entry.getIssuer().isEmpty() && !entry.getName().isEmpty()) {
|
||||
return String.format("%s (%s)", entry.getIssuer(), entry.getName());
|
||||
} else if (entry.getIssuer().isEmpty() && entry.getName().isEmpty()) {
|
||||
return context.getString(R.string.unknown_issuer);
|
||||
} else if (entry.getIssuer().isEmpty()) {
|
||||
return entry.getName();
|
||||
} else {
|
||||
return entry.getIssuer();
|
||||
}
|
||||
}
|
||||
|
||||
public static void showDiscardDialog(Context context, DialogInterface.OnClickListener onSave, DialogInterface.OnClickListener onDiscard) {
|
||||
showSecureDialog(new AlertDialog.Builder(context)
|
||||
.setTitle(context.getString(R.string.discard_changes))
|
||||
.setMessage(context.getString(R.string.discard_changes_description))
|
||||
.setPositiveButton(R.string.save, onSave)
|
||||
.setNegativeButton(R.string.discard, onDiscard)
|
||||
.create());
|
||||
}
|
||||
|
||||
public static void showSetPasswordDialog(ComponentActivity activity, Dialogs.SlotListener listener) {
|
||||
public static void showSetPasswordDialog(ComponentActivity activity, PasswordSlotListener listener) {
|
||||
Zxcvbn zxcvbn = new Zxcvbn();
|
||||
View view = activity.getLayoutInflater().inflate(R.layout.dialog_password, null);
|
||||
EditText textPassword = view.findViewById(R.id.text_password);
|
||||
@@ -115,7 +125,7 @@ public class Dialogs {
|
||||
TextInputLayout textPasswordWrapper = view.findViewById(R.id.text_password_wrapper);
|
||||
CheckBox switchToggleVisibility = view.findViewById(R.id.check_toggle_visibility);
|
||||
|
||||
switchToggleVisibility.setOnCheckedChangeListener((CompoundButton.OnCheckedChangeListener) (buttonView, isChecked) -> {
|
||||
switchToggleVisibility.setOnCheckedChangeListener((buttonView, isChecked) -> {
|
||||
if (isChecked) {
|
||||
textPassword.setTransformationMethod(null);
|
||||
textPasswordConfirm.setTransformationMethod(null);
|
||||
@@ -166,28 +176,17 @@ public class Dialogs {
|
||||
});
|
||||
});
|
||||
|
||||
TextWatcher watcher = new TextWatcher() {
|
||||
@Override
|
||||
public void onTextChanged(CharSequence c, int start, int before, int count) {
|
||||
boolean equal = EditTextHelper.areEditTextsEqual(textPassword, textPasswordConfirm);
|
||||
buttonOK.get().setEnabled(equal);
|
||||
TextWatcher watcher = new SimpleTextWatcher(text -> {
|
||||
boolean equal = EditTextHelper.areEditTextsEqual(textPassword, textPasswordConfirm);
|
||||
buttonOK.get().setEnabled(equal);
|
||||
|
||||
Strength strength = zxcvbn.measure(textPassword.getText());
|
||||
barPasswordStrength.setProgress(strength.getScore());
|
||||
barPasswordStrength.setProgressTintList(ColorStateList.valueOf(Color.parseColor(PasswordStrengthHelper.getColor(strength.getScore()))));
|
||||
textPasswordStrength.setText((textPassword.getText().length() != 0) ? PasswordStrengthHelper.getString(strength.getScore(), activity) : "");
|
||||
textPasswordWrapper.setError(strength.getFeedback().getWarning());
|
||||
strength.wipe();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence c, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable c) {
|
||||
}
|
||||
};
|
||||
Strength strength = zxcvbn.measure(textPassword.getText());
|
||||
barPasswordStrength.setProgress(strength.getScore());
|
||||
barPasswordStrength.setProgressTintList(ColorStateList.valueOf(Color.parseColor(PasswordStrengthHelper.getColor(strength.getScore()))));
|
||||
textPasswordStrength.setText((textPassword.getText().length() != 0) ? PasswordStrengthHelper.getString(strength.getScore(), activity) : "");
|
||||
textPasswordWrapper.setError(strength.getFeedback().getWarning());
|
||||
strength.wipe();
|
||||
});
|
||||
textPassword.addTextChangedListener(watcher);
|
||||
textPasswordConfirm.addTextChangedListener(watcher);
|
||||
|
||||
@@ -195,8 +194,14 @@ public class Dialogs {
|
||||
}
|
||||
|
||||
private static void showTextInputDialog(Context context, @StringRes int titleId, @StringRes int messageId, @StringRes int hintId, TextInputListener listener, DialogInterface.OnCancelListener cancelListener, boolean isSecret) {
|
||||
final AtomicReference<Button> buttonOK = new AtomicReference<>();
|
||||
View view = LayoutInflater.from(context).inflate(R.layout.dialog_text_input, null);
|
||||
TextInputEditText input = view.findViewById(R.id.text_input);
|
||||
input.addTextChangedListener(new SimpleTextWatcher(text -> {
|
||||
if (buttonOK.get() != null) {
|
||||
buttonOK.get().setEnabled(!text.toString().isEmpty());
|
||||
}
|
||||
}));
|
||||
TextInputLayout inputLayout = view.findViewById(R.id.text_input_layout);
|
||||
if (isSecret) {
|
||||
input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
@@ -206,10 +211,7 @@ public class Dialogs {
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(context)
|
||||
.setTitle(titleId)
|
||||
.setView(view)
|
||||
.setPositiveButton(android.R.string.ok, (dialog1, which) -> {
|
||||
char[] text = EditTextHelper.getEditTextChars(input);
|
||||
listener.onTextInputResult(text);
|
||||
});
|
||||
.setPositiveButton(android.R.string.ok, null);
|
||||
|
||||
if (cancelListener != null) {
|
||||
builder.setOnCancelListener(cancelListener);
|
||||
@@ -220,6 +222,17 @@ public class Dialogs {
|
||||
}
|
||||
|
||||
AlertDialog dialog = builder.create();
|
||||
dialog.setOnShowListener(d -> {
|
||||
Button button = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
|
||||
button.setEnabled(false);
|
||||
buttonOK.set(button);
|
||||
|
||||
button.setOnClickListener(v -> {
|
||||
char[] text = EditTextHelper.getEditTextChars(input);
|
||||
listener.onTextInputResult(text);
|
||||
dialog.dismiss();
|
||||
});
|
||||
});
|
||||
dialog.setCanceledOnTouchOutside(true);
|
||||
dialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE);
|
||||
showSecureDialog(dialog);
|
||||
@@ -284,15 +297,15 @@ public class Dialogs {
|
||||
showSecureDialog(dialog);
|
||||
}
|
||||
|
||||
public static void showNumberPickerDialog(Activity activity, NumberInputListener listener) {
|
||||
View view = activity.getLayoutInflater().inflate(R.layout.dialog_number_picker, null);
|
||||
public static void showTapToRevealTimeoutPickerDialog(Context context, int currentValue, NumberInputListener listener) {
|
||||
View view = LayoutInflater.from(context).inflate(R.layout.dialog_number_picker, null);
|
||||
NumberPicker numberPicker = view.findViewById(R.id.numberPicker);
|
||||
numberPicker.setMinValue(1);
|
||||
numberPicker.setMaxValue(60);
|
||||
numberPicker.setValue(new Preferences(activity.getApplicationContext()).getTapToRevealTime());
|
||||
numberPicker.setValue(currentValue);
|
||||
numberPicker.setWrapSelectorWheel(true);
|
||||
|
||||
AlertDialog dialog = new AlertDialog.Builder(activity)
|
||||
AlertDialog dialog = new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.set_number)
|
||||
.setView(view)
|
||||
.setPositiveButton(android.R.string.ok, (dialog1, which) ->
|
||||
@@ -302,22 +315,22 @@ public class Dialogs {
|
||||
showSecureDialog(dialog);
|
||||
}
|
||||
|
||||
public static void showBackupVersionsPickerDialog(Activity activity, NumberInputListener listener) {
|
||||
public static void showBackupVersionsPickerDialog(Context context, int currentVersionCount, NumberInputListener listener) {
|
||||
final int max = 30;
|
||||
String[] numbers = new String[max / 5];
|
||||
for (int i = 0; i < numbers.length; i++) {
|
||||
numbers[i] = Integer.toString(i * 5 + 5);
|
||||
}
|
||||
|
||||
View view = activity.getLayoutInflater().inflate(R.layout.dialog_number_picker, null);
|
||||
View view = LayoutInflater.from(context).inflate(R.layout.dialog_number_picker, null);
|
||||
NumberPicker numberPicker = view.findViewById(R.id.numberPicker);
|
||||
numberPicker.setDisplayedValues(numbers);
|
||||
numberPicker.setMaxValue(numbers.length - 1);
|
||||
numberPicker.setMinValue(0);
|
||||
numberPicker.setValue(new Preferences(activity.getApplicationContext()).getBackupsVersionCount() / 5 - 1);
|
||||
numberPicker.setValue(currentVersionCount / 5 - 1);
|
||||
numberPicker.setWrapSelectorWheel(false);
|
||||
|
||||
AlertDialog dialog = new AlertDialog.Builder(activity)
|
||||
AlertDialog dialog = new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.set_number)
|
||||
.setView(view)
|
||||
.setPositiveButton(android.R.string.ok, (dialog1, which) ->
|
||||
@@ -331,6 +344,10 @@ public class Dialogs {
|
||||
showErrorDialog(context, message, e, null);
|
||||
}
|
||||
|
||||
public static void showErrorDialog(Context context, String message, Exception e) {
|
||||
showErrorDialog(context, message, e, null);
|
||||
}
|
||||
|
||||
public static void showErrorDialog(Context context, @StringRes int message, CharSequence error) {
|
||||
showErrorDialog(context, message, error, null);
|
||||
}
|
||||
@@ -339,7 +356,15 @@ public class Dialogs {
|
||||
showErrorDialog(context, message, e.toString(), listener);
|
||||
}
|
||||
|
||||
public static void showErrorDialog(Context context, String message, Exception e, DialogInterface.OnClickListener listener) {
|
||||
showErrorDialog(context, message, e.toString(), listener);
|
||||
}
|
||||
|
||||
public static void showErrorDialog(Context context, @StringRes int message, CharSequence error, DialogInterface.OnClickListener listener) {
|
||||
showErrorDialog(context, context.getString(message), error, listener);
|
||||
}
|
||||
|
||||
public static void showErrorDialog(Context context, String message, CharSequence error, DialogInterface.OnClickListener listener) {
|
||||
View view = LayoutInflater.from(context).inflate(R.layout.dialog_error, null);
|
||||
TextView textDetails = view.findViewById(R.id.error_details);
|
||||
textDetails.setText(error);
|
||||
@@ -379,6 +404,72 @@ public class Dialogs {
|
||||
Dialogs.showSecureDialog(dialog);
|
||||
}
|
||||
|
||||
public static void showBackupErrorDialog(Context context, Preferences.BackupResult backupRes, DialogInterface.OnClickListener listener) {
|
||||
String system = context.getString(backupRes.isBuiltIn() ? R.string.backup_system_builtin : R.string.backup_system_android);
|
||||
String message = context.getString(R.string.backup_error_dialog_details, system, backupRes.getElapsedSince(context));
|
||||
Dialogs.showErrorDialog(context, message, backupRes.getError(), listener);
|
||||
}
|
||||
|
||||
public static void showMultiMessageDialog(
|
||||
Context context, @StringRes int title, String message, List<CharSequence> messages, DialogInterface.OnClickListener listener) {
|
||||
Dialogs.showSecureDialog(new AlertDialog.Builder(context)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
|
||||
if (listener != null) {
|
||||
listener.onClick(dialog, which);
|
||||
}
|
||||
})
|
||||
.setNeutralButton(context.getString(R.string.details), (dialog, which) -> {
|
||||
showDetailedMultiMessageDialog(context, title, messages, listener);
|
||||
})
|
||||
.create());
|
||||
}
|
||||
|
||||
public static <T extends Throwable> void showMultiErrorDialog(
|
||||
Context context, @StringRes int title, String message, List<T> errors, DialogInterface.OnClickListener listener) {
|
||||
List<CharSequence> messages = new ArrayList<>();
|
||||
for (Throwable e : errors) {
|
||||
messages.add(e.toString());
|
||||
}
|
||||
|
||||
showMultiMessageDialog(context, title, message, messages, listener);
|
||||
}
|
||||
|
||||
private static void showDetailedMultiMessageDialog(
|
||||
Context context, @StringRes int title, List<CharSequence> messages, DialogInterface.OnClickListener listener) {
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder();
|
||||
for (CharSequence message : messages) {
|
||||
builder.append(message);
|
||||
builder.append("\n\n");
|
||||
}
|
||||
|
||||
AlertDialog dialog = new AlertDialog.Builder(context)
|
||||
.setTitle(title)
|
||||
.setMessage(builder)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(android.R.string.ok, (dialog1, which) -> {
|
||||
if (listener != null) {
|
||||
listener.onClick(dialog1, which);
|
||||
}
|
||||
})
|
||||
.setNeutralButton(android.R.string.copy, null)
|
||||
.create();
|
||||
|
||||
dialog.setOnShowListener(d -> {
|
||||
Button button = dialog.getButton(AlertDialog.BUTTON_NEUTRAL);
|
||||
button.setOnClickListener(v -> {
|
||||
ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
ClipData clip = ClipData.newPlainText("text/plain", builder.toString());
|
||||
clipboard.setPrimaryClip(clip);
|
||||
Toast.makeText(context, R.string.errors_copied, Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
});
|
||||
|
||||
Dialogs.showSecureDialog(dialog);
|
||||
}
|
||||
|
||||
public static void showTimeSyncWarningDialog(Context context, Dialog.OnClickListener listener) {
|
||||
Preferences prefs = new Preferences(context);
|
||||
View view = LayoutInflater.from(context).inflate(R.layout.dialog_time_sync, null);
|
||||
@@ -431,6 +522,46 @@ public class Dialogs {
|
||||
.create());
|
||||
}
|
||||
|
||||
public static void showPartialGoogleAuthImportWarningDialog(Context context, List<Integer> missingIndexes, int entries, List<CharSequence> scanningErrors, DialogInterface.OnClickListener dismissHandler) {
|
||||
String missingIndexesAsString = missingIndexes.stream()
|
||||
.map(index -> context.getString(R.string.missing_qr_code_descriptor, index + 1))
|
||||
.collect(Collectors.joining("\n"));
|
||||
|
||||
View view = LayoutInflater.from(context).inflate(R.layout.dialog_error, null);
|
||||
TextView errorDetails = view.findViewById(R.id.error_details);
|
||||
for (CharSequence error: scanningErrors) {
|
||||
errorDetails.append(error);
|
||||
errorDetails.append("\n\n");
|
||||
}
|
||||
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.partial_google_auth_import)
|
||||
.setMessage(context.getString(R.string.partial_google_auth_import_warning, missingIndexesAsString))
|
||||
.setView(view)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(context.getString(R.string.import_partial_export_anyway, entries), (dialog, which) -> {
|
||||
dismissHandler.onClick(dialog, which);
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, null);
|
||||
|
||||
if (scanningErrors.size() > 0) {
|
||||
builder.setNeutralButton(R.string.show_error_details, null);
|
||||
}
|
||||
|
||||
AlertDialog dialog = builder.create();
|
||||
dialog.setOnShowListener(d -> {
|
||||
Button btnNeutral = dialog.getButton(DialogInterface.BUTTON_NEUTRAL);
|
||||
if (btnNeutral != null) {
|
||||
btnNeutral.setOnClickListener(b -> {
|
||||
errorDetails.setVisibility(View.VISIBLE);
|
||||
btnNeutral.setVisibility(View.GONE);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
showSecureDialog(dialog);
|
||||
}
|
||||
|
||||
private static void setImporterHelpText(TextView view, DatabaseImporter.Definition definition, boolean isDirect) {
|
||||
if (isDirect) {
|
||||
view.setText(view.getResources().getString(R.string.importer_help_direct, definition.getName()));
|
||||
@@ -451,8 +582,8 @@ public class Dialogs {
|
||||
void onTextInputResult(char[] text);
|
||||
}
|
||||
|
||||
public interface SlotListener {
|
||||
void onSlotResult(Slot slot, Cipher cipher);
|
||||
public interface PasswordSlotListener {
|
||||
void onSlotResult(PasswordSlot slot, Cipher cipher);
|
||||
void onException(Exception e);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,23 +41,23 @@ public abstract class SimpleWebViewDialog extends DialogFragment {
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||
final View view;
|
||||
try {
|
||||
view = LayoutInflater.from(getActivity()).inflate(R.layout.dialog_web_view, null);
|
||||
view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_web_view, null);
|
||||
} catch (InflateException e) {
|
||||
e.printStackTrace();
|
||||
return new AlertDialog.Builder(getActivity())
|
||||
return new AlertDialog.Builder(requireContext())
|
||||
.setTitle(android.R.string.dialog_alert_title)
|
||||
.setMessage(getString(R.string.webview_error))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
AlertDialog dialog = new AlertDialog.Builder(getActivity())
|
||||
AlertDialog dialog = new AlertDialog.Builder(requireContext())
|
||||
.setTitle(_title)
|
||||
.setView(view)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
|
||||
String content = getContent(getContext());
|
||||
String content = getContent(requireContext());
|
||||
final WebView webView = view.findViewById(R.id.web_view);
|
||||
webView.loadData(content, "text/html", "UTF-8");
|
||||
return dialog;
|
||||
@@ -70,11 +70,11 @@ public abstract class SimpleWebViewDialog extends DialogFragment {
|
||||
|
||||
protected String getBackgroundColor() {
|
||||
int backgroundColorResource = _theme == Theme.AMOLED ? R.attr.cardBackgroundFocused : R.attr.cardBackground;
|
||||
return colorToCSS(ThemeHelper.getThemeColor(backgroundColorResource, getContext().getTheme()));
|
||||
return colorToCSS(ThemeHelper.getThemeColor(backgroundColorResource, requireContext().getTheme()));
|
||||
}
|
||||
|
||||
protected String getTextColor() {
|
||||
return colorToCSS(0xFFFFFF & ThemeHelper.getThemeColor(R.attr.primaryText, getContext().getTheme()));
|
||||
return colorToCSS(0xFFFFFF & ThemeHelper.getThemeColor(R.attr.primaryText, requireContext().getTheme()));
|
||||
}
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
package com.beemdevelopment.aegis.ui.fragments;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.preference.Preference;
|
||||
import androidx.preference.SwitchPreferenceCompat;
|
||||
|
||||
import com.beemdevelopment.aegis.Preferences;
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.ui.AegisActivity;
|
||||
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
|
||||
import com.beemdevelopment.aegis.vault.VaultManagerException;
|
||||
|
||||
public class BackupsPreferencesFragment extends PreferencesFragment {
|
||||
private SwitchPreferenceCompat _androidBackupsPreference;
|
||||
private SwitchPreferenceCompat _backupsPreference;
|
||||
private Preference _backupsLocationPreference;
|
||||
private Preference _backupsTriggerPreference;
|
||||
private Preference _backupsVersionsPreference;
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
updateBackupPreference();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
|
||||
super.onCreatePreferences(savedInstanceState, rootKey);
|
||||
addPreferencesFromResource(R.xml.preferences_backups);
|
||||
Preferences prefs = getPreferences();
|
||||
|
||||
_backupsPreference = findPreference("pref_backups");
|
||||
_backupsPreference.setOnPreferenceChangeListener((preference, newValue) -> {
|
||||
if ((boolean) newValue) {
|
||||
selectBackupsLocation();
|
||||
} else {
|
||||
prefs.setIsBackupsEnabled(false);
|
||||
updateBackupPreference();
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
_androidBackupsPreference = findPreference("pref_android_backups");
|
||||
_androidBackupsPreference.setOnPreferenceChangeListener((preference, newValue) -> {
|
||||
prefs.setIsAndroidBackupsEnabled((boolean) newValue);
|
||||
updateBackupPreference();
|
||||
getVault().androidBackupDataChanged();
|
||||
return false;
|
||||
});
|
||||
|
||||
Uri backupLocation = prefs.getBackupsLocation();
|
||||
_backupsLocationPreference = findPreference("pref_backups_location");
|
||||
if (backupLocation != null) {
|
||||
_backupsLocationPreference.setSummary(String.format("%s: %s", getString(R.string.pref_backups_location_summary), Uri.decode(backupLocation.toString())));
|
||||
}
|
||||
_backupsLocationPreference.setOnPreferenceClickListener(preference -> {
|
||||
selectBackupsLocation();
|
||||
return false;
|
||||
});
|
||||
|
||||
_backupsTriggerPreference = findPreference("pref_backups_trigger");
|
||||
_backupsTriggerPreference.setOnPreferenceClickListener(preference -> {
|
||||
if (prefs.isBackupsEnabled()) {
|
||||
try {
|
||||
getVault().backup();
|
||||
Toast.makeText(getActivity(), R.string.backup_successful, Toast.LENGTH_LONG).show();
|
||||
} catch (VaultManagerException e) {
|
||||
e.printStackTrace();
|
||||
Dialogs.showErrorDialog(getContext(), R.string.backup_error, e);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
_backupsVersionsPreference = findPreference("pref_backups_versions");
|
||||
_backupsVersionsPreference.setSummary(getResources().getQuantityString(R.plurals.pref_backups_versions_summary, prefs.getBackupsVersionCount(), prefs.getBackupsVersionCount()));
|
||||
_backupsVersionsPreference.setOnPreferenceClickListener(preference -> {
|
||||
Dialogs.showBackupVersionsPickerDialog(getActivity(), number -> {
|
||||
number = number * 5 + 5;
|
||||
prefs.setBackupsVersionCount(number);
|
||||
_backupsVersionsPreference.setSummary(getResources().getQuantityString(R.plurals.pref_backups_versions_summary, prefs.getBackupsVersionCount(), prefs.getBackupsVersionCount()));
|
||||
});
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
if (data != null && requestCode == CODE_BACKUPS) {
|
||||
onSelectBackupsLocationResult(resultCode, data);
|
||||
}
|
||||
}
|
||||
|
||||
private void onSelectBackupsLocationResult(int resultCode, Intent data) {
|
||||
Uri uri = data.getData();
|
||||
if (resultCode != Activity.RESULT_OK || uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int flags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
getContext().getContentResolver().takePersistableUriPermission(data.getData(), flags);
|
||||
|
||||
Preferences prefs = getPreferences();
|
||||
prefs.setBackupsLocation(uri);
|
||||
prefs.setIsBackupsEnabled(true);
|
||||
prefs.setBackupsError(null);
|
||||
_backupsLocationPreference.setSummary(String.format("%s: %s", getString(R.string.pref_backups_location_summary), Uri.decode(uri.toString())));
|
||||
updateBackupPreference();
|
||||
}
|
||||
|
||||
private void updateBackupPreference() {
|
||||
boolean encrypted = getVault().isEncryptionEnabled();
|
||||
boolean androidBackupEnabled = getPreferences().isAndroidBackupsEnabled() && encrypted;
|
||||
boolean backupEnabled = getPreferences().isBackupsEnabled() && encrypted;
|
||||
_androidBackupsPreference.setChecked(androidBackupEnabled);
|
||||
_androidBackupsPreference.setEnabled(encrypted);
|
||||
_backupsPreference.setChecked(backupEnabled);
|
||||
_backupsPreference.setEnabled(encrypted);
|
||||
_backupsLocationPreference.setVisible(backupEnabled);
|
||||
_backupsTriggerPreference.setVisible(backupEnabled);
|
||||
_backupsVersionsPreference.setVisible(backupEnabled);
|
||||
}
|
||||
|
||||
private void selectBackupsLocation() {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
|
||||
|
||||
AegisActivity.Helper.startExtActivityForResult(this, intent, CODE_BACKUPS);
|
||||
}
|
||||
}
|
||||
-359
@@ -1,359 +0,0 @@
|
||||
package com.beemdevelopment.aegis.ui.fragments;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.AutoCompleteTextView;
|
||||
import android.widget.Button;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.ArrayRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.content.FileProvider;
|
||||
import androidx.preference.Preference;
|
||||
|
||||
import com.beemdevelopment.aegis.BuildConfig;
|
||||
import com.beemdevelopment.aegis.R;
|
||||
import com.beemdevelopment.aegis.helpers.DropdownHelper;
|
||||
import com.beemdevelopment.aegis.importers.DatabaseImporter;
|
||||
import com.beemdevelopment.aegis.ui.AegisActivity;
|
||||
import com.beemdevelopment.aegis.ui.ImportEntriesActivity;
|
||||
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
|
||||
import com.beemdevelopment.aegis.ui.tasks.ExportTask;
|
||||
import com.beemdevelopment.aegis.ui.tasks.ImportFileTask;
|
||||
import com.beemdevelopment.aegis.vault.VaultBackupManager;
|
||||
import com.beemdevelopment.aegis.vault.VaultFileCredentials;
|
||||
import com.beemdevelopment.aegis.vault.VaultManager;
|
||||
import com.beemdevelopment.aegis.vault.VaultManagerException;
|
||||
import com.beemdevelopment.aegis.vault.slots.Slot;
|
||||
import com.beemdevelopment.aegis.vault.slots.SlotException;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
|
||||
public class ImportExportPreferencesFragment extends PreferencesFragment {
|
||||
// keep a reference to the type of database converter that was selected
|
||||
private Class<? extends DatabaseImporter> _importerType;
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
|
||||
super.onCreatePreferences(savedInstanceState, rootKey);
|
||||
addPreferencesFromResource(R.xml.preferences_import_export);
|
||||
|
||||
if (savedInstanceState != null) {
|
||||
_importerType = (Class<? extends DatabaseImporter>) savedInstanceState.getSerializable("importerType");
|
||||
}
|
||||
|
||||
Preference importPreference = findPreference("pref_import");
|
||||
importPreference.setOnPreferenceClickListener(preference -> {
|
||||
Dialogs.showImportersDialog(getContext(), false, definition -> {
|
||||
_importerType = definition.getType();
|
||||
|
||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
intent.setType("*/*");
|
||||
AegisActivity.Helper.startExtActivityForResult(this, intent, CODE_IMPORT_SELECT);
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
Preference importAppPreference = findPreference("pref_import_app");
|
||||
importAppPreference.setOnPreferenceClickListener(preference -> {
|
||||
Dialogs.showImportersDialog(getContext(), true, definition -> {
|
||||
startImportEntriesActivity(definition.getType(), null);
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
Preference exportPreference = findPreference("pref_export");
|
||||
exportPreference.setOnPreferenceClickListener(preference -> {
|
||||
startExport();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(@NonNull Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putSerializable("importerType", _importerType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
if (requestCode == CODE_IMPORT) {
|
||||
getResult().putExtra("needsRecreate", true);
|
||||
} else if (data != null) {
|
||||
switch (requestCode) {
|
||||
case CODE_IMPORT_SELECT:
|
||||
onImportSelectResult(resultCode, data);
|
||||
break;
|
||||
case CODE_EXPORT:
|
||||
// intentional fallthrough
|
||||
case CODE_EXPORT_PLAIN:
|
||||
// intentional fallthrough
|
||||
case CODE_EXPORT_GOOGLE_URI:
|
||||
onExportResult(requestCode, resultCode, data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void onImportSelectResult(int resultCode, Intent data) {
|
||||
Uri uri = data.getData();
|
||||
if (resultCode != Activity.RESULT_OK || uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImportFileTask.Params params = new ImportFileTask.Params(uri, "import", null);
|
||||
ImportFileTask task = new ImportFileTask(getContext(), result -> {
|
||||
if (result.getException() == null) {
|
||||
startImportEntriesActivity(_importerType, result.getFile());
|
||||
} else {
|
||||
Dialogs.showErrorDialog(getContext(), R.string.reading_file_error, result.getException());
|
||||
}
|
||||
});
|
||||
task.execute(getLifecycle(), params);
|
||||
}
|
||||
|
||||
private void startImportEntriesActivity(Class<? extends DatabaseImporter> importerType, File file) {
|
||||
Intent intent = new Intent(getActivity(), ImportEntriesActivity.class);
|
||||
intent.putExtra("importerType", importerType);
|
||||
intent.putExtra("file", file);
|
||||
startActivityForResult(intent, CODE_IMPORT);
|
||||
}
|
||||
|
||||
private void startExport() {
|
||||
View view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_export, null);
|
||||
TextView warningText = view.findViewById(R.id.text_export_warning);
|
||||
CheckBox checkBoxEncrypt = view.findViewById(R.id.checkbox_export_encrypt);
|
||||
CheckBox checkBoxAccept = view.findViewById(R.id.checkbox_accept);
|
||||
AutoCompleteTextView dropdown = view.findViewById(R.id.dropdown_export_format);
|
||||
DropdownHelper.fillDropdown(getContext(), dropdown, R.array.export_formats);
|
||||
dropdown.setText(getString(R.string.export_format_aegis), false);
|
||||
dropdown.setOnItemClickListener((parent, view1, position, id) -> {
|
||||
checkBoxEncrypt.setChecked(position == 0);
|
||||
checkBoxEncrypt.setEnabled(position == 0);
|
||||
warningText.setVisibility(checkBoxEncrypt.isChecked() ? View.GONE : View.VISIBLE);
|
||||
});
|
||||
|
||||
AlertDialog dialog = new AlertDialog.Builder(getContext())
|
||||
.setTitle(R.string.pref_export_summary)
|
||||
.setView(view)
|
||||
.setNeutralButton(R.string.share, null)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create();
|
||||
|
||||
dialog.setOnShowListener(d -> {
|
||||
Button btnPos = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
|
||||
Button btnNeutral = dialog.getButton(AlertDialog.BUTTON_NEUTRAL);
|
||||
|
||||
checkBoxEncrypt.setOnCheckedChangeListener((buttonView, isChecked) -> {
|
||||
warningText.setVisibility(isChecked ? View.GONE : View.VISIBLE);
|
||||
checkBoxAccept.setVisibility(isChecked ? View.GONE : View.VISIBLE);
|
||||
checkBoxAccept.setChecked(false);
|
||||
btnPos.setEnabled(isChecked);
|
||||
btnNeutral.setEnabled(isChecked);
|
||||
});
|
||||
|
||||
checkBoxAccept.setOnCheckedChangeListener((buttonView, isChecked) -> {
|
||||
btnPos.setEnabled(isChecked);
|
||||
btnNeutral.setEnabled(isChecked);
|
||||
});
|
||||
|
||||
btnPos.setOnClickListener(v -> {
|
||||
dialog.dismiss();
|
||||
|
||||
if (!checkBoxEncrypt.isChecked() && !checkBoxAccept.isChecked()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int pos = getStringResourceIndex(R.array.export_formats, dropdown.getText().toString());
|
||||
int requestCode = getExportRequestCode(pos, checkBoxEncrypt.isChecked());
|
||||
VaultBackupManager.FileInfo fileInfo = getExportFileInfo(pos, checkBoxEncrypt.isChecked());
|
||||
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.setType(getExportMimeType(requestCode))
|
||||
.putExtra(Intent.EXTRA_TITLE, fileInfo.toString());
|
||||
AegisActivity.Helper.startExtActivityForResult(this, intent, requestCode);
|
||||
});
|
||||
|
||||
btnNeutral.setOnClickListener(v -> {
|
||||
dialog.dismiss();
|
||||
|
||||
int pos = getStringResourceIndex(R.array.export_formats, dropdown.getText().toString());
|
||||
if (!checkBoxEncrypt.isChecked() && !checkBoxAccept.isChecked()) {
|
||||
return;
|
||||
}
|
||||
|
||||
File file;
|
||||
try {
|
||||
VaultBackupManager.FileInfo fileInfo = getExportFileInfo(pos, checkBoxEncrypt.isChecked());
|
||||
file = File.createTempFile(fileInfo.getFilename() + "-", "." + fileInfo.getExtension(), getExportCacheDir());
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
Dialogs.showErrorDialog(getContext(), R.string.exporting_vault_error, e);
|
||||
return;
|
||||
}
|
||||
|
||||
int requestCode = getExportRequestCode(pos, checkBoxEncrypt.isChecked());
|
||||
startExportVault(requestCode, cb -> {
|
||||
try (OutputStream stream = new FileOutputStream(file)) {
|
||||
cb.exportVault(stream);
|
||||
} catch (IOException | VaultManagerException e) {
|
||||
e.printStackTrace();
|
||||
Dialogs.showErrorDialog(getContext(), R.string.exporting_vault_error, e);
|
||||
return;
|
||||
}
|
||||
|
||||
Uri uri = FileProvider.getUriForFile(getContext(), BuildConfig.FILE_PROVIDER_AUTHORITY, file);
|
||||
Intent intent = new Intent(Intent.ACTION_SEND)
|
||||
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.setType(getExportMimeType(requestCode))
|
||||
.putExtra(Intent.EXTRA_STREAM, uri);
|
||||
Intent chooser = Intent.createChooser(intent, getString(R.string.pref_export_summary));
|
||||
AegisActivity.Helper.startExtActivity(this, chooser);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Dialogs.showSecureDialog(dialog);
|
||||
}
|
||||
|
||||
private static int getExportRequestCode(int spinnerPos, boolean encrypt) {
|
||||
if (spinnerPos == 0) {
|
||||
return encrypt ? CODE_EXPORT : CODE_EXPORT_PLAIN;
|
||||
}
|
||||
|
||||
return CODE_EXPORT_GOOGLE_URI;
|
||||
}
|
||||
|
||||
private static VaultBackupManager.FileInfo getExportFileInfo(int spinnerPos, boolean encrypt) {
|
||||
if (spinnerPos == 0) {
|
||||
String filename = encrypt ? VaultManager.FILENAME_PREFIX_EXPORT : VaultManager.FILENAME_PREFIX_EXPORT_PLAIN;
|
||||
return new VaultBackupManager.FileInfo(filename);
|
||||
}
|
||||
|
||||
return new VaultBackupManager.FileInfo(VaultManager.FILENAME_PREFIX_EXPORT_URI, "txt");
|
||||
}
|
||||
|
||||
private static String getExportMimeType(int requestCode) {
|
||||
return requestCode == CODE_EXPORT_GOOGLE_URI ? "text/plain" : "application/json";
|
||||
}
|
||||
|
||||
private File getExportCacheDir() throws IOException {
|
||||
File dir = new File(getContext().getCacheDir(), "export");
|
||||
if (!dir.exists() && !dir.mkdir()) {
|
||||
throw new IOException(String.format("Unable to create directory %s", dir));
|
||||
}
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
private void startExportVault(int requestCode, StartExportCallback cb) {
|
||||
switch (requestCode) {
|
||||
case CODE_EXPORT:
|
||||
if (getVault().isEncryptionEnabled()) {
|
||||
cb.exportVault(stream -> getVault().export(stream));
|
||||
} else {
|
||||
Dialogs.showSetPasswordDialog(getActivity(), new Dialogs.SlotListener() {
|
||||
@Override
|
||||
public void onSlotResult(Slot slot, Cipher cipher) {
|
||||
VaultFileCredentials creds = new VaultFileCredentials();
|
||||
|
||||
try {
|
||||
slot.setKey(creds.getKey(), cipher);
|
||||
creds.getSlots().add(slot);
|
||||
} catch (SlotException e) {
|
||||
onException(e);
|
||||
return;
|
||||
}
|
||||
|
||||
cb.exportVault(stream -> getVault().export(stream, creds));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onException(Exception e) {
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
case CODE_EXPORT_PLAIN:
|
||||
cb.exportVault((stream) -> getVault().export(stream, null));
|
||||
break;
|
||||
case CODE_EXPORT_GOOGLE_URI:
|
||||
cb.exportVault((stream) -> getVault().exportGoogleUris(stream));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void onExportResult(int requestCode, int resultCode, Intent data) {
|
||||
Uri uri = data.getData();
|
||||
if (resultCode != Activity.RESULT_OK || uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
startExportVault(requestCode, cb -> {
|
||||
File file;
|
||||
OutputStream outStream = null;
|
||||
try {
|
||||
file = File.createTempFile(VaultManager.FILENAME_PREFIX_EXPORT + "-", ".json", getExportCacheDir());
|
||||
outStream = new FileOutputStream(file);
|
||||
cb.exportVault(outStream);
|
||||
|
||||
new ExportTask(getContext(), new ExportResultListener()).execute(getLifecycle(), new ExportTask.Params(file, uri));
|
||||
} catch (VaultManagerException | IOException e) {
|
||||
e.printStackTrace();
|
||||
Dialogs.showErrorDialog(getContext(), R.string.exporting_vault_error, e);
|
||||
} finally {
|
||||
try {
|
||||
if (outStream != null) {
|
||||
outStream.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private int getStringResourceIndex(@ArrayRes int id, String string) {
|
||||
String[] res = getResources().getStringArray(id);
|
||||
for (int i = 0; i < res.length; i++) {
|
||||
if (res[i].equalsIgnoreCase(string)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private class ExportResultListener implements ExportTask.Callback {
|
||||
@Override
|
||||
public void onTaskFinished(Exception e) {
|
||||
if (e != null) {
|
||||
e.printStackTrace();
|
||||
Dialogs.showErrorDialog(getContext(), R.string.exporting_vault_error, e);
|
||||
} else {
|
||||
Toast.makeText(getContext(), getString(R.string.exported_vault), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private interface FinishExportCallback {
|
||||
void exportVault(OutputStream stream) throws IOException, VaultManagerException;
|
||||
}
|
||||
|
||||
private interface StartExportCallback {
|
||||
void exportVault(FinishExportCallback exportCb);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user